from django.conf import settings from django.core.files.base import ContentFile from django.shortcuts import get_object_or_404 from django.db import transaction from datetime import date, datetime, time as datetime_time, timedelta from math import ceil from django.db.models import Q, F, Count, Max from django.db.models.functions import Lower from django.utils.text import slugify from django.utils import timezone from ninja import File, Router, Query, UploadedFile from ninja.errors import HttpError from typing import List, Literal, Optional from uuid import UUID, uuid4 from apps.events.api.schemas import ( EventAdminDetailSchema, EventBriefSchema, EventCreateSchema, EventGallerySchema, EventListSchema, PaginatedEventBrowseSchema, EventSchema, EventUpdateSchema, MyEventRegistrationOut, PaginatedMyEventRegistrationsSchema, PaginatedRegistrationSchema, RegisterationDetailSchema, RegistrationCreateSchema, RegistrationSchema, RegistrationStatusOut, RegistrationHubOut, RegistrationStatusUpdateSchema, ) from apps.events.event_status import ( PUBLIC_EVENT_STATUSES, active_registration_count_annotation, event_status_filter, ) from core.authentication import jwt_auth from apps.events.models import Event, Registration from apps.events.checkout import ( CHECKOUT_TIMEOUT, cancel_event_checkouts, expire_user_registration_checkouts, mark_checkout_cancelled, queue_checkout_reconciliation, ) from apps.gallery.models import Gallery from apps.gallery.tasks import process_uploaded_image from apps.notifications.services import notify_user from apps.payments.models import DiscountCode, Payment from apps.users.tasks import send_critical_sms from core.api.schemas import ErrorSchema, MessageSchema events_router = Router() def _is_staff_user(user) -> bool: return bool(user and (user.is_staff or user.is_superuser)) def _staff_forbidden(): return 403, {"error": "اجازه دسترسی ندارید."} def _normalize_status_values(values): if not values: return [] if isinstance(values, str): values = [values] return [part.strip() for value in values for part in value.split(",") if part.strip()] def _save_uploaded_image(instance, field_name: str, file: UploadedFile, folder: str): if not file.content_type or not file.content_type.startswith("image/"): return False, {"error": "فایل باید تصویر باشد."} if file.size > 10 * 1024 * 1024: return False, {"error": "حجم فایل باید کمتر از ۱۰ مگابایت باشد."} extension = file.name.rsplit(".", 1)[-1] if "." in file.name else "jpg" getattr(instance, field_name).save( f"{folder}/{uuid4().hex}.{extension}", ContentFile(file.read()), save=True, ) return True, instance def _frontend_event_url(event: Event) -> str: root = getattr(settings, "FRONTEND_ROOT", "/") or "/" if not root.endswith("/"): root = f"{root}/" return f"{root}events/{event.slug or event.id}" def _notify_event_update( event: Event, *, notification_type: str, title: str, message: str, level: str, sms_kind: str | None = None, ): recipients = ( Registration.objects.filter(event=event, is_deleted=False) .exclude(status=Registration.StatusChoices.CANCELLED) .select_related("user") ) for registration in recipients: notify_user( registration.user_id, { "type": notification_type, "title": title, "message": message, "level": level, "action_url": _frontend_event_url(event), "entity_type": "event", "entity_id": event.id, "meta": {"event_status": event.status}, }, ) if sms_kind and registration.user.mobile and registration.user.is_mobile_verified: send_critical_sms.delay(registration.user.mobile, sms_kind, event.title) # Event endpoints @events_router.get("/", response=List[EventListSchema]) def list_events( request, # status: Optional[str] = None, status: Optional[List[str]] = Query(None), event_type: Optional[str] = None, search: Optional[str] = None, limit: int = 20, offset: int = 0 ): """List published events with optional derived-status filtering.""" now = timezone.now() queryset = Event.objects.filter(is_deleted=False, is_published=True).prefetch_related('gallery_images').annotate( browse_registration_count=active_registration_count_annotation(now=now), ) if status: parts = _normalize_status_values(status) invalid_statuses = set(parts) - set(PUBLIC_EVENT_STATUSES) if invalid_statuses: raise HttpError(400, "وضعیت رویداد معتبر نیست.") matching_statuses = Q() for event_status in parts: matching_statuses |= event_status_filter(event_status, now=now) queryset = queryset.filter(matching_statuses) if event_type: queryset = queryset.filter(event_type=event_type) if search: queryset = queryset.filter( Q(title__icontains=search) | Q(description__icontains=search) ) queryset = queryset.order_by('-start_time', '-id') events = queryset[offset:offset + limit] return events @events_router.get("/admin-list/", response={200: List[EventListSchema], 403: ErrorSchema}, auth=jwt_auth) def list_admin_events( request, status: Optional[List[str]] = Query(None), event_type: Optional[str] = None, search: Optional[str] = None, limit: int = 20, offset: int = 0, ): """List active events for staff, including unpublished drafts.""" if not _is_staff_user(request.auth): return _staff_forbidden() now = timezone.now() queryset = Event.objects.filter(is_deleted=False).prefetch_related("gallery_images") if status: parts = _normalize_status_values(status) invalid_statuses = set(parts) - set(PUBLIC_EVENT_STATUSES) - {"draft"} if invalid_statuses: raise HttpError(400, "وضعیت رویداد معتبر نیست.") matching_statuses = Q() if "draft" in parts: matching_statuses |= Q(is_published=False) queryset = queryset.annotate( browse_registration_count=active_registration_count_annotation(now=now) ) for event_status in set(parts) - {"draft"}: matching_statuses |= Q(is_published=True) & event_status_filter(event_status, now=now) queryset = queryset.filter(matching_statuses) if event_type: queryset = queryset.filter(event_type=event_type) if search: queryset = queryset.filter(Q(title__icontains=search) | Q(description__icontains=search)) queryset = queryset.annotate( browse_registration_count=active_registration_count_annotation(now=now), ).order_by("-is_published", "-start_time", "-id") return queryset[offset:offset + limit] def _event_registration_open_query(now): return ( Q(is_published=True, is_cancelled=False, start_time__gt=now) & (Q(registration_start_date__isnull=True) | Q(registration_start_date__lte=now)) & (Q(registration_end_date__isnull=True) | Q(registration_end_date__gt=now)) & (Q(capacity__isnull=True) | Q(capacity__gt=F("browse_registration_count"))) ) def _parse_browse_date(value: Optional[str], field_name: str): if not value: return None try: parsed = date.fromisoformat(value) except ValueError as error: raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.") from error if parsed.isoformat() != value: raise HttpError(400, f"{field_name} باید به شکل YYYY-MM-DD باشد.") return timezone.make_aware( datetime.combine(parsed, datetime_time.min), timezone.get_current_timezone(), ) @events_router.get("/browse/", response=PaginatedEventBrowseSchema) def browse_events( request, state: Literal[ "all", "cancelled", "completed", "in_progress", "registration_closed", "full", "registration_open", "upcoming", ] = "all", event_type: Optional[str] = None, search: Optional[str] = None, date_from: Optional[str] = None, date_to: Optional[str] = None, price_min: Optional[int] = None, price_max: Optional[int] = None, free_only: bool = False, page: int = 1, ): """Browse public events; price bounds and ceiling are in tomans, stored event prices are rials.""" page_size = 12 if page < 1: raise HttpError(400, "شماره صفحه باید بزرگ‌تر از صفر باشد.") if event_type and event_type not in Event.TypeChoices.values: raise HttpError(400, "نوع رویداد معتبر نیست.") if price_min is not None and price_min < 0: raise HttpError(400, "حداقل قیمت نمی‌تواند منفی باشد.") if price_max is not None and price_max < 0: raise HttpError(400, "حداکثر قیمت نمی‌تواند منفی باشد.") if price_min is not None and price_max is not None and price_min > price_max: raise HttpError(400, "حداقل قیمت نمی‌تواند از حداکثر قیمت بیشتر باشد.") selected_from = _parse_browse_date(date_from, "date_from") selected_to = _parse_browse_date(date_to, "date_to") if selected_from and selected_to and selected_from > selected_to: raise HttpError(400, "تاریخ شروع نمی‌تواند بعد از تاریخ پایان باشد.") public_events = Event.objects.filter(is_deleted=False, is_published=True) highest_price_rial = public_events.aggregate(value=Max("price"))["value"] or 0 price_ceiling = ceil(highest_price_rial / 10 / 1_000_000) * 1_000_000 now = timezone.now() queryset = ( public_events .annotate( browse_registration_count=active_registration_count_annotation(now=now), ) .prefetch_related("gallery_images") ) if state != "all": queryset = queryset.filter(event_status_filter(state, now=now)) if event_type: queryset = queryset.filter(event_type=event_type) if search and search.strip(): queryset = queryset.filter( Q(title__icontains=search.strip()) | Q(description__icontains=search.strip()) ) if selected_from: queryset = queryset.filter(start_time__gte=selected_from) if selected_to: queryset = queryset.filter(start_time__lt=selected_to + timedelta(days=1)) if free_only: queryset = queryset.filter(Q(price__isnull=True) | Q(price__lte=0)) else: if price_min is not None: queryset = queryset.filter(price__gte=price_min * 10) if price_max is not None: queryset = queryset.filter(price__lte=price_max * 10) queryset = queryset.order_by("-start_time", "-id") count = queryset.count() total_pages = ceil(count / page_size) if count else 0 current_page = min(page, total_pages) if total_pages else 1 results = queryset[(current_page - 1) * page_size:current_page * page_size] return { "count": count, "page": current_page, "page_size": page_size, "total_pages": total_pages, "price_ceiling": price_ceiling, "results": results, } @events_router.get("/{int:event_id}", response=EventSchema) def get_event(request, event_id: int): """Get event details by ID""" queryset = Event.objects.prefetch_related('gallery_images').filter(id=event_id, is_deleted=False) if not _is_staff_user(getattr(request, "auth", None)): queryset = queryset.filter(is_published=True) event = get_object_or_404(queryset) return event @events_router.get("/slug/{str:slug}", response=EventSchema) def get_event_by_slug(request, slug: str): """Get event details by slug""" queryset = Event.objects.prefetch_related('gallery_images').filter(slug=slug, is_deleted=False) if not _is_staff_user(getattr(request, "auth", None)): queryset = queryset.filter(is_published=True) event = get_object_or_404(queryset) return event @events_router.post("/", response={201: EventSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def create_event(request, payload: EventCreateSchema): """Create a new event""" if not _is_staff_user(request.auth): return _staff_forbidden() data = payload.dict(exclude={'gallery_image_ids'}) gallery_image_ids = payload.gallery_image_ids or [] if data.get("slug"): data["slug"] = slugify(data["slug"]) event = Event(**data) try: event.full_clean() event.save() except Exception as exc: return 400, {"error": str(exc)} if gallery_image_ids: event.gallery_images.set(gallery_image_ids) return 201, event @events_router.put("/{int:event_id}", response={200: EventSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def update_event(request, event_id: int, payload: EventUpdateSchema): """Update an existing event""" if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) previous_state = { "is_published": event.is_published, "is_cancelled": event.is_cancelled, "start_time": event.start_time, "end_time": event.end_time, "address": event.address, "location": event.location, "online_link": event.online_link, } update_data = payload.dict(exclude_unset=True) gallery_image_ids = update_data.pop('gallery_image_ids', None) for attr, value in update_data.items(): if attr == "slug" and value: value = slugify(value) setattr(event, attr, value) if 'title' in update_data and not update_data.get("slug"): event.slug = slugify(event.title) try: event.full_clean() event.save() except Exception as exc: return 400, {"error": str(exc)} if gallery_image_ids is not None: event.gallery_images.set(gallery_image_ids) schedule_changed = any( previous_state[field] != getattr(event, field) for field in ("start_time", "end_time", "address", "location", "online_link") ) cancelled_now = not previous_state["is_cancelled"] and event.is_cancelled if cancelled_now: cancel_event_checkouts(event.id) _notify_event_update( event, notification_type="event_cancelled", title=f"رویداد «{event.title}» لغو شد", message="این رویداد لغو شده است. برای جزئیات بیشتر صفحه رویداد را بررسی کنید.", level="warning", sms_kind="event_cancellation", ) elif schedule_changed: _notify_event_update( event, notification_type="event_rescheduled", title=f"جزئیات برگزاری «{event.title}» تغییر کرد", message="جزئیات زمان‌بندی یا محل برگزاری این رویداد به‌روزرسانی شده است.", level="info", sms_kind="event_reschedule", ) return 200, event @events_router.post("/{int:event_id}/checkout/cancel", response=MessageSchema, auth=jwt_auth) def cancel_registration_checkout(request, event_id: int): """Cancel the user's active paid checkout and release its seat.""" with transaction.atomic(): event = get_object_or_404( Event.objects.select_for_update(), id=event_id, is_deleted=False, ) registration = ( Registration.objects.select_for_update() .filter( event=event, user=request.auth, status=Registration.StatusChoices.PENDING, checkout_expires_at__isnull=False, is_deleted=False, ) .order_by("-registered_at", "-id") .first() ) if registration is None: raise HttpError(409, "ثبت‌نام فعالی برای لغو وجود ندارد.") payment = Payment.objects.select_for_update().filter( registration=registration, ).order_by("-created_at", "-id").first() if payment and payment.status == Payment.OrderStatusChoices.PAID: raise HttpError(409, "پرداخت تأیید شده است و امکان لغو پرداخت وجود ندارد.") mark_checkout_cancelled( registration, Registration.CancellationReasonChoices.USER_CANCELLED, ) queue_checkout_reconciliation(registration) return {"message": "درخواست پرداخت لغو شد و ظرفیت آزاد شد."} @events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) def delete_event(request, event_id: int): """Soft delete an event""" if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) event.delete() return 200, {"message": "Event deleted successfully"} @events_router.post("/{int:event_id}/featured-image", response={200: EventSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def upload_event_featured_image(request, event_id: int, file: UploadedFile = File(...)): """Upload or replace the poster/featured image for an event.""" if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) ok, result = _save_uploaded_image(event, "featured_image", file, "events/featured") if not ok: return 400, result return 200, event @events_router.delete("/{int:event_id}/featured-image", response={200: EventSchema, 403: ErrorSchema}, auth=jwt_auth) def delete_event_featured_image(request, event_id: int): """Remove the poster/featured image for an event.""" if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) if event.featured_image: event.featured_image.delete(save=False) event.featured_image = None event.save(update_fields=["featured_image", "updated_at"]) return 200, event @events_router.get("/{int:event_id}/gallery", response={200: List[EventGallerySchema], 403: ErrorSchema}, auth=jwt_auth) def list_event_gallery(request, event_id: int): if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) return 200, event.gallery_images.filter(is_deleted=False).select_related("uploaded_by") @events_router.post("/{int:event_id}/gallery", response={201: EventGallerySchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def upload_event_gallery_image( request, event_id: int, file: UploadedFile = File(...), title: str | None = None, alt_text: str | None = None, ): if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) if not file.content_type or not file.content_type.startswith("image/"): return 400, {"error": "فایل باید تصویر باشد."} if file.size > 10 * 1024 * 1024: return 400, {"error": "حجم فایل باید کمتر از ۱۰ مگابایت باشد."} try: gallery_item = Gallery.objects.create( title=title or file.name, description="", uploaded_by=request.auth, alt_text=alt_text or title or file.name, is_public=True, ) gallery_item._defer_image_processing = True extension = file.name.rsplit(".", 1)[-1] if "." in file.name else "jpg" gallery_item.image.save(f"gallery/{uuid4().hex}.{extension}", ContentFile(file.read())) event.gallery_images.add(gallery_item) process_uploaded_image.delay(gallery_item.id) except Exception as exc: return 400, {"error": str(exc)} return 201, gallery_item @events_router.delete("/{int:event_id}/gallery/{int:image_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) def delete_event_gallery_image(request, event_id: int, image_id: int): if not _is_staff_user(request.auth): return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) image = get_object_or_404(Gallery, id=image_id, is_deleted=False) event.gallery_images.remove(image) if not image.event_galleries.exclude(id=event.id).exists(): image.delete() return 200, {"message": "Gallery image removed"} # Registration endpoints @events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema]) def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0): """List registrations for a specific event""" event = get_object_or_404(Event, id=event_id, is_deleted=False) queryset = event.registrations.filter(is_deleted=False).select_related('user') registrations = queryset[offset:offset + limit] return registrations @events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth) def list_event_registrations_admin( request, event_id: int, status: Optional[List[str]] = Query(None), university: Optional[str] = Query(None), major: Optional[str] = Query(None), search: Optional[str] = Query(None), limit: int = Query(20, ge=1, le=200), offset: int = Query(0, ge=0), ): """List registrations with filters for admin dashboard""" user = request.auth if not (user.is_staff or user.is_superuser): return 403, {"error": "اجازه دسترسی ندارید."} event = get_object_or_404(Event, id=event_id, is_deleted=False) qs = ( event.registrations.filter(is_deleted=False) .select_related("user", "user__university", "user__major") .prefetch_related("payments__discount_code") .order_by("-registered_at") ) status_values = status or request.GET.getlist('status') if status_values: qs = qs.filter(status__in=status_values) if university: qs = qs.filter( Q(user__university__code__icontains=university) | Q(user__university__name__icontains=university) ) if major: qs = qs.filter( Q(user__major__code__icontains=major) | Q(user__major__name__icontains=major) ) if search: qs = qs.filter( Q(user__username__icontains=search) | Q(user__mobile__icontains=search) | Q(user__email__icontains=search) | Q(user__first_name__icontains=search) | Q(user__last_name__icontains=search) ) total = qs.count() results = qs[offset : offset + limit] return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results)) @events_router.post( "/{int:event_id}/register", response=RegistrationSchema, auth=jwt_auth, ) def register_for_event( request, event_id: int, payload: RegistrationCreateSchema | None = None, ): """Register current user for an event""" user = request.auth now = timezone.now() with transaction.atomic(): event = get_object_or_404( Event.objects.select_for_update(), id=event_id, is_deleted=False, ) expire_user_registration_checkouts(event=event, user=user, now=now) with transaction.atomic(): event = get_object_or_404( Event.objects.select_for_update(), id=event_id, is_deleted=False, ) if Registration.objects.filter( event=event, user=user, status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False, ).exists(): raise HttpError(400, "شما قبلا در این ایونت ثبت‌نام کرده‌اید.") registration = ( Registration.objects.select_for_update() .filter(event=event, user=user, is_deleted=False) .order_by("-registered_at", "-id") .first() ) if registration and registration.status == Registration.StatusChoices.PENDING: if registration.checkout_expires_at is None: # Preserve pre-timeout rows without backfilling or expiring them. if registration.final_price is None: registration.final_price = event.price registration.save(update_fields=["final_price"]) return registration if registration.checkout_expires_at > now: # Existing checkouts may finish through their original window, # even if registration has since closed or its held seat is full. return registration mark_checkout_cancelled( registration, Registration.CancellationReasonChoices.CHECKOUT_EXPIRED, ) unresolved_attempt = Payment.objects.filter( user=user, event=event, registration__cancellation_reason__in=[ Registration.CancellationReasonChoices.CHECKOUT_EXPIRED, Registration.CancellationReasonChoices.USER_CANCELLED, Registration.CancellationReasonChoices.EVENT_CANCELLED, ], ).filter( Q(status__in=[Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING]) | Q( status=Payment.OrderStatusChoices.PAID, refund_status__in=[ Payment.RefundStatusChoices.PENDING, Payment.RefundStatusChoices.MANUAL_REVIEW, ], ) ).exists() if unresolved_attempt: raise HttpError(409, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.") if event.get_display_status(now=now) != "registration_open": raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.") discount_code_value = ( payload.discount_code if payload and payload.discount_code else request.GET.get("discount_code") ) applied_code = None discount_amount = 0 final_price = event.price or 0 if discount_code_value: applied_code = DiscountCode.objects.filter( code=discount_code_value, applicable_events=event, is_active=True, ).first() if not applied_code: raise HttpError(400, "کد تخفیف برای این رویداد معتبر نیست.") final_price, discount_amount = applied_code.calculate_discount(event, user) is_free = final_price == 0 registration = Registration.objects.create( event=event, user=user, status=( Registration.StatusChoices.CONFIRMED if is_free else Registration.StatusChoices.PENDING ), checkout_started_at=None if is_free else now, checkout_expires_at=None if is_free else now + CHECKOUT_TIMEOUT, discount_code=applied_code, discount_amount=discount_amount, final_price=final_price, ) return registration @events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth) def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema): """Update registration status""" user = request.auth registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False) requested_status = payload.dict(exclude_unset=True).get('status') if requested_status != Registration.StatusChoices.CANCELLED: raise HttpError(403, "وضعیت ثبت‌نام فقط پس از تأیید پرداخت توسط سامانه تغییر می‌کند.") with transaction.atomic(): Event.objects.select_for_update().get(pk=registration.event_id) registration = Registration.objects.select_for_update().get(pk=registration.pk) payment = Payment.objects.select_for_update().filter( registration=registration, ).order_by("-created_at", "-id").first() if payment and payment.status == Payment.OrderStatusChoices.PAID: raise HttpError(409, "پرداخت تأیید شده است؛ امکان لغو این ثبت‌نام وجود ندارد.") if payment and payment.status in ( Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING, ) and not registration.checkout_expires_at: raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.") if registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at: mark_checkout_cancelled( registration, Registration.CancellationReasonChoices.USER_CANCELLED, ) queue_checkout_reconciliation(registration) elif payment and payment.status in ( Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING, ): raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.") else: registration.status = requested_status registration.full_clean() registration.save() return registration @events_router.delete("/registrations/{int:registration_id}", response=MessageSchema, auth=jwt_auth) def cancel_registration(request, registration_id: int): """Cancel a registration""" user = request.auth registration = get_object_or_404( Registration, id=registration_id, user=user, is_deleted=False, ) with transaction.atomic(): Event.objects.select_for_update().get(pk=registration.event_id) registration = Registration.objects.select_for_update().get(pk=registration.pk) payment = Payment.objects.select_for_update().filter( registration=registration, ).order_by("-created_at", "-id").first() if payment and payment.status == Payment.OrderStatusChoices.PAID: raise HttpError(409, "پرداخت این ثبت‌نام تأیید شده است؛ برای پیگیری با پشتیبانی تماس بگیرید.") if registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at: mark_checkout_cancelled( registration, Registration.CancellationReasonChoices.USER_CANCELLED, ) queue_checkout_reconciliation(registration) elif payment and payment.status in ( Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING, ): raise HttpError(409, "تراکنش ثبت‌نام در حال بررسی است؛ برای لغو با پشتیبانی تماس بگیرید.") else: registration.delete() return {"message": "ثبت‌نام شما لغو شد :("} @events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth) def verify_my_registration(request, ticket_id: UUID): try: reg = Registration.objects.select_related("event").get(ticket_id=ticket_id, user=request.auth) return { "event_image": request.build_absolute_uri(reg.event.featured_image.url) if reg.event.featured_image else None, "event_title": reg.event.title, "event_type": reg.event.get_event_type_display(), "ticket_id": reg.ticket_id, "status": reg.status, "registered_at": reg.registered_at, "success_markdown": reg.event.registration_success_markdown, } except Registration.DoesNotExist: raise HttpError(404, "registration not found") def _my_registration_checkout_fields(registration, now): payment = registration.payments.order_by("-created_at", "-id").first() checkout_state = "none" can_resume = False if registration.status == Registration.StatusChoices.PENDING: if payment and payment.status == Payment.OrderStatusChoices.PAID: checkout_state = "unresolved" elif registration.checkout_expires_at is None: checkout_state = "legacy_pending" elif registration.checkout_expires_at > now: checkout_state = "active" can_resume = _can_resume_registration(registration, payment) else: checkout_state = "expired" elif registration.status == Registration.StatusChoices.CANCELLED: checkout_state = "cancelled" if payment and ( payment.status in (Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING) or ( payment.status == Payment.OrderStatusChoices.PAID and payment.refund_status in ( Payment.RefundStatusChoices.PENDING, Payment.RefundStatusChoices.MANUAL_REVIEW, ) ) ): checkout_state = "unresolved" payment_status = None if payment: payment_status = { Payment.OrderStatusChoices.INIT: "initiated", Payment.OrderStatusChoices.PENDING: "pending", Payment.OrderStatusChoices.PAID: "paid", Payment.OrderStatusChoices.FAILED: "failed", Payment.OrderStatusChoices.CANCELED: "canceled", }[payment.status] return { "checkout_state": checkout_state, "checkout_expires_at": registration.checkout_expires_at, "payment_id": payment.id if payment else None, "payment_status": payment_status, "refund_status": payment.refund_status if payment else None, "can_resume": can_resume, } def _can_resume_registration(registration, payment): if payment and payment.verification_manual_review_at: return False event = registration.event return bool( event.is_published and not event.is_cancelled and event.is_registration_open and (event.capacity is None or event.current_attendees_count <= event.capacity) ) @events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth) def my_registrations_paginated( request, status: Literal["all", "pending", "confirmed", "cancelled", "attended"] = Query("confirmed"), event_state: Literal[ "all", "cancelled", "completed", "in_progress", "registration_closed", "full", "registration_open", "upcoming", ] = Query("all"), sort_by: Literal["start_time", "title"] = Query("start_time"), sort_order: Literal["asc", "desc"] = Query("desc"), page: int = Query(1, ge=1), ): page_size = 10 queryset = Registration.objects.filter(user=request.auth, event__is_published=True) if status != "all": queryset = queryset.filter(status=status) now = timezone.now() queryset = queryset.annotate( event_browse_registration_count=active_registration_count_annotation( now=now, relation="event__registrations", ) ) if event_state != "all": queryset = queryset.filter( event_status_filter( event_state, now=now, prefix="event__", count_field="event_browse_registration_count", ) ) ordering_expression = Lower("event__title") if sort_by == "title" else F("event__start_time") ordering_expression = ordering_expression.desc() if sort_order == "desc" else ordering_expression.asc() queryset = queryset.select_related("event").order_by(ordering_expression, "id") count = queryset.count() total_pages = max(1, (count + page_size - 1) // page_size) start = (page - 1) * page_size results = [ MyEventRegistrationOut( id=registration.id, ticket_id=registration.ticket_id, created_at=registration.created_at, status=registration.status, **_my_registration_checkout_fields(registration, now), event=EventBriefSchema( id=registration.event.id, title=registration.event.title, slug=registration.event.slug, start_date=registration.event.start_time, end_date=registration.event.end_time, start_time=registration.event.start_time, end_time=registration.event.end_time, registration_start_date=registration.event.registration_start_date, registration_end_date=registration.event.registration_end_date, effective_registration_end_date=registration.event.effective_registration_end_date, capacity=registration.event.capacity, registration_count=registration.event.current_attendees_count, status=registration.event.get_display_status(now=now), status_label=registration.event.status_label, is_published=registration.event.is_published, is_cancelled=registration.event.is_cancelled, location=registration.event.location, price=registration.event.price, absolute_image_url=( request.build_absolute_uri(registration.event.featured_image.url) if registration.event.featured_image else None ), ), ) for registration in queryset[start : start + page_size] ] return { "count": count, "page": page, "page_size": page_size, "total_pages": total_pages, "results": results, } @events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth) def my_registrations(request): qs = ( Registration.objects .filter(user=request.auth, event__is_published=True) .select_related("event") .order_by("-created_at") ) out: List[MyEventRegistrationOut] = [] now = timezone.now() for r in qs: out.append( MyEventRegistrationOut( id=r.id, ticket_id=r.ticket_id, created_at=r.created_at, status=r.status, **_my_registration_checkout_fields(r, now), event=EventBriefSchema( id=r.event.id, title=r.event.title, slug=r.event.slug, start_date=r.event.start_time, end_date=r.event.end_time, start_time=r.event.start_time, end_time=r.event.end_time, registration_start_date=r.event.registration_start_date, registration_end_date=r.event.registration_end_date, effective_registration_end_date=r.event.effective_registration_end_date, capacity=r.event.capacity, registration_count=r.event.current_attendees_count, status=r.event.status, status_label=r.event.status_label, is_published=r.event.is_published, is_cancelled=r.event.is_cancelled, location=r.event.location, price=r.event.price, absolute_image_url=request.build_absolute_uri(r.event.featured_image.url) if r.event.featured_image else None, ), ) ) return out @events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth) def is_registered(request, event_id: int): now = timezone.now() registration = ( Registration.objects.filter( user=request.auth, event_id=event_id, is_deleted=False, ) .order_by("-registered_at", "-id") .first() ) payment = None if registration: payment = registration.payments.order_by("-created_at", "-id").first() is_confirmed = bool( registration and registration.status in [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED] ) checkout_state = "none" can_resume = False can_cancel = False if is_confirmed: checkout_state = "confirmed" elif registration and registration.status == Registration.StatusChoices.PENDING: if payment and payment.status == Payment.OrderStatusChoices.PAID: checkout_state = "unresolved" elif registration.checkout_expires_at is None: checkout_state = "legacy_pending" elif registration.checkout_expires_at > now: checkout_state = "active" can_resume = _can_resume_registration(registration, payment) can_cancel = True else: checkout_state = "expired" elif registration and registration.status == Registration.StatusChoices.CANCELLED: checkout_state = "cancelled" if payment and ( payment.status in [Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING] or ( payment.status == Payment.OrderStatusChoices.PAID and payment.refund_status in [ Payment.RefundStatusChoices.PENDING, Payment.RefundStatusChoices.MANUAL_REVIEW, ] ) ): checkout_state = "unresolved" payment_status = None if payment: payment_status = { Payment.OrderStatusChoices.INIT: "initiated", Payment.OrderStatusChoices.PENDING: "pending", Payment.OrderStatusChoices.PAID: "paid", Payment.OrderStatusChoices.FAILED: "failed", Payment.OrderStatusChoices.CANCELED: "canceled", }[payment.status] return { "is_registered": is_confirmed, "checkout_state": checkout_state, "registration_id": registration.id if registration else None, "registration_status": registration.status if registration else None, "ticket_id": str(registration.ticket_id) if registration else None, "checkout_expires_at": registration.checkout_expires_at if registration else None, "payment_id": payment.id if payment else None, "payment_status": payment_status, "refund_status": payment.refund_status if payment else None, "can_resume": can_resume, "can_cancel": can_cancel, } def _registration_hub_state(registration, payment, now): if payment: if payment.refund_status == Payment.RefundStatusChoices.PENDING: return "refund_pending" if payment.refund_status == Payment.RefundStatusChoices.MANUAL_REVIEW: return "refund_manual_review" if payment.refund_status == Payment.RefundStatusChoices.SUCCEEDED: return "refund_succeeded" if payment.status == Payment.OrderStatusChoices.FAILED: return "failed" if payment.status == Payment.OrderStatusChoices.CANCELED: return "cancelled" if payment.verification_manual_review_at: return "manual_review" if payment.status == Payment.OrderStatusChoices.PAID: if registration and registration.status in ( Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, ): return "paid_success" # A charge without a confirmed registration must not be presented as # a successful registration. Keep the saved transaction visible for review. return "manual_review" if payment.status in ( Payment.OrderStatusChoices.INIT, Payment.OrderStatusChoices.PENDING, ): if registration and registration.status == Registration.StatusChoices.PENDING: if registration.checkout_expires_at is None: return "legacy_pending" if registration.checkout_expires_at > now: return "active_checkout" return "verification_pending" if registration: if registration.status in ( Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, ): return "free_success" if (registration.final_price or 0) <= 0 else "manual_review" if registration.status == Registration.StatusChoices.PENDING: if registration.checkout_expires_at is None: return "legacy_pending" if registration.checkout_expires_at > now: return "active_checkout" return "expired" if registration.status == Registration.StatusChoices.CANCELLED: if registration.cancellation_reason == Registration.CancellationReasonChoices.CHECKOUT_EXPIRED: return "expired" if registration.cancellation_reason == Registration.CancellationReasonChoices.PAYMENT_FAILED: return "failed" return "cancelled" return "ready" def _registration_hub_records(event, user, registration_id, payment_id, now): if registration_id: registration = Registration.objects.filter( ticket_id=registration_id, event=event, user=user, is_deleted=False, ).first() if registration is None: return None, None payment = registration.payments.order_by("-created_at", "-id").first() return registration, payment if payment_id: payment = Payment.objects.select_related("registration").filter( pk=payment_id, event=event, user=user, ).first() if payment is None: return None, None registration = payment.registration if registration is not None and ( registration.event_id != event.id or registration.user_id != user.id ): registration = None return registration, payment registrations = list( Registration.objects.filter(event=event, user=user, is_deleted=False) .prefetch_related("payments") .order_by("-registered_at", "-id") ) registration_payments = { registration.id: registration.payments.order_by("-created_at", "-id").first() for registration in registrations } payments = list( Payment.objects.filter(event=event, user=user) .select_related("registration") .order_by("-created_at", "-id") ) unresolved = [] for registration in registrations: payment = registration_payments[registration.id] state = _registration_hub_state(registration, payment, now) if state in { "active_checkout", "legacy_pending", "verification_pending", "manual_review", "refund_pending", "refund_manual_review", }: unresolved.append((registration.registered_at, registration, payment)) linked_registration_ids = set(registration_payments) for payment in payments: if payment.registration_id in linked_registration_ids: continue state = _registration_hub_state(payment.registration, payment, now) if state in { "active_checkout", "legacy_pending", "verification_pending", "manual_review", "refund_pending", "refund_manual_review", }: unresolved.append((payment.created_at, payment.registration, payment)) if unresolved: _, registration, payment = max(unresolved, key=lambda item: item[0]) return registration, payment if registrations: registration = registrations[0] return registration, registration_payments[registration.id] if payments: return payments[0].registration, payments[0] return None, None @events_router.get("/{str:slug}/registration/status", response=RegistrationHubOut, auth=jwt_auth) def registration_hub_status( request, slug: str, registration_id: Optional[UUID] = None, payment_id: Optional[int] = None, ): """Return the authenticated user's authoritative registration/payment view for an event.""" if registration_id and payment_id: raise HttpError(400, "فقط یکی از شناسه‌های ثبت‌نام یا پرداخت را ارسال کنید.") event = Event.objects.filter(slug=slug, is_deleted=False).first() if event is None: lookup = {"pk": int(slug)} if slug.isdecimal() else {"slug": slug} event = get_object_or_404(Event, is_deleted=False, **lookup) now = timezone.now() registration, payment = _registration_hub_records( event, request.auth, registration_id, payment_id, now, ) if (registration_id or payment_id) and registration is None and payment is None: raise HttpError(404, "وضعیت ثبت‌نام یا پرداخت پیدا نشد.") state = _registration_hub_state(registration, payment, now) unresolved = state in { "active_checkout", "legacy_pending", "verification_pending", "manual_review", "refund_pending", "refund_manual_review", } can_resume = bool( state == "active_checkout" and registration and registration.status == Registration.StatusChoices.PENDING and _can_resume_registration(registration, payment) ) can_cancel = bool( state == "active_checkout" and registration and registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at and registration.checkout_expires_at > now ) registration_is_settled = bool( registration and registration.status in ( Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, ) ) registration_window_open = bool( event.is_published and not event.is_cancelled and event.is_registration_open and event.has_available_slots ) can_register = bool(registration_window_open and not unresolved and not registration_is_settled) image_url = None if event.featured_image and hasattr(event.featured_image, "url"): image_url = request.build_absolute_uri(event.featured_image.url) payment_status = None if payment: payment_status = { Payment.OrderStatusChoices.INIT: "initiated", Payment.OrderStatusChoices.PENDING: "pending", Payment.OrderStatusChoices.PAID: "paid", Payment.OrderStatusChoices.FAILED: "failed", Payment.OrderStatusChoices.CANCELED: "canceled", }[payment.status] return { "state": state, "event": { "id": event.id, "title": event.title, "slug": event.slug, "description": event.description, "event_type": event.event_type, "address": event.address, "location": event.location, "start_time": event.start_time, "end_time": event.end_time, "registration_start_date": event.registration_start_date, "registration_end_date": event.registration_end_date, "effective_registration_end_date": event.effective_registration_end_date, "capacity": event.capacity, "price": event.price, "status": event.status, "status_label": event.status_label, "is_published": event.is_published, "is_cancelled": event.is_cancelled, "registration_count": event.current_attendees_count, "image_url": image_url, "registration_success_markdown": event.registration_success_markdown, }, "registration": { "ticket_id": registration.ticket_id, "status": registration.status, "registered_at": registration.registered_at, "checkout_started_at": registration.checkout_started_at, "checkout_expires_at": registration.checkout_expires_at, "cancellation_reason": registration.cancellation_reason, "final_price": registration.final_price, "discount_amount": registration.discount_amount, } if registration else None, "payment": { "id": payment.id, "status": payment_status, "verification_state": payment.verification_state, "refund_status": payment.refund_status, "base_amount": payment.base_amount, "discount_amount": payment.discount_amount, "amount": payment.amount, "ref_id": payment.ref_id, "created_at": payment.created_at, "gateway_paid_at": payment.gateway_paid_at, } if payment else None, "can_register": can_register, "can_resume": can_resume, "can_cancel": can_cancel, } @events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth) def event_admin_detail(request, event_id: int): user = request.auth if not (user.is_staff or user.is_superuser): return 403, {"error": "اجازه دسترسی ندارید."} event = get_object_or_404( Event.objects.prefetch_related( 'gallery_images', 'registrations__user', 'registrations__payments__discount_code' ), id=event_id, is_deleted=False, ) return event