1277 lines
50 KiB
Python
1277 lines
50 KiB
Python
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, Case, When, IntegerField, 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 core.authentication import jwt_auth
|
||
from apps.events.models import Event, Registration
|
||
from apps.events.checkout import (
|
||
CHECKOUT_TIMEOUT,
|
||
cancel_event_checkouts,
|
||
expire_stale_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 _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 events with filtering and pagination"""
|
||
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
|
||
|
||
if status:
|
||
if "," in status:
|
||
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||
queryset = queryset.filter(status__in=parts)
|
||
else:
|
||
queryset = queryset.filter(status__in=status)
|
||
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(
|
||
published_first=Case(
|
||
When(status='published', then=0),
|
||
default=1,
|
||
output_field=IntegerField()
|
||
)
|
||
).order_by('published_first', '-start_time', '-id')
|
||
|
||
events = queryset[offset:offset + limit]
|
||
return events
|
||
|
||
|
||
def _event_registration_open_query(now):
|
||
return (
|
||
Q(status=Event.StatusChoices.PUBLISHED)
|
||
& (Q(registration_start_date__isnull=True) | Q(registration_start_date__lte=now))
|
||
& (Q(registration_end_date__isnull=True) | Q(registration_end_date__gte=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", "completed", "upcoming", "open"] = "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_statuses = [Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED]
|
||
public_events = Event.objects.filter(is_deleted=False, status__in=public_statuses)
|
||
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=Count(
|
||
"registrations",
|
||
filter=Q(
|
||
registrations__is_deleted=False,
|
||
) & (
|
||
Q(registrations__status__in=[
|
||
Registration.StatusChoices.CONFIRMED,
|
||
Registration.StatusChoices.ATTENDED,
|
||
])
|
||
| Q(
|
||
registrations__status=Registration.StatusChoices.PENDING,
|
||
registrations__checkout_expires_at__gt=now,
|
||
)
|
||
),
|
||
distinct=True,
|
||
)
|
||
)
|
||
.prefetch_related("gallery_images")
|
||
)
|
||
|
||
if state == "completed":
|
||
queryset = queryset.filter(
|
||
Q(status=Event.StatusChoices.COMPLETED) | Q(end_time__lte=now)
|
||
)
|
||
elif state == "upcoming":
|
||
queryset = queryset.filter(
|
||
status=Event.StatusChoices.PUBLISHED,
|
||
start_time__gt=now,
|
||
).exclude(_event_registration_open_query(now))
|
||
elif state == "open":
|
||
queryset = queryset.filter(_event_registration_open_query(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.annotate(
|
||
published_first=Case(
|
||
When(status=Event.StatusChoices.PUBLISHED, then=0),
|
||
default=1,
|
||
output_field=IntegerField(),
|
||
)
|
||
).order_by("published_first", "-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"""
|
||
event = get_object_or_404(
|
||
Event.objects.prefetch_related('gallery_images'),
|
||
id=event_id,
|
||
is_deleted=False
|
||
)
|
||
return event
|
||
|
||
@events_router.get("/slug/{str:slug}", response=EventSchema)
|
||
def get_event_by_slug(request, slug: str):
|
||
"""Get event details by slug"""
|
||
event = get_object_or_404(
|
||
Event.objects.prefetch_related('gallery_images'),
|
||
slug=slug,
|
||
is_deleted=False
|
||
)
|
||
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 = {
|
||
"status": event.status,
|
||
"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 = (
|
||
previous_state["status"] != Event.StatusChoices.CANCELLED
|
||
and event.status == Event.StatusChoices.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()
|
||
expire_stale_registration_checkouts(now=now, event_id=event_id)
|
||
|
||
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.status != Event.StatusChoices.PUBLISHED:
|
||
raise HttpError(400, "ثبتنام این رویداد فعال نیست.")
|
||
if event.registration_end_date and event.registration_end_date < now:
|
||
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||
if event.registration_start_date and event.registration_start_date > now:
|
||
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||
if not event.has_available_slots:
|
||
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 = not bool(payment and payment.verification_manual_review_at)
|
||
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,
|
||
}
|
||
|
||
|
||
@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"),
|
||
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)
|
||
if status != "all":
|
||
queryset = queryset.filter(status=status)
|
||
|
||
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
|
||
now = timezone.now()
|
||
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,
|
||
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)
|
||
.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,
|
||
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()
|
||
)
|
||
if registration and registration.status == Registration.StatusChoices.PENDING and registration.checkout_expires_at and registration.checkout_expires_at <= now:
|
||
expire_stale_registration_checkouts(now=now, event_id=event_id)
|
||
registration.refresh_from_db()
|
||
|
||
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 = not bool(payment and payment.verification_manual_review_at)
|
||
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()
|
||
expire_stale_registration_checkouts(now=now, event_id=event.id)
|
||
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 (payment is None or not payment.verification_manual_review_at)
|
||
)
|
||
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.status == Event.StatusChoices.PUBLISHED
|
||
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,
|
||
"capacity": event.capacity,
|
||
"price": event.price,
|
||
"status": event.status,
|
||
"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
|