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)