From 6c5fadef2f929b39182c66f5fb9fa32d967fd940 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Sat, 3 Oct 2026 12:46:16 +0330 Subject: [PATCH] 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. --- apps/communications/tasks.py | 3 +- apps/content/admin.py | 2 +- apps/content/api/views.py | 3 +- apps/events/admin.py | 38 +-- apps/events/api/schemas.py | 33 ++- apps/events/api/views.py | 225 +++++++++++------- apps/events/checkout.py | 4 +- apps/events/event_status.py | 99 ++++++++ apps/events/fixtures/events.json | 30 ++- .../migrations/0019_event_lifecycle_flags.py | 62 +++++ apps/events/models.py | 83 ++++++- apps/events/resources.py | 2 +- .../test_checkout_lock_contention.py | 2 +- .../tests/integration/test_event_changes.py | 4 +- .../tests/integration/test_event_models.py | 2 +- apps/events/tests/integration/test_events.py | 33 ++- .../integration/test_registration_checkout.py | 6 +- apps/events/tests/unit/test_admin_actions.py | 17 +- apps/payments/api/views.py | 16 +- .../tests/integration/test_payment_models.py | 2 +- .../test_payment_reconciliation.py | 4 +- .../tests/integration/test_payments.py | 9 +- apps/users/api/schemas.py | 9 + apps/users/api/views.py | 11 +- .../users/tests/integration/test_api_flows.py | 2 +- conftest.py | 2 +- 26 files changed, 525 insertions(+), 178 deletions(-) create mode 100644 apps/events/event_status.py create mode 100644 apps/events/migrations/0019_event_lifecycle_flags.py diff --git a/apps/communications/tasks.py b/apps/communications/tasks.py index 9b54b0e..d3e5549 100644 --- a/apps/communications/tasks.py +++ b/apps/communications/tasks.py @@ -18,7 +18,8 @@ def send_event_reminders(): window = timedelta(minutes=30) events = Event.objects.filter( start_time__range=(reminder_target - window, reminder_target + window), - status=Event.StatusChoices.PUBLISHED, + is_published=True, + is_cancelled=False, is_deleted=False, ) total_sent = 0 diff --git a/apps/content/admin.py b/apps/content/admin.py index 0022bcc..7369b5a 100644 --- a/apps/content/admin.py +++ b/apps/content/admin.py @@ -239,7 +239,7 @@ class AboutYearAdmin(ModelAdmin): if db_field.name == "events": field.queryset = Event.objects.filter( is_deleted=False, - status__in=(Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED), + is_published=True, start_time__lte=timezone.now(), ).order_by("-start_time", "title") return field diff --git a/apps/content/api/views.py b/apps/content/api/views.py index f494ace..081933a 100644 --- a/apps/content/api/views.py +++ b/apps/content/api/views.py @@ -117,13 +117,12 @@ def get_page_content(request, response: HttpResponse, slug: str): page = AboutContent.objects.filter(pk=1).first() if page is None: raise HttpError(404, "Page content not found") - event_statuses = (Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED) years_by_label = {} for year in page.years.prefetch_related("members", "events").all(): label = year.year_label.strip() or "سال نامشخص" events = year.events.filter( is_deleted=False, - status__in=event_statuses, + is_published=True, start_time__lte=timezone.now(), ).order_by("-start_time", "-pk") years_by_label[label] = { diff --git a/apps/events/admin.py b/apps/events/admin.py index 41a8dee..10d85ee 100644 --- a/apps/events/admin.py +++ b/apps/events/admin.py @@ -14,11 +14,11 @@ from apps.events.checkout import cancel_event_checkouts class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): resource_class = EventResource list_display = ( - 'title', 'event_type', 'start_time_display', 'end_time_display', 'status', + 'title', 'event_type', 'start_time_display', 'end_time_display', 'status_display', 'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display', ) list_filter = ( - 'event_type', 'status', 'is_deleted', 'start_time', 'end_time', + 'event_type', 'is_published', 'is_cancelled', 'is_deleted', 'start_time', 'end_time', 'registration_start_date', 'registration_end_date', SoftDeleteListFilter, ) search_fields = ('title', 'description', 'address') @@ -27,7 +27,8 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): filter_horizontal = ('gallery_images',) fieldsets = ( ('Event Details', {'fields': ('title', 'slug', 'description', 'featured_image')}), - ('Timing & Type', {'fields': ('start_time', 'end_time', 'event_type', 'status')}), + ('Timing & Type', {'fields': ('start_time', 'end_time', 'event_type')}), + ('Publication', {'fields': ('is_published', 'is_cancelled')}), ('Location & Online', { 'fields': ('address', 'location', 'online_link'), 'description': 'For On-Site or Hybrid events, provide address and select on map. For Online events, provide a link.', @@ -41,9 +42,13 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): ) readonly_fields = ('deleted_at',) actions = BaseModelAdmin.actions + [ - 'make_published', 'make_draft', 'make_cancelled', 'make_completed', 'restore_events', + 'make_published', 'make_draft', 'make_cancelled', 'restore_events', ] + @admin.display(description='Status') + def status_display(self, obj): + return obj.status_label + @admin.display(description='Price') def price_display(self, obj): return obj.price if obj.price is not None else 'رایگان' @@ -70,30 +75,25 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): @admin.action(description='Mark selected events as published') def make_published(self, request, queryset): - queryset.update(status=Event.StatusChoices.PUBLISHED) - self.message_user(request, f'Published {queryset.count()} events.') + count = queryset.update(is_published=True) + self.message_user(request, f'Published {count} events.') @admin.action(description='Mark selected events as draft') def make_draft(self, request, queryset): - queryset.update(status=Event.StatusChoices.DRAFT) - self.message_user(request, f'Marked {queryset.count()} events as draft.') + count = queryset.update(is_published=False) + self.message_user(request, f'Marked {count} events as draft.') @admin.action(description='Mark selected events as cancelled') def make_cancelled(self, request, queryset): count = 0 for event in queryset: - if event.status != Event.StatusChoices.CANCELLED: - event.status = Event.StatusChoices.CANCELLED - event.save(update_fields=['status']) + if not event.is_cancelled: + event.is_cancelled = True + event.save(update_fields=['is_cancelled', 'updated_at']) cancel_event_checkouts(event.pk) count += 1 self.message_user(request, f'Cancelled {count} events.') - @admin.action(description='Mark selected events as completed') - def make_completed(self, request, queryset): - queryset.update(status=Event.StatusChoices.COMPLETED) - self.message_user(request, f'Completed {queryset.count()} events.') - @admin.action(description='Restore selected events') def restore_events(self, request, queryset): for event in queryset: @@ -101,11 +101,11 @@ class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): self.message_user(request, f'Restored {queryset.count()} events.') def save_model(self, request, obj, form, change): - previous_status = None + was_cancelled = None if change and obj.pk: - previous_status = Event.all_objects.filter(pk=obj.pk).values_list("status", flat=True).first() + was_cancelled = Event.all_objects.filter(pk=obj.pk).values_list("is_cancelled", flat=True).first() super().save_model(request, obj, form, change) - if previous_status != Event.StatusChoices.CANCELLED and obj.status == Event.StatusChoices.CANCELLED: + if was_cancelled is False and obj.is_cancelled: cancel_event_checkouts(obj.pk) diff --git a/apps/events/api/schemas.py b/apps/events/api/schemas.py index 3014c4b..4018bcf 100644 --- a/apps/events/api/schemas.py +++ b/apps/events/api/schemas.py @@ -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 diff --git a/apps/events/api/views.py b/apps/events/api/views.py index e1d4ef5..5635e18 100644 --- a/apps/events/api/views.py +++ b/apps/events/api/views.py @@ -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, diff --git a/apps/events/checkout.py b/apps/events/checkout.py index 3a1e8e2..dd22e98 100644 --- a/apps/events/checkout.py +++ b/apps/events/checkout.py @@ -80,7 +80,7 @@ def expire_stale_registration_checkouts(*, now=None, event_id=None): continue reason = ( Registration.CancellationReasonChoices.EVENT_CANCELLED - if registration.event.status == Event.StatusChoices.CANCELLED + if registration.event.is_cancelled else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED ) if mark_checkout_cancelled(registration, reason): @@ -105,7 +105,7 @@ def expire_user_registration_checkouts(*, event, user, now): for registration in registrations: reason = ( Registration.CancellationReasonChoices.EVENT_CANCELLED - if event.status == Event.StatusChoices.CANCELLED + if event.is_cancelled else Registration.CancellationReasonChoices.CHECKOUT_EXPIRED ) if mark_checkout_cancelled(registration, reason): diff --git a/apps/events/event_status.py b/apps/events/event_status.py new file mode 100644 index 0000000..1088399 --- /dev/null +++ b/apps/events/event_status.py @@ -0,0 +1,99 @@ +from django.db.models import Count, F, Q + + +EVENT_STATUS_LABELS = { + "draft": "پیش‌نویس", + "cancelled": "لغو شده", + "completed": "برگزار شده", + "in_progress": "در حال برگزاری", + "registration_closed": "پایان ثبت‌نام", + "full": "تکمیل ظرفیت", + "registration_open": "در حال ثبت‌نام", + "upcoming": "به‌زودی", +} + +PUBLIC_EVENT_STATUSES = tuple(key for key in EVENT_STATUS_LABELS if key != "draft") + + +def active_registration_count_annotation(*, now, relation="registrations"): + return Count( + relation, + filter=Q(**{f"{relation}__is_deleted": False}) + & ( + Q(**{f"{relation}__status__in": ["confirmed", "attended"]}) + | Q( + **{ + f"{relation}__status": "pending", + f"{relation}__checkout_expires_at__gt": now, + } + ) + ), + distinct=True, + ) + + +def event_status_filter(status, *, now, prefix="", count_field="browse_registration_count"): + """Return a queryset condition for a derived event status. + + The caller should restrict the queryset to published events and annotate the + active registration count using ``count_field`` before applying this filter. + """ + def field(name): + return f"{prefix}{name}" + count = F(count_field) + before_event_start = Q(**{f"{field('start_time')}__gt": now}) + deadline_open = Q(**{f"{field('registration_end_date')}__isnull": True}) | Q( + **{f"{field('registration_end_date')}__gt": now} + ) + + if status == "cancelled": + return Q(**{field("is_cancelled"): True}) + if status == "completed": + return Q(**{f"{field('end_time')}__lte": now}) & Q(**{field("is_cancelled"): False}) + if status == "in_progress": + return ( + Q(**{f"{field('start_time')}__lte": now}) + & Q(**{f"{field('end_time')}__gt": now}) + & Q(**{field("is_cancelled"): False}) + ) + if status == "registration_closed": + return ( + before_event_start + & Q(**{f"{field('registration_end_date')}__lte": now}) + & Q(**{field("is_cancelled"): False}) + ) + if status == "full": + return ( + before_event_start + & deadline_open + & Q(**{f"{field('capacity')}__isnull": False}) + & Q(**{f"{field('capacity')}__lte": count}) + & Q(**{field("is_cancelled"): False}) + ) + if status == "registration_open": + registration_started = Q(**{f"{field('registration_start_date')}__isnull": True}) | Q( + **{f"{field('registration_start_date')}__lte": now} + ) + capacity_available = Q(**{f"{field('capacity')}__isnull": True}) | Q( + **{f"{field('capacity')}__gt": count} + ) + return ( + before_event_start + & registration_started + & deadline_open + & capacity_available + & Q(**{field("is_cancelled"): False}) + ) + if status == "upcoming": + registration_not_started = Q(**{f"{field('registration_start_date')}__gt": now}) + capacity_available = Q(**{f"{field('capacity')}__isnull": True}) | Q( + **{f"{field('capacity')}__gt": count} + ) + return ( + before_event_start + & registration_not_started + & deadline_open + & capacity_available + & Q(**{field("is_cancelled"): False}) + ) + raise ValueError(f"Unsupported event status: {status}") diff --git a/apps/events/fixtures/events.json b/apps/events/fixtures/events.json index cbd39a1..fa794b0 100644 --- a/apps/events/fixtures/events.json +++ b/apps/events/fixtures/events.json @@ -14,7 +14,8 @@ "event_type": "on_site", "address": "سالن کنفرانس دانشکده مهندسی کامپیوتر", "location": "35.7219,51.3890", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 50, "price": "150000.00", "registration_start_date": "2024-03-01T00:00:00Z", @@ -36,7 +37,8 @@ "event_type": "on_site", "address": "آزمایشگاه کامپیوتر شماره ۱", "location": "35.7225,51.3885", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 80, "price": null, "registration_start_date": "2024-03-05T00:00:00Z", @@ -57,7 +59,8 @@ "end_time": "2024-03-28T21:00:00Z", "event_type": "online", "online_link": "https://meet.google.com/abc-defg-hij", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 200, "price": null, "registration_start_date": "2024-03-10T00:00:00Z", @@ -80,7 +83,8 @@ "address": "کلاس ۲۰۵ ساختمان مهندسی کامپیوتر", "location": "35.7230,51.3880", "online_link": "https://zoom.us/j/123456789", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 40, "price": "200000.00", "registration_start_date": "2024-03-20T00:00:00Z", @@ -102,7 +106,8 @@ "event_type": "on_site", "address": "شرکت دیجی‌کالا، تهران", "location": "35.7580,51.4100", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 30, "price": null, "registration_start_date": "2024-03-25T00:00:00Z", @@ -124,7 +129,8 @@ "event_type": "on_site", "address": "مرکز نوآوری دانشگاه", "location": "35.7200,51.3900", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 60, "price": "100000.00", "registration_start_date": "2024-04-01T00:00:00Z", @@ -147,7 +153,8 @@ "address": "آمفی‌تئاتر مرکزی دانشگاه", "location": "35.7210,51.3895", "online_link": "https://meet.google.com/xyz-uvw-rst", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 150, "price": null, "registration_start_date": "2024-04-10T00:00:00Z", @@ -169,7 +176,8 @@ "event_type": "on_site", "address": "آزمایشگاه شبکه دانشکده", "location": "35.7215,51.3888", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 25, "price": "300000.00", "registration_start_date": "2024-04-15T00:00:00Z", @@ -191,7 +199,8 @@ "event_type": "on_site", "address": "استودیو طراحی دانشکده هنر", "location": "35.7240,51.3870", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 40, "price": "50000.00", "registration_start_date": "2024-04-20T00:00:00Z", @@ -213,7 +222,8 @@ "event_type": "on_site", "address": "سالن همایش‌های دانشگاه", "location": "35.7205,51.3892", - "status": "published", + "is_published": true, + "is_cancelled": false, "capacity": 100, "price": null, "registration_start_date": "2024-05-01T00:00:00Z", diff --git a/apps/events/migrations/0019_event_lifecycle_flags.py b/apps/events/migrations/0019_event_lifecycle_flags.py new file mode 100644 index 0000000..7d7eb0a --- /dev/null +++ b/apps/events/migrations/0019_event_lifecycle_flags.py @@ -0,0 +1,62 @@ +from django.db import migrations, models +from django.db.models import F + + +def copy_legacy_event_state(apps, schema_editor): + Event = apps.get_model("events", "Event") + events = Event._base_manager.all() + events.update(legacy_status=F("status")) + events.filter(status__in=["published", "completed"]).update( + is_published=True, + is_cancelled=False, + ) + events.filter(status="cancelled").update( + is_published=True, + is_cancelled=True, + ) + + +def restore_legacy_event_state(apps, schema_editor): + Event = apps.get_model("events", "Event") + Event._base_manager.update(status=F("legacy_status")) + + +class Migration(migrations.Migration): + dependencies = [ + ("events", "0018_registrationnotification_reg_notif_pending_idx"), + ] + + operations = [ + migrations.AddField( + model_name="event", + name="is_published", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="event", + name="is_cancelled", + field=models.BooleanField(default=False), + ), + migrations.AddField( + model_name="event", + name="legacy_status", + field=models.CharField(max_length=10, null=True), + ), + migrations.RunPython(copy_legacy_event_state, restore_legacy_event_state), + migrations.RemoveIndex( + model_name="event", + name="events_even_status_189ced_idx", + ), + migrations.RemoveField( + model_name="event", + name="status", + ), + migrations.RemoveField( + model_name="event", + name="legacy_status", + ), + migrations.AddIndex( + model_name="event", + index=models.Index(fields=["is_published", "start_time"], name="event_pub_start_idx"), + ), + ] diff --git a/apps/events/models.py b/apps/events/models.py index 74ecbca..e3e9803 100644 --- a/apps/events/models.py +++ b/apps/events/models.py @@ -1,5 +1,6 @@ from django.db import models, transaction from django.conf import settings +from django.core.exceptions import ValidationError from django.utils import timezone from django.utils.text import slugify @@ -14,6 +15,7 @@ from core.media import ( safe_process_public_image, ) from core.models import BaseModel +from apps.events.event_status import EVENT_STATUS_LABELS class Event(BaseModel): @@ -22,12 +24,6 @@ class Event(BaseModel): ON_SITE = 'on_site', 'حضوری' HYBRID = 'hybrid', 'آنلاین/حضوری' - class StatusChoices(models.TextChoices): - DRAFT = 'draft', 'Draft' - PUBLISHED = 'published', 'Published' - CANCELLED = 'cancelled', 'Cancelled' - COMPLETED = 'completed', 'Completed' - title = models.CharField(max_length=255) slug = models.SlugField(max_length=255, unique=True, blank=True) description = models.TextField(help_text="Event description in Markdown format") @@ -43,7 +39,8 @@ class Event(BaseModel): online_link = models.URLField(max_length=500, blank=True, null=True, help_text="Link for online events (e.g., Zoom, Google Meet)") - status = models.CharField(max_length=10, choices=StatusChoices.choices, default=StatusChoices.DRAFT) + is_published = models.BooleanField(default=False) + is_cancelled = models.BooleanField(default=False) capacity = models.PositiveIntegerField(null=True, blank=True, help_text="Maximum number of attendees (leave blank for unlimited)") @@ -63,14 +60,75 @@ class Event(BaseModel): class Meta: ordering = ['-start_time'] indexes = [ - models.Index(fields=['status', 'start_time']), + models.Index(fields=['is_published', 'start_time'], name='event_pub_start_idx'), models.Index(fields=['event_type']), ] def __str__(self): return self.title + def clean(self): + super().clean() + should_validate_deadline = self.pk is None + if self.pk is not None: + previous_schedule = type(self).all_objects.filter(pk=self.pk).values( + "registration_end_date", "start_time" + ).first() + should_validate_deadline = previous_schedule is None or ( + previous_schedule["registration_end_date"] != self.registration_end_date + or previous_schedule["start_time"] != self.start_time + ) + if ( + should_validate_deadline + and self.registration_end_date + and self.start_time + and self.registration_end_date > self.start_time + ): + raise ValidationError({ + "registration_end_date": "مهلت ثبت‌نام باید تا زمان شروع رویداد پایان یابد." + }) + + @property + def effective_registration_end_date(self): + """Cap registration at event start, including records with legacy dates.""" + if self.registration_end_date is None: + return self.start_time + return min(self.registration_end_date, self.start_time) + + def get_display_status(self, *, now=None, registration_count=None): + now = now or timezone.now() + if not self.is_published: + return "draft" + if self.is_cancelled: + return "cancelled" + if self.end_time <= now: + return "completed" + if self.start_time <= now: + return "in_progress" + if now >= self.effective_registration_end_date: + return "registration_closed" + + if registration_count is None: + registration_count = getattr(self, "browse_registration_count", None) + if registration_count is None: + registration_count = self.current_attendees_count + if self.capacity is not None and registration_count >= self.capacity: + return "full" + if self.registration_start_date is not None and now < self.registration_start_date: + return "upcoming" + return "registration_open" + + @property + def status(self): + """Computed lifecycle state; no status value is persisted on Event.""" + return self.get_display_status() + + @property + def status_label(self): + return EVENT_STATUS_LABELS[self.status] + def save(self, *args, **kwargs): + self.clean() previous_image_name = get_image_previous_name(self, "featured_image") current_image_name = self.featured_image.name if self.featured_image else None @@ -103,8 +161,13 @@ class Event(BaseModel): @property def is_registration_open(self): now = timezone.now() - return (self.registration_start_date is None or now >= self.registration_start_date) and \ - (self.registration_end_date is None or now <= self.registration_end_date) + return ( + self.is_published + and not self.is_cancelled + and now < self.start_time + and (self.registration_start_date is None or now >= self.registration_start_date) + and now < self.effective_registration_end_date + ) @property def current_attendees_count(self): diff --git a/apps/events/resources.py b/apps/events/resources.py index d819ca4..c29987b 100644 --- a/apps/events/resources.py +++ b/apps/events/resources.py @@ -17,7 +17,7 @@ class EventResource(resources.ModelResource): model = Event fields = ( 'id', 'title', 'slug', 'description', 'start_time', 'end_time', - 'event_type', 'address', 'location', 'online_link', 'status', + 'event_type', 'address', 'location', 'online_link', 'is_published', 'is_cancelled', 'capacity', 'price', 'registration_start_date', 'registration_end_date', 'featured_image', 'gallery_images', 'created_at', 'updated_at', 'is_deleted', 'deleted_at' diff --git a/apps/events/tests/integration/test_checkout_lock_contention.py b/apps/events/tests/integration/test_checkout_lock_contention.py index 20ad41c..461fe46 100644 --- a/apps/events/tests/integration/test_checkout_lock_contention.py +++ b/apps/events/tests/integration/test_checkout_lock_contention.py @@ -27,7 +27,7 @@ def test_status_reads_and_expiry_worker_progress_around_a_locked_checkout(): start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), - status=Event.StatusChoices.PUBLISHED, price=1000, capacity=2, + is_published=True, price=1000, capacity=2, ) user = User.objects.create_user(username="checkout-lock-owner", mobile="09191112233", password="TestPass123!") other = User.objects.create_user(username="checkout-lock-other", mobile="09191112234", password="TestPass123!") diff --git a/apps/events/tests/integration/test_event_changes.py b/apps/events/tests/integration/test_event_changes.py index c9269e4..5020759 100644 --- a/apps/events/tests/integration/test_event_changes.py +++ b/apps/events/tests/integration/test_event_changes.py @@ -30,10 +30,10 @@ def test_event_reschedule_and_cancellation_notify_registered_users(client, user_ assert event.location == "New hall" assert notify.call_args.args[1]["type"] == "event_rescheduled" sms.assert_called_with(attendee.mobile, "event_reschedule", event.title) - cancelled = _put(client, path, {"status": Event.StatusChoices.CANCELLED}, auth_headers(staff)) + cancelled = _put(client, path, {"is_cancelled": True}, auth_headers(staff)) assert cancelled.status_code == 200, cancelled.content event.refresh_from_db() - assert event.status == Event.StatusChoices.CANCELLED + assert event.is_cancelled assert notify.call_args.args[1]["type"] == "event_cancelled" assert sms.call_count == 2 diff --git a/apps/events/tests/integration/test_event_models.py b/apps/events/tests/integration/test_event_models.py index b7885fd..8f7a62c 100644 --- a/apps/events/tests/integration/test_event_models.py +++ b/apps/events/tests/integration/test_event_models.py @@ -15,7 +15,7 @@ class TestEventModelTests: def setup_method(self): now = timezone.now() - self.event = Event.objects.create(title='Workshop', description='**Important** details', start_time=now + timedelta(days=1), end_time=now + timedelta(days=1, hours=2), capacity=1, registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(hours=1)) + self.event = Event.objects.create(title='Workshop', description='**Important** details', start_time=now + timedelta(days=1), end_time=now + timedelta(days=1, hours=2), capacity=1, registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(hours=1), is_published=True) self.user = User.objects.create_user(username='attendee', mobile='09191110003', password='password') def test_markdown_description_and_registration_window_remain_available(self): diff --git a/apps/events/tests/integration/test_events.py b/apps/events/tests/integration/test_events.py index 80a7a4a..0e4be67 100644 --- a/apps/events/tests/integration/test_events.py +++ b/apps/events/tests/integration/test_events.py @@ -43,16 +43,18 @@ class TestEventsAPIIntegrationTests: type(self).setUpTestData() self.token = create_jwt_token(self.user) self.staff_token = create_jwt_token(self.staff) - self.event = self._create_event(title='Integration Event', description='Integration description.', status=Event.StatusChoices.PUBLISHED, price=0) - self.other_event = self._create_event(title='Other Published', description='Searchable', status=Event.StatusChoices.PUBLISHED, price=0) + self.event = self._create_event(title='Integration Event', description='Integration description.', price=0) + self.other_event = self._create_event(title='Other Published', description='Searchable', price=0) def _auth_headers(self, token): return {'HTTP_AUTHORIZATION': f'Bearer {token}'} def _create_event(self, **overrides): now = timezone.now() - defaults = {'title': 'Event Title', 'description': 'Description', 'start_time': now, 'end_time': now + timedelta(hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=5), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'location': 'Campus', 'online_link': 'https://meet.example.com', 'price': 0, 'capacity': 10, 'status': Event.StatusChoices.PUBLISHED} + defaults = {'title': 'Event Title', 'description': 'Description', 'start_time': now + timedelta(days=2), 'end_time': now + timedelta(days=2, hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=1), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'location': 'Campus', 'online_link': 'https://meet.example.com', 'price': 0, 'capacity': 10, 'is_published': True} defaults.update(overrides) + if 'registration_end_date' not in overrides and defaults['registration_end_date'] > defaults['start_time']: + defaults['registration_end_date'] = defaults['start_time'] return Event.objects.create(**defaults) def _create_gallery_image(self): @@ -69,7 +71,7 @@ class TestEventsAPIIntegrationTests: return Registration.objects.create(event=event, user=user, status=status, final_price=event.price) def test_list_events_filters_and_search(self): - response = self.client.get('/api/events/', {'status': 'published', 'search': 'Searchable'}) + response = self.client.get('/api/events/', {'search': 'Searchable'}) data = response.json() assert response.status_code == 200 assert any((item['id'] == self.other_event.id for item in data)) @@ -83,7 +85,7 @@ class TestEventsAPIIntegrationTests: assert response_slug.json()['slug'] == self.event.slug def test_create_update_and_delete_event(self): - payload = {'title': 'New Event', 'description': 'Desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'status': Event.StatusChoices.DRAFT, 'price': 5000} + payload = {'title': 'New Event', 'description': 'Desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'is_published': False, 'price': 5000} created = self.client.post('/api/events/', data=json.dumps(payload), content_type='application/json', **self._auth_headers(self.staff_token)) assert created.status_code == 201 event_id = created.json()['id'] @@ -105,7 +107,7 @@ class TestEventsAPIIntegrationTests: assert detail_resp.status_code == 200 def test_list_events_filters_by_event_type_and_search(self): - event = self._create_event(title='Special Search', description='Unique discovery', event_type=Event.TypeChoices.ONLINE, status=Event.StatusChoices.PUBLISHED) + event = self._create_event(title='Special Search', description='Unique discovery', event_type=Event.TypeChoices.ONLINE) response = self.client.get('/api/events/', {'event_type': Event.TypeChoices.ONLINE, 'search': 'Unique discovery'}) assert response.status_code == 200 assert any((item['id'] == event.id for item in response.json())) @@ -160,11 +162,17 @@ class TestEventsAPIIntegrationTests: self._create_registration(full, self.user, status=Registration.StatusChoices.CONFIRMED) upcoming = self.client.get('/api/events/browse/', {'state': 'upcoming', 'search': 'Browse'}).json() - open_results = self.client.get('/api/events/browse/', {'state': 'open', 'search': 'Browse'}).json() + closed_results = self.client.get('/api/events/browse/', {'state': 'registration_closed', 'search': 'Browse'}).json() + full_results = self.client.get('/api/events/browse/', {'state': 'full', 'search': 'Browse'}).json() + open_results = self.client.get('/api/events/browse/', {'state': 'registration_open', 'search': 'Browse'}).json() upcoming_ids = {item['id'] for item in upcoming['results']} + closed_ids = {item['id'] for item in closed_results['results']} + full_ids = {item['id'] for item in full_results['results']} open_ids = {item['id'] for item in open_results['results']} - assert {not_started.id, closed.id, full.id}.issubset(upcoming_ids) + assert upcoming_ids == {not_started.id} + assert closed_ids == {closed.id} + assert full_ids == {full.id} assert open_event.id not in upcoming_ids assert open_ids == {open_event.id} @@ -220,13 +228,13 @@ class TestEventsAPIIntegrationTests: assert outside_day.id not in {item['id'] for item in paid_results['results']} def test_list_events_handles_comma_status_parameter(self): - event = self._create_event(title='Comma Event', status=Event.StatusChoices.PUBLISHED) - results = list_events(None, status=f'{Event.StatusChoices.PUBLISHED},{Event.StatusChoices.DRAFT}', event_type=None, search=None, limit=10, offset=0) + event = self._create_event(title='Comma Event') + results = list_events(None, status='registration_open,in_progress', event_type=None, search=None, limit=10, offset=0) assert event in list(results) def test_create_event_attaches_gallery_images(self): gallery = self._create_gallery_image() - payload = {'title': 'Gallery Event', 'description': 'Gallery desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'status': Event.StatusChoices.DRAFT, 'price': 5000, 'gallery_image_ids': [gallery.id]} + payload = {'title': 'Gallery Event', 'description': 'Gallery desc', 'start_time': (timezone.now() + timedelta(days=1)).isoformat(), 'end_time': (timezone.now() + timedelta(days=1, hours=1)).isoformat(), 'event_type': Event.TypeChoices.ON_SITE, 'is_published': False, 'price': 5000, 'gallery_image_ids': [gallery.id]} response = self.client.post('/api/events/', data=json.dumps(payload), content_type='application/json', **self._auth_headers(self.staff_token)) body = response.json() assert response.status_code == 201 @@ -461,7 +469,8 @@ class TestEventSchemasIntegrationTests: self.user = User.objects.create_user(username='schema_user', email='schema.user@example.com', mobile='09198000003', password=self.password) self.user.is_email_verified = True self.user.save(update_fields=['is_email_verified']) - self.event = Event.objects.create(title='Schema Event', description='**bold**', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), price=1000, slug='schema-event') + now = timezone.now() + self.event = Event.objects.create(title='Schema Event', description='**bold**', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), price=1000, slug='schema-event', is_published=True) Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.CONFIRMED, final_price=0) Registration.objects.create(event=self.event, user=self.user, status=Registration.StatusChoices.ATTENDED, final_price=0) diff --git a/apps/events/tests/integration/test_registration_checkout.py b/apps/events/tests/integration/test_registration_checkout.py index ba081b7..dee8854 100644 --- a/apps/events/tests/integration/test_registration_checkout.py +++ b/apps/events/tests/integration/test_registration_checkout.py @@ -37,7 +37,7 @@ class TestRegistrationCheckout: slug=f"checkout-{suffix}", price=1250000, capacity=1, - status=Event.StatusChoices.PUBLISHED, + is_published=True, ) def auth_headers(self): @@ -509,8 +509,8 @@ class TestRegistrationCheckout: from apps.payments.tasks import sweep_stale_payment_verifications enqueue = mocker.patch("apps.payments.tasks.reconcile_payment.apply_async") - self.event.status = Event.StatusChoices.CANCELLED - self.event.save(update_fields=["status"]) + self.event.is_cancelled = True + self.event.save(update_fields=["is_cancelled"]) cancelled = cancel_event_checkouts(self.event.id) diff --git a/apps/events/tests/unit/test_admin_actions.py b/apps/events/tests/unit/test_admin_actions.py index 4be8fcf..871b4c0 100644 --- a/apps/events/tests/unit/test_admin_actions.py +++ b/apps/events/tests/unit/test_admin_actions.py @@ -19,24 +19,23 @@ def test_event_admin_formats_empty_price_and_capacity(): assert model_admin.is_registration_open_display(event) -def test_event_admin_status_actions_update_selection(mocker): +def test_event_admin_publication_and_cancellation_actions_update_selection(mocker): model_admin = EventAdmin(Event, admin.site) mocker.patch.object(model_admin, "message_user") cancel_checkouts = mocker.patch("apps.events.admin.cancel_event_checkouts") queryset = mocker.Mock() queryset.count.return_value = 2 - for action, status in ( - (model_admin.make_published, Event.StatusChoices.PUBLISHED), - (model_admin.make_draft, Event.StatusChoices.DRAFT), - (model_admin.make_completed, Event.StatusChoices.COMPLETED), + for action, field_value in ( + (model_admin.make_published, {"is_published": True}), + (model_admin.make_draft, {"is_published": False}), ): action(SimpleNamespace(), queryset) - queryset.update.assert_called_with(status=status) + queryset.update.assert_called_with(**field_value) - published = SimpleNamespace(status=Event.StatusChoices.PUBLISHED, pk=10, save=mocker.Mock()) - already_cancelled = SimpleNamespace(status=Event.StatusChoices.CANCELLED, pk=11, save=mocker.Mock()) + published = SimpleNamespace(is_cancelled=False, pk=10, save=mocker.Mock()) + already_cancelled = SimpleNamespace(is_cancelled=True, pk=11, save=mocker.Mock()) model_admin.make_cancelled(SimpleNamespace(), [published, already_cancelled]) - published.save.assert_called_once_with(update_fields=["status"]) + published.save.assert_called_once_with(update_fields=["is_cancelled", "updated_at"]) already_cancelled.save.assert_not_called() cancel_checkouts.assert_called_once_with(published.pk) diff --git a/apps/payments/api/views.py b/apps/payments/api/views.py index 973e784..41c8811 100644 --- a/apps/payments/api/views.py +++ b/apps/payments/api/views.py @@ -367,7 +367,7 @@ def create_payment(request, payload: CreatePaymentIn): pk=payload.event_id, is_deleted=False, ) - if event.status == Event.StatusChoices.CANCELLED: + if event.is_cancelled: raise HttpError(400, "این رویداد لغو شده است و امکان پرداخت ندارد.") keyed_payment = Payment.objects.filter( @@ -440,14 +440,8 @@ def create_payment(request, payload: CreatePaymentIn): needs_new_checkout = not registration or registration.status == Registration.StatusChoices.CANCELLED if needs_new_checkout: - if event.status != Event.StatusChoices.PUBLISHED: + if event.get_display_status(now=now) != "registration_open": raise HttpError(400, "ثبت‌نام این رویداد فعال نیست.") - if event.registration_start_date and event.registration_start_date > now: - raise HttpError(400, "زمان ثبت‌نام هنوز آغاز نشده است") - if event.registration_end_date and event.registration_end_date < now: - raise HttpError(400, "مهلت ثبت‌نام به پایان رسیده‌است") - if not event.has_available_slots: - raise HttpError(400, "ظرفیت شرکت‌کنندگان تکمیل است") if not registration or registration.status == Registration.StatusChoices.CANCELLED: registration = Registration.objects.create( event=event, @@ -604,7 +598,7 @@ def create_payment(request, payload: CreatePaymentIn): if ( registration is not None and registration.status == Registration.StatusChoices.CANCELLED - ) or event.status == Event.StatusChoices.CANCELLED: + ) or event.is_cancelled: if registration is not None: queue_checkout_reconciliation(registration) @@ -653,7 +647,7 @@ def _checkout_may_be_confirmed(event, registration, paid_at, now): Registration.StatusChoices.ATTENDED, ): return True - if event.status == Event.StatusChoices.CANCELLED: + if event.is_cancelled: return False expiry = registration.checkout_expires_at @@ -736,7 +730,7 @@ def _mark_payment_paid(payment_id: int, verification_data: dict | None = None): now = timezone.now() if registration and registration.status == Registration.StatusChoices.PENDING: - if event.status == Event.StatusChoices.CANCELLED: + if event.is_cancelled: registration.status = Registration.StatusChoices.CANCELLED registration.cancellation_reason = Registration.CancellationReasonChoices.EVENT_CANCELLED registration.save(update_fields=["status", "cancellation_reason", "updated_at"]) diff --git a/apps/payments/tests/integration/test_payment_models.py b/apps/payments/tests/integration/test_payment_models.py index 3865282..769514e 100644 --- a/apps/payments/tests/integration/test_payment_models.py +++ b/apps/payments/tests/integration/test_payment_models.py @@ -23,7 +23,7 @@ class PaymentTestMixin: @staticmethod def _create_event(**overrides): now = timezone.now() - defaults = {'title': 'Sample', 'description': 'Desc', 'start_time': now, 'end_time': now + timedelta(hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=5), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'price': 100000, 'capacity': 10, 'status': Event.StatusChoices.PUBLISHED} + defaults = {'title': 'Sample', 'description': 'Desc', 'start_time': now + timedelta(days=2), 'end_time': now + timedelta(days=2, hours=2), 'registration_start_date': now - timedelta(days=1), 'registration_end_date': now + timedelta(days=1), 'slug': f'event-{uuid.uuid4().hex[:6]}', 'price': 100000, 'capacity': 10, 'is_published': True} defaults.update(overrides) return Event.objects.create(**defaults) diff --git a/apps/payments/tests/integration/test_payment_reconciliation.py b/apps/payments/tests/integration/test_payment_reconciliation.py index 5c77541..04a50ed 100644 --- a/apps/payments/tests/integration/test_payment_reconciliation.py +++ b/apps/payments/tests/integration/test_payment_reconciliation.py @@ -243,8 +243,8 @@ def test_late_success_after_user_or_event_cancellation_is_refunded( registration.cancellation_reason = reason registration.save(update_fields=["cancellation_reason"]) if cancel_event: - payment.event.status = payment.event.StatusChoices.CANCELLED - payment.event.save(update_fields=["status"]) + payment.event.is_cancelled = True + payment.event.save(update_fields=["is_cancelled"]) mocker.patch("apps.payments.tasks.refund_payment.apply_async") paid_at = (registration.checkout_expires_at - timedelta(seconds=1)).isoformat() diff --git a/apps/payments/tests/integration/test_payments.py b/apps/payments/tests/integration/test_payments.py index 49bacb7..35cb9e3 100644 --- a/apps/payments/tests/integration/test_payments.py +++ b/apps/payments/tests/integration/test_payments.py @@ -22,14 +22,16 @@ class TestPaymentsAPIIntegrationTests: def setup_method(self): type(self).setUpTestData() - self.event = Event.objects.create(title='Pay Event', description='Payment event', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=2), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), slug='pay-event', price=50000, capacity=10, status=Event.StatusChoices.PUBLISHED) + now = timezone.now() + self.event = Event.objects.create(title='Pay Event', description='Payment event', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=2), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), slug='pay-event', price=50000, capacity=10, is_published=True) self.token = create_jwt_token(self.user) def _headers(self): return {'HTTP_AUTHORIZATION': f'Bearer {self.token}'} def _create_paid_event(self): - return Event.objects.create(title='Paid Event', description='Paid', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=2), slug=f'paid-{timezone.now().timestamp()}', price=20000, capacity=5, status=Event.StatusChoices.PUBLISHED) + now = timezone.now() + return Event.objects.create(title='Paid Event', description='Paid', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), slug=f'paid-{now.timestamp()}', price=20000, capacity=5, is_published=True) def _create_discount_code(self, event): code = DiscountCode.objects.create(code='DISC50', value=50, is_active=True) @@ -37,7 +39,8 @@ class TestPaymentsAPIIntegrationTests: return code def test_create_payment_for_free_event(self): - free = Event.objects.create(title='Free', description='Zero', start_time=timezone.now(), end_time=timezone.now() + timedelta(hours=1), registration_start_date=timezone.now() - timedelta(days=1), registration_end_date=timezone.now() + timedelta(days=1), slug='free-event', price=0, capacity=10, status=Event.StatusChoices.PUBLISHED) + now = timezone.now() + free = Event.objects.create(title='Free', description='Zero', start_time=now + timedelta(days=2), end_time=now + timedelta(days=2, hours=1), registration_start_date=now - timedelta(days=1), registration_end_date=now + timedelta(days=1), slug='free-event', price=0, capacity=10, is_published=True) response = self.client.post('/api/payments/create', data=json.dumps({'event_id': free.id, 'description': 'Free registration'}), content_type='application/json', **self._headers()) assert response.status_code == 200 data = response.json() diff --git a/apps/users/api/schemas.py b/apps/users/api/schemas.py index b407498..2704d5b 100644 --- a/apps/users/api/schemas.py +++ b/apps/users/api/schemas.py @@ -315,6 +315,15 @@ class PublicProfileEventSchema(Schema): title: str slug: str 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_cancelled: bool = False image_url: Optional[str] = None participation: Literal["confirmed", "attended"] diff --git a/apps/users/api/views.py b/apps/users/api/views.py index 19f6d56..91b5396 100644 --- a/apps/users/api/views.py +++ b/apps/users/api/views.py @@ -400,7 +400,7 @@ def get_public_profile(request, username: str): Registration.objects.filter( user=user, event__is_deleted=False, - event__status__in=[Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED], + event__is_published=True, status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], ) .select_related("event") @@ -412,6 +412,15 @@ def get_public_profile(request, username: str): "title": event.title, "slug": event.slug, "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, + "registration_count": event.current_attendees_count, + "status": event.status, + "status_label": event.status_label, + "is_cancelled": event.is_cancelled, "image_url": request.build_absolute_uri(event.featured_image.url) if event.featured_image else None, "participation": registration.status, }) diff --git a/apps/users/tests/integration/test_api_flows.py b/apps/users/tests/integration/test_api_flows.py index e94f1e2..c7da67d 100644 --- a/apps/users/tests/integration/test_api_flows.py +++ b/apps/users/tests/integration/test_api_flows.py @@ -92,7 +92,7 @@ def test_public_profile_hides_personal_fields_until_the_user_opts_in(client, use ) event = event_factory(slug="public-profile-event") Registration.objects.create(event=event, user=member, status=Registration.StatusChoices.CONFIRMED) - draft_event = event_factory(slug="private-profile-event", status=Event.StatusChoices.DRAFT) + draft_event = event_factory(slug="private-profile-event", is_published=False) Registration.objects.create(event=draft_event, user=member, status=Registration.StatusChoices.ATTENDED) public_path = f"/api/auth/public-profile/{member.username}" diff --git a/conftest.py b/conftest.py index 04181a8..a45ea36 100644 --- a/conftest.py +++ b/conftest.py @@ -101,7 +101,7 @@ def event_factory(db): "registration_start_date": now - timedelta(days=1), "registration_end_date": now + timedelta(days=1), "capacity": 10, - "status": Event.StatusChoices.PUBLISHED, + "is_published": True, } values.update(overrides) return Event.objects.create(**values)