feat: derive event lifecycle status from dates and flags
Replace persisted event statuses with publication and cancellation flags, and compute public lifecycle labels from event timing and registration capacity. Keep filters, registration checks, and profile responses aligned with the derived status.
This commit is contained in:
@@ -56,6 +56,9 @@ class EventSchema(ModelSchema):
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
absolute_featured_image_thumbnail_url: Optional[str] = None
|
||||
absolute_featured_image_preview_url: Optional[str] = None
|
||||
status: str
|
||||
status_label: str
|
||||
effective_registration_end_date: datetime
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
@@ -63,7 +66,8 @@ class EventSchema(ModelSchema):
|
||||
'id', 'title', 'slug', 'description', 'featured_image', 'event_type',
|
||||
'address', 'location', 'online_link', 'start_time', 'end_time',
|
||||
'registration_start_date', 'registration_end_date', 'registration_success_markdown',
|
||||
'capacity', 'price', 'status', 'created_at', 'updated_at'
|
||||
'capacity', 'price', 'is_published',
|
||||
'is_cancelled', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
@@ -87,6 +91,8 @@ class EventSchema(ModelSchema):
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
if hasattr(obj, "browse_registration_count"):
|
||||
return obj.browse_registration_count
|
||||
now = timezone.now()
|
||||
return obj.registrations.filter(is_deleted=False).filter(
|
||||
Q(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
|
||||
@@ -113,9 +119,13 @@ class EventListSchema(Schema):
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
effective_registration_end_date: datetime
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: str
|
||||
status_label: str
|
||||
is_published: bool
|
||||
is_cancelled: bool
|
||||
registration_count: int
|
||||
created_at: datetime
|
||||
|
||||
@@ -172,7 +182,8 @@ class EventCreateSchema(Schema):
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: str = "draft"
|
||||
is_published: bool = False
|
||||
is_cancelled: bool = False
|
||||
registration_success_markdown: Optional[str] = None
|
||||
gallery_image_ids: Optional[List[int]] = []
|
||||
|
||||
@@ -191,7 +202,8 @@ class EventUpdateSchema(Schema):
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
is_published: Optional[bool] = None
|
||||
is_cancelled: Optional[bool] = None
|
||||
registration_success_markdown: Optional[str] = None
|
||||
gallery_image_ids: Optional[List[int]] = None
|
||||
|
||||
@@ -344,6 +356,17 @@ class EventBriefSchema(Schema):
|
||||
slug: str
|
||||
start_date: datetime
|
||||
end_date: Optional[datetime] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
effective_registration_end_date: datetime
|
||||
capacity: Optional[int] = None
|
||||
registration_count: int = 0
|
||||
status: str
|
||||
status_label: str
|
||||
is_published: bool = True
|
||||
is_cancelled: bool = False
|
||||
location: Optional[str] = None
|
||||
price: int
|
||||
absolute_image_url: Optional[str] = None
|
||||
@@ -398,9 +421,13 @@ class RegistrationHubEventOut(Schema):
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
effective_registration_end_date: datetime
|
||||
capacity: Optional[int] = None
|
||||
price: int
|
||||
status: str
|
||||
status_label: str
|
||||
is_published: bool
|
||||
is_cancelled: bool
|
||||
registration_count: int
|
||||
image_url: Optional[str] = None
|
||||
registration_success_markdown: Optional[str] = None
|
||||
|
||||
@@ -5,7 +5,7 @@ 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 import Q, F, Count, Max
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
@@ -34,6 +34,11 @@ from apps.events.api.schemas import (
|
||||
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 (
|
||||
@@ -61,6 +66,14 @@ 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": "فایل باید تصویر باشد."}
|
||||
@@ -124,15 +137,21 @@ def list_events(
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
):
|
||||
"""List events with filtering and pagination"""
|
||||
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
|
||||
"""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:
|
||||
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)
|
||||
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:
|
||||
@@ -140,23 +159,57 @@ def list_events(
|
||||
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')
|
||||
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(status=Event.StatusChoices.PUBLISHED)
|
||||
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__gte=now))
|
||||
& (Q(registration_end_date__isnull=True) | Q(registration_end_date__gt=now))
|
||||
& (Q(capacity__isnull=True) | Q(capacity__gt=F("browse_registration_count")))
|
||||
)
|
||||
|
||||
@@ -179,7 +232,10 @@ def _parse_browse_date(value: Optional[str], field_name: str):
|
||||
@events_router.get("/browse/", response=PaginatedEventBrowseSchema)
|
||||
def browse_events(
|
||||
request,
|
||||
state: Literal["all", "completed", "upcoming", "open"] = "all",
|
||||
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,
|
||||
@@ -207,8 +263,7 @@ def browse_events(
|
||||
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)
|
||||
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
|
||||
|
||||
@@ -216,37 +271,13 @@ def browse_events(
|
||||
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,
|
||||
)
|
||||
browse_registration_count=active_registration_count_annotation(now=now),
|
||||
)
|
||||
.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 state != "all":
|
||||
queryset = queryset.filter(event_status_filter(state, now=now))
|
||||
|
||||
if event_type:
|
||||
queryset = queryset.filter(event_type=event_type)
|
||||
@@ -266,13 +297,7 @@ def browse_events(
|
||||
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")
|
||||
queryset = queryset.order_by("-start_time", "-id")
|
||||
|
||||
count = queryset.count()
|
||||
total_pages = ceil(count / page_size) if count else 0
|
||||
@@ -291,21 +316,19 @@ def browse_events(
|
||||
@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
|
||||
)
|
||||
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"""
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related('gallery_images'),
|
||||
slug=slug,
|
||||
is_deleted=False
|
||||
)
|
||||
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)
|
||||
@@ -336,7 +359,8 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
|
||||
return _staff_forbidden()
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
previous_state = {
|
||||
"status": event.status,
|
||||
"is_published": event.is_published,
|
||||
"is_cancelled": event.is_cancelled,
|
||||
"start_time": event.start_time,
|
||||
"end_time": event.end_time,
|
||||
"address": event.address,
|
||||
@@ -368,10 +392,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
|
||||
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
|
||||
)
|
||||
cancelled_now = not previous_state["is_cancelled"] and event.is_cancelled
|
||||
|
||||
if cancelled_now:
|
||||
cancel_event_checkouts(event.id)
|
||||
@@ -663,14 +684,8 @@ def register_for_event(
|
||||
if unresolved_attempt:
|
||||
raise HttpError(409, "وضعیت پرداخت قبلی هنوز مشخص نشده است. پس از بررسی دوباره تلاش کنید.")
|
||||
|
||||
if event.status != Event.StatusChoices.PUBLISHED:
|
||||
if event.get_display_status(now=now) != "registration_open":
|
||||
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
|
||||
@@ -850,7 +865,8 @@ def _can_resume_registration(registration, payment):
|
||||
return False
|
||||
event = registration.event
|
||||
return bool(
|
||||
event.status == Event.StatusChoices.PUBLISHED
|
||||
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)
|
||||
)
|
||||
@@ -860,22 +876,42 @@ def _can_resume_registration(registration, payment):
|
||||
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)
|
||||
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
|
||||
now = timezone.now()
|
||||
results = [
|
||||
MyEventRegistrationOut(
|
||||
id=registration.id,
|
||||
@@ -889,6 +925,17 @@ def my_registrations_paginated(
|
||||
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=(
|
||||
@@ -913,7 +960,7 @@ def my_registrations_paginated(
|
||||
def my_registrations(request):
|
||||
qs = (
|
||||
Registration.objects
|
||||
.filter(user=request.auth)
|
||||
.filter(user=request.auth, event__is_published=True)
|
||||
.select_related("event")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
@@ -933,6 +980,17 @@ def my_registrations(request):
|
||||
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,
|
||||
@@ -1202,7 +1260,8 @@ def registration_hub_status(
|
||||
)
|
||||
)
|
||||
registration_window_open = bool(
|
||||
event.status == Event.StatusChoices.PUBLISHED
|
||||
event.is_published
|
||||
and not event.is_cancelled
|
||||
and event.is_registration_open
|
||||
and event.has_available_slots
|
||||
)
|
||||
@@ -1236,9 +1295,13 @@ def registration_hub_status(
|
||||
"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,
|
||||
|
||||
Reference in New Issue
Block a user