initial commit
This commit is contained in:
418
apps/events/admin.py
Normal file
418
apps/events/admin.py
Normal file
@@ -0,0 +1,418 @@
|
||||
from django.contrib import admin, messages
|
||||
from django.template.response import TemplateResponse
|
||||
from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME
|
||||
from django.template.loader import render_to_string
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect
|
||||
from django.urls import reverse_lazy
|
||||
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from core.templatetags.jalali import jdate
|
||||
from unfold.decorators import action as unfold_action
|
||||
|
||||
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from apps.events.models import Event, Registration, EventEmailLog
|
||||
from apps.events.resources import EventResource, RegistrationResource
|
||||
from apps.events.tasks import (
|
||||
queue_skyroom_credentials,
|
||||
send_skyroom_credentials_individual_task,
|
||||
send_event_reminder_task,
|
||||
queue_event_announcement,
|
||||
queue_invites_to_non_registered_users,
|
||||
)
|
||||
from apps.events.admin_forms import AnnouncementForm
|
||||
from apps.events.tasks import _send_html_email
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
class EventAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
resource_class = EventResource
|
||||
list_display = (
|
||||
'title', 'event_type', 'start_time_display', 'end_time_display', 'status',
|
||||
'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display'
|
||||
)
|
||||
list_filter = (
|
||||
'event_type', 'status', 'is_deleted',
|
||||
'start_time', 'end_time', 'registration_start_date', 'registration_end_date',
|
||||
SoftDeleteListFilter
|
||||
)
|
||||
search_fields = ('title', 'description', 'address')
|
||||
prepopulated_fields = {'slug': ('title',)}
|
||||
date_hierarchy = 'start_time'
|
||||
filter_horizontal = ('gallery_images',)
|
||||
|
||||
fieldsets = (
|
||||
('Event Details', {
|
||||
'fields': ('title', 'slug', 'description', 'featured_image')
|
||||
}),
|
||||
('Timing & Type', {
|
||||
'fields': ('start_time', 'end_time', 'event_type', 'status')
|
||||
}),
|
||||
('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.'
|
||||
}),
|
||||
('Registration & Pricing', {
|
||||
'fields': ('capacity', 'price', 'registration_start_date', 'registration_end_date', 'registration_success_markdown'),
|
||||
'description': 'Leave capacity blank for unlimited. Leave price blank for free events.'
|
||||
}),
|
||||
('Gallery', {
|
||||
'fields': ('gallery_images',),
|
||||
'description': 'Add images related to this event from the Gallery app.'
|
||||
}),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
readonly_fields = ('deleted_at',)
|
||||
|
||||
actions = BaseModelAdmin.actions + [
|
||||
'make_published',
|
||||
'make_draft',
|
||||
'make_cancelled',
|
||||
'make_completed',
|
||||
'restore_events',
|
||||
]
|
||||
|
||||
actions_row = [
|
||||
'action_send_announcement',
|
||||
'action_send_reminder_now',
|
||||
'action_send_skyroom_credentials',
|
||||
'action_invite_other_users',
|
||||
]
|
||||
|
||||
@admin.display(description="Price")
|
||||
def price_display(self, obj):
|
||||
return obj.price if obj.price is not None else "رایگان"
|
||||
|
||||
@admin.display(description="Start")
|
||||
def start_time_display(self, obj):
|
||||
return jdate(obj.start_time)
|
||||
|
||||
@admin.display(description="End")
|
||||
def end_time_display(self, obj):
|
||||
return jdate(obj.end_time)
|
||||
|
||||
@admin.display(description="Capacity")
|
||||
def capacity_display(self, obj):
|
||||
return obj.capacity if obj.capacity is not None else "نامحدود"
|
||||
|
||||
@admin.display(description="Attendees")
|
||||
def attendees_display(self, obj):
|
||||
return obj.current_attendees_count
|
||||
|
||||
@admin.display(description="Open", boolean=True)
|
||||
def is_registration_open_display(self, obj):
|
||||
return obj.is_registration_open
|
||||
|
||||
@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.")
|
||||
|
||||
@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.")
|
||||
|
||||
@admin.action(description="Mark selected events as cancelled")
|
||||
def make_cancelled(self, request, queryset):
|
||||
queryset.update(status=Event.StatusChoices.CANCELLED)
|
||||
self.message_user(request, f"Cancelled {queryset.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"Marked {queryset.count()} events as completed.")
|
||||
|
||||
@admin.action(description="Restore selected events")
|
||||
def restore_events(self, request, queryset):
|
||||
for event in queryset:
|
||||
event.restore()
|
||||
self.message_user(request, f"Restored {queryset.count()} events.")
|
||||
|
||||
@unfold_action(description="Send Skyroom Credentials")
|
||||
def action_send_skyroom_credentials(self, request, object_id: int):
|
||||
event = Event.objects.get(pk=object_id)
|
||||
queue_skyroom_credentials.delay(event.pk)
|
||||
self.message_user(request, f"ارسال مشخصات اسکایروم برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||
|
||||
@unfold_action(description="Send new Reminder")
|
||||
def action_send_reminder_now(self, request, object_id: int):
|
||||
event = Event.objects.get(pk=object_id)
|
||||
send_event_reminder_task.delay(event.pk)
|
||||
self.message_user(request, f"یادآوری برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||
|
||||
@unfold_action(description="send new Announcement")
|
||||
def action_send_announcement(self, request, object_id: int):
|
||||
"""
|
||||
این اکشن یک فرم میگیرد (عنوان/متن/وضعیتها) و با تمپلیت Unfold نشان داده میشود.
|
||||
"""
|
||||
form = AnnouncementForm(request.POST or None)
|
||||
event = Event.objects.get(pk=object_id)
|
||||
|
||||
if request.method == "POST" and form.is_valid():
|
||||
subject = form.cleaned_data["subject"]
|
||||
body_html = form.cleaned_data["body_html"]
|
||||
statuses = form.cleaned_data["statuses"] or None
|
||||
queue_event_announcement.delay(event.pk, subject, body_html, statuses=statuses)
|
||||
self.message_user(request, f"اطلاعیه برای رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": "ارسال اطلاعیه گروهی",
|
||||
"opts": self.model._meta,
|
||||
"form": form,
|
||||
"action_name": "action_send_announcement",
|
||||
"action_checkbox_name": ACTION_CHECKBOX_NAME,
|
||||
}
|
||||
return TemplateResponse(request, "forms/admin_announcement.html", context)
|
||||
|
||||
@unfold_action(description="Invite other users")
|
||||
def action_invite_other_users(self, request, object_id: int):
|
||||
event = Event.objects.get(pk=object_id)
|
||||
queue_invites_to_non_registered_users.delay(event.pk)
|
||||
self.message_user(request, f"دعوت برای شرکت در رویداد '{event.title}' صف شد.", messages.SUCCESS)
|
||||
return redirect(reverse_lazy("admin:events_event_changelist"))
|
||||
|
||||
|
||||
@admin.register(Registration)
|
||||
class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
resource_class = RegistrationResource
|
||||
list_display = (
|
||||
'user',
|
||||
'event',
|
||||
'status',
|
||||
'registered_at',
|
||||
'ticket_id',
|
||||
'discount_code',
|
||||
'discount_amount',
|
||||
'final_price',
|
||||
)
|
||||
list_filter = (
|
||||
'status',
|
||||
'event',
|
||||
'is_deleted',
|
||||
'registered_at',
|
||||
SoftDeleteListFilter
|
||||
)
|
||||
search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id')
|
||||
readonly_fields = (
|
||||
'ticket_id',
|
||||
'registered_at',
|
||||
'confirmation_email_sent_at',
|
||||
'cancellation_email_sent_at',
|
||||
'discount_code',
|
||||
'discount_amount',
|
||||
'final_price',
|
||||
'deleted_at',
|
||||
)
|
||||
|
||||
fieldsets = (
|
||||
(
|
||||
'Registration Details',
|
||||
{
|
||||
'fields': (
|
||||
'user',
|
||||
'event',
|
||||
'status',
|
||||
'registered_at',
|
||||
'ticket_id',
|
||||
'confirmation_email_sent_at',
|
||||
'cancellation_email_sent_at',
|
||||
)
|
||||
},
|
||||
),
|
||||
(
|
||||
'Pricing & Discount',
|
||||
{
|
||||
'fields': ('discount_code', 'discount_amount', 'final_price'),
|
||||
'classes': ('collapse',),
|
||||
},
|
||||
),
|
||||
('Soft Delete', {
|
||||
'fields': ('is_deleted', 'deleted_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = BaseModelAdmin.actions + [
|
||||
'confirm_registrations',
|
||||
'cancel_registrations',
|
||||
'mark_attended',
|
||||
'restore_registrations',
|
||||
]
|
||||
actions_row = [
|
||||
'action_email_selected',
|
||||
'action_send_skyroom_credentials',
|
||||
]
|
||||
|
||||
@admin.action(description="Confirm selected registrations")
|
||||
def confirm_registrations(self, request, queryset):
|
||||
queryset.update(status=Registration.StatusChoices.CONFIRMED)
|
||||
self.message_user(request, f"Confirmed {queryset.count()} registrations.")
|
||||
|
||||
@admin.action(description="Cancel selected registrations")
|
||||
def cancel_registrations(self, request, queryset):
|
||||
queryset.update(status=Registration.StatusChoices.CANCELLED)
|
||||
self.message_user(request, f"Cancelled {queryset.count()} registrations.")
|
||||
|
||||
@admin.action(description="Mark selected registrations as attended")
|
||||
def mark_attended(self, request, queryset):
|
||||
queryset.update(status=Registration.StatusChoices.ATTENDED)
|
||||
self.message_user(request, f"Marked {queryset.count()} registrations as attended.")
|
||||
|
||||
@admin.action(description="Restore selected registrations")
|
||||
def restore_registrations(self, request, queryset):
|
||||
for registration in queryset:
|
||||
registration.restore()
|
||||
self.message_user(request, f"Restored {queryset.count()} registrations.")
|
||||
|
||||
@unfold_action(description="send email to registrated user")
|
||||
def action_email_selected(self, request, object_id: int):
|
||||
"""
|
||||
همان فرم اطلاعیه را میگیرد و به افراد انتخابشده ایمیل میزند.
|
||||
برای نمایش فرم، از تمپلیت Unfold استفاده میکنیم.
|
||||
"""
|
||||
form = AnnouncementForm(request.POST or None)
|
||||
registration = Registration.objects.get(id=object_id)
|
||||
|
||||
if request.method == "POST" and form.is_valid():
|
||||
subject = form.cleaned_data["subject"]
|
||||
body_html = form.cleaned_data["body_html"]
|
||||
|
||||
user = registration.user
|
||||
ctx = {
|
||||
"user": user,
|
||||
"event": registration.event,
|
||||
"body_html": body_html,
|
||||
"event_url": f"{settings.FRONTEND_ROOT}events/{registration.event.slug}",
|
||||
}
|
||||
html = render_to_string("emails/event_announcement.html", ctx)
|
||||
_send_html_email(subject, html, user.email)
|
||||
|
||||
self.message_user(request, f"ارسال ایمیل انجام شد.", messages.SUCCESS)
|
||||
return redirect(reverse_lazy("admin:events_registration_changelist"))
|
||||
|
||||
context = {
|
||||
**self.admin_site.each_context(request),
|
||||
"title": "ارسال ایمیل به ثبتنامهای انتخابشده",
|
||||
"form": AnnouncementForm(),
|
||||
"opts": self.model._meta,
|
||||
"action_name": "action_email_selected",
|
||||
"action_checkbox_name": ACTION_CHECKBOX_NAME,
|
||||
}
|
||||
return TemplateResponse(request, "forms/admin_announcement.html", context)
|
||||
|
||||
@unfold_action(description="Send Skyroom Credentials")
|
||||
def action_send_skyroom_credentials(self, request, object_id: int):
|
||||
send_skyroom_credentials_individual_task.delay(object_id)
|
||||
self.message_user(request, f"ارسال مشخصات اسکایروم به کاربر مربوطه صف شد.", messages.SUCCESS)
|
||||
return redirect(reverse_lazy("admin:events_registration_changelist"))
|
||||
|
||||
|
||||
from apps.events.tasks import send_invite_to_user
|
||||
|
||||
|
||||
|
||||
@admin.register(EventEmailLog)
|
||||
class EventEmailLogAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
list_display = (
|
||||
"id",
|
||||
"event",
|
||||
"user",
|
||||
"user_email",
|
||||
"kind",
|
||||
"status",
|
||||
"sent_at",
|
||||
"created_at",
|
||||
)
|
||||
list_filter = (
|
||||
"kind",
|
||||
"status",
|
||||
"event",
|
||||
("sent_at", admin.EmptyFieldListFilter),
|
||||
("error", admin.EmptyFieldListFilter),
|
||||
SoftDeleteListFilter,
|
||||
)
|
||||
search_fields = (
|
||||
"user__email",
|
||||
"user__username",
|
||||
"user__first_name",
|
||||
"user__last_name",
|
||||
"event__title",
|
||||
)
|
||||
autocomplete_fields = ("event", "user")
|
||||
date_hierarchy = "created_at"
|
||||
ordering = ("-created_at",)
|
||||
list_per_page = 50
|
||||
list_select_related = ("event", "user")
|
||||
|
||||
# چون این مدل برای ایدمپوتنسی حیاتی است، ویرایش دستی را محدود میکنیم
|
||||
readonly_fields = (
|
||||
"event",
|
||||
"user",
|
||||
"kind",
|
||||
"status",
|
||||
"error",
|
||||
"sent_at",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
)
|
||||
fields = readonly_fields
|
||||
|
||||
def has_add_permission(self, request):
|
||||
return False
|
||||
|
||||
def has_change_permission(self, request, obj=None):
|
||||
return True
|
||||
|
||||
actions = BaseModelAdmin.actions + [
|
||||
'resend_selected_emails'
|
||||
]
|
||||
|
||||
@admin.display(description="Email", ordering="user__email")
|
||||
def user_email(self, obj):
|
||||
return obj.user.email or "—"
|
||||
|
||||
@admin.action(description="ارسال مجدد ایمیل برای رکوردهای انتخابشده")
|
||||
def resend_selected_emails(self, request, queryset):
|
||||
"""
|
||||
رکوردهای SENT را اسکیپ میکند، بقیه را به وضعیت pending برمیگرداند
|
||||
و تسک ارسال تکی را در صف میگذارد (ایدِمپوتنت).
|
||||
"""
|
||||
queued = 0
|
||||
skipped = 0
|
||||
|
||||
for log in queryset.select_related("event", "user"):
|
||||
if log.status == EventEmailLog.STATUS_SENT:
|
||||
skipped += 1
|
||||
continue
|
||||
|
||||
# برگرداندن به pending و پاک کردن خطا
|
||||
if log.status != EventEmailLog.STATUS_PENDING or log.error:
|
||||
log.status = EventEmailLog.STATUS_PENDING
|
||||
log.error = ""
|
||||
log.save(update_fields=["status", "error", "updated_at"])
|
||||
|
||||
# صف کردن تسک اتمی
|
||||
send_invite_to_user.delay(log.event_id, log.user_id)
|
||||
queued += 1
|
||||
|
||||
if queued:
|
||||
self.message_user(
|
||||
request,
|
||||
"%(n)d مورد در صف ارسال قرار گرفت." % {"n": queued},
|
||||
level=messages.SUCCESS,
|
||||
)
|
||||
if skipped:
|
||||
self.message_user(
|
||||
request,
|
||||
"%(n)d مورد قبلاً ارسال شده بود و نادیده گرفته شد." % {"n": skipped},
|
||||
level=messages.WARNING,
|
||||
)
|
||||
25
apps/events/admin_forms.py
Normal file
25
apps/events/admin_forms.py
Normal file
@@ -0,0 +1,25 @@
|
||||
from django import forms
|
||||
|
||||
from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget
|
||||
|
||||
from apps.events.models import Registration
|
||||
|
||||
|
||||
class AnnouncementForm(forms.Form):
|
||||
subject = forms.CharField(
|
||||
label="Subject",
|
||||
max_length=200,
|
||||
widget=UnfoldAdminTextInputWidget,
|
||||
)
|
||||
body_html = forms.CharField(
|
||||
label="Text (HTML or plain-text)",
|
||||
widget=UnfoldAdminTextareaWidget,
|
||||
help_text="you can enter either HTML or plain-text."
|
||||
)
|
||||
statuses = forms.MultipleChoiceField(
|
||||
label="Statuses to sent",
|
||||
required=False,
|
||||
choices=Registration.StatusChoices.choices,
|
||||
initial=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED],
|
||||
widget=forms.CheckboxSelectMultiple,
|
||||
)
|
||||
0
apps/events/api/__init__.py
Normal file
0
apps/events/api/__init__.py
Normal file
247
apps/events/api/schemas.py
Normal file
247
apps/events/api/schemas.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Event and gallery API schemas."""
|
||||
|
||||
from uuid import UUID
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import field_validator
|
||||
from typing import Literal, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from apps.blog.api.schemas import AuthorSchema
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.payments.models import Payment
|
||||
|
||||
|
||||
class EventGallerySchema(ModelSchema):
|
||||
"""Schema representing gallery items associated with an event."""
|
||||
uploaded_by: AuthorSchema
|
||||
file_size_mb: float
|
||||
markdown_url: str
|
||||
absolute_image_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Gallery
|
||||
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||
'width', 'height', 'is_public', 'created_at']
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_url(obj, context):
|
||||
request = context['request']
|
||||
if obj.image and hasattr(obj.image, 'url'):
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return None
|
||||
|
||||
class EventSchema(ModelSchema):
|
||||
"""Schema providing full event details for API responses."""
|
||||
gallery_images: List[EventGallerySchema]
|
||||
description_html: str
|
||||
registration_count: int
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Event
|
||||
model_fields = [
|
||||
'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'
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_url(obj, context):
|
||||
request = context['request']
|
||||
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
|
||||
@staticmethod
|
||||
def resolve_description_html(obj):
|
||||
return obj.description_html
|
||||
|
||||
|
||||
class EventListSchema(Schema):
|
||||
"""Condensed event representation for list endpoints."""
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
featured_image: Optional[str] = None
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
event_type: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: str
|
||||
registration_count: int
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_url(obj, context):
|
||||
request = context['request']
|
||||
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
|
||||
class EventCreateSchema(Schema):
|
||||
"""Payload for creating events via the API."""
|
||||
title: str
|
||||
description: str
|
||||
event_type: str
|
||||
address: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
online_link: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: str = "draft"
|
||||
gallery_image_ids: Optional[List[int]] = []
|
||||
|
||||
class EventUpdateSchema(Schema):
|
||||
"""Payload for updating events via the API."""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
event_type: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
online_link: Optional[str] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
gallery_image_ids: Optional[List[int]] = None
|
||||
|
||||
class RegistrationSchema(ModelSchema):
|
||||
"""Schema describing a registration entry with event context."""
|
||||
user: AuthorSchema
|
||||
event: EventListSchema
|
||||
discount_code: str | None = None
|
||||
|
||||
class Config:
|
||||
model = Registration
|
||||
model_fields = [
|
||||
'id',
|
||||
'status',
|
||||
'registered_at',
|
||||
'ticket_id',
|
||||
'discount_amount',
|
||||
'final_price',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_discount_code(obj):
|
||||
return obj.discount_code.code if obj.discount_code else None
|
||||
|
||||
|
||||
class AdminUserSchema(Schema):
|
||||
id: int
|
||||
username: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
|
||||
|
||||
class PaymentAdminSchema(Schema):
|
||||
id: int
|
||||
authority: Optional[str]
|
||||
ref_id: Optional[str]
|
||||
status: int
|
||||
status_label: str
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
verified_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
discount_code: Optional[str]
|
||||
user: AdminUserSchema
|
||||
|
||||
@field_validator("discount_code", mode="before")
|
||||
def normalize_discount_code(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "code"):
|
||||
return value.code
|
||||
return str(value)
|
||||
|
||||
|
||||
class RegistrationAdminSchema(Schema):
|
||||
id: int
|
||||
ticket_id: UUID
|
||||
status: str
|
||||
status_label: str
|
||||
registered_at: datetime
|
||||
final_price: Optional[int]
|
||||
discount_amount: Optional[int]
|
||||
user: AdminUserSchema
|
||||
payments: List[PaymentAdminSchema]
|
||||
|
||||
|
||||
class EventAdminDetailSchema(EventSchema):
|
||||
registrations: List[RegistrationAdminSchema] = []
|
||||
|
||||
@staticmethod
|
||||
def resolve_registrations(obj):
|
||||
return obj.registrations.select_related("user").prefetch_related(
|
||||
"payments__discount_code"
|
||||
).order_by("-registered_at")
|
||||
|
||||
class PaginatedRegistrationSchema(Schema):
|
||||
count: int
|
||||
next: Optional[str] = None
|
||||
previous: Optional[str] = None
|
||||
results: List[RegistrationAdminSchema]
|
||||
|
||||
class RegistrationStatusUpdateSchema(Schema):
|
||||
status: str
|
||||
|
||||
class RegisterationDetailSchema(Schema):
|
||||
"""Detailed registration information with associated event metadata."""
|
||||
event_image: Optional[str]
|
||||
event_title: str
|
||||
event_type: str
|
||||
ticket_id: UUID
|
||||
status: str
|
||||
registered_at: datetime
|
||||
success_markdown: Optional[str]
|
||||
|
||||
class EventBriefSchema(Schema):
|
||||
"""Minimal event representation used for nested responses."""
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
start_date: datetime
|
||||
end_date: Optional[datetime] = None
|
||||
location: Optional[str] = None
|
||||
price: int
|
||||
absolute_image_url: Optional[str] = None
|
||||
|
||||
class MyEventRegistrationOut(Schema):
|
||||
"""Registration information as returned to authenticated users."""
|
||||
id: int
|
||||
created_at: datetime
|
||||
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||
event: EventBriefSchema
|
||||
|
||||
class RegistrationStatusOut(Schema):
|
||||
is_registered: bool
|
||||
|
||||
|
||||
class RegistrationCreateSchema(Schema):
|
||||
discount_code: Optional[str] = None
|
||||
370
apps/events/api/views.py
Normal file
370
apps/events/api/views.py
Normal file
@@ -0,0 +1,370 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Case, When, IntegerField
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from ninja import Router, Query
|
||||
from ninja.errors import HttpError
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from apps.events.api.schemas import (
|
||||
EventAdminDetailSchema,
|
||||
EventBriefSchema,
|
||||
EventCreateSchema,
|
||||
EventListSchema,
|
||||
EventSchema,
|
||||
EventUpdateSchema,
|
||||
MyEventRegistrationOut,
|
||||
PaginatedRegistrationSchema,
|
||||
RegisterationDetailSchema,
|
||||
RegistrationCreateSchema,
|
||||
RegistrationSchema,
|
||||
RegistrationStatusOut,
|
||||
RegistrationStatusUpdateSchema,
|
||||
)
|
||||
from core.authentication import jwt_auth
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.payments.models import DiscountCode
|
||||
from core.api.schemas import ErrorSchema, MessageSchema
|
||||
|
||||
events_router = Router()
|
||||
|
||||
# Event endpoints
|
||||
@events_router.get("/", response=List[EventListSchema])
|
||||
def list_events(
|
||||
request,
|
||||
# status: Optional[str] = None,
|
||||
status: Optional[List[str]] = Query(None),
|
||||
event_type: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
):
|
||||
"""List events with filtering and pagination"""
|
||||
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
|
||||
|
||||
if status:
|
||||
if "," in status:
|
||||
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||||
queryset = queryset.filter(status__in=parts)
|
||||
else:
|
||||
queryset = queryset.filter(status__in=status)
|
||||
if event_type:
|
||||
queryset = queryset.filter(event_type=event_type)
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search) | Q(description__icontains=search)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
published_first=Case(
|
||||
When(status='published', then=0),
|
||||
default=1,
|
||||
output_field=IntegerField()
|
||||
)
|
||||
).order_by('published_first', '-start_time', '-id')
|
||||
|
||||
events = queryset[offset:offset + limit]
|
||||
return events
|
||||
|
||||
@events_router.get("/{int:event_id}", response=EventSchema)
|
||||
def get_event(request, event_id: int):
|
||||
"""Get event details by ID"""
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related('gallery_images'),
|
||||
id=event_id,
|
||||
is_deleted=False
|
||||
)
|
||||
return event
|
||||
|
||||
@events_router.get("/slug/{str:slug}", response=EventSchema)
|
||||
def get_event_by_slug(request, slug: str):
|
||||
"""Get event details by slug"""
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related('gallery_images'),
|
||||
slug=slug,
|
||||
is_deleted=False
|
||||
)
|
||||
return event
|
||||
|
||||
@events_router.post("/", response=EventSchema)
|
||||
def create_event(request, payload: EventCreateSchema):
|
||||
"""Create a new event"""
|
||||
gallery_image_ids = payload.dict().pop('gallery_image_ids', [])
|
||||
event = Event.objects.create(**payload.dict(exclude={'gallery_image_ids'}))
|
||||
|
||||
if gallery_image_ids:
|
||||
event.gallery_images.set(gallery_image_ids)
|
||||
|
||||
return event
|
||||
|
||||
@events_router.put("/{int:event_id}", response=EventSchema)
|
||||
def update_event(request, event_id: int, payload: EventUpdateSchema):
|
||||
"""Update an existing event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
|
||||
update_data = payload.dict(exclude_unset=True)
|
||||
gallery_image_ids = update_data.pop('gallery_image_ids', None)
|
||||
|
||||
for attr, value in update_data.items():
|
||||
setattr(event, attr, value)
|
||||
|
||||
if 'title' in update_data:
|
||||
event.slug = slugify(event.title)
|
||||
|
||||
event.save()
|
||||
|
||||
if gallery_image_ids is not None:
|
||||
event.gallery_images.set(gallery_image_ids)
|
||||
|
||||
return event
|
||||
|
||||
@events_router.delete("/{int:event_id}", response=MessageSchema)
|
||||
def delete_event(request, event_id: int):
|
||||
"""Soft delete an event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
event.delete()
|
||||
return {"message": "Event deleted successfully"}
|
||||
|
||||
# Registration endpoints
|
||||
@events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema])
|
||||
def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0):
|
||||
"""List registrations for a specific event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
queryset = event.registrations.filter(is_deleted=False).select_related('user')
|
||||
|
||||
registrations = queryset[offset:offset + limit]
|
||||
return registrations
|
||||
|
||||
|
||||
@events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_event_registrations_admin(
|
||||
request,
|
||||
event_id: int,
|
||||
status: Optional[List[str]] = Query(None),
|
||||
university: Optional[str] = Query(None),
|
||||
major: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List registrations with filters for admin dashboard"""
|
||||
user = request.auth
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
qs = (
|
||||
event.registrations.filter(is_deleted=False)
|
||||
.select_related("user")
|
||||
.prefetch_related("payments__discount_code")
|
||||
.order_by("-registered_at")
|
||||
)
|
||||
|
||||
status_values = status or request.GET.getlist('status')
|
||||
if status_values:
|
||||
qs = qs.filter(status__in=status_values)
|
||||
|
||||
if university:
|
||||
qs = qs.filter(
|
||||
Q(user__university__code__icontains=university)
|
||||
| Q(user__university__name__icontains=university)
|
||||
)
|
||||
|
||||
if major:
|
||||
qs = qs.filter(
|
||||
Q(user__major__code__icontains=major)
|
||||
| Q(user__major__name__icontains=major)
|
||||
)
|
||||
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(user__username__icontains=search)
|
||||
| Q(user__email__icontains=search)
|
||||
| Q(user__first_name__icontains=search)
|
||||
| Q(user__last_name__icontains=search)
|
||||
)
|
||||
|
||||
total = qs.count()
|
||||
results = qs[offset : offset + limit]
|
||||
|
||||
return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results))
|
||||
|
||||
@events_router.post(
|
||||
"/{int:event_id}/register",
|
||||
response=RegistrationSchema,
|
||||
auth=jwt_auth,
|
||||
)
|
||||
def register_for_event(
|
||||
request,
|
||||
event_id: int,
|
||||
payload: RegistrationCreateSchema | None = None,
|
||||
):
|
||||
"""Register current user for an event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
user = request.auth
|
||||
|
||||
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
|
||||
raise HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
||||
|
||||
if event.registration_end_date and event.registration_end_date < timezone.now():
|
||||
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||||
|
||||
if event.registration_start_date and event.registration_start_date > timezone.now():
|
||||
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||||
|
||||
if not event.has_available_slots:
|
||||
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
||||
|
||||
# Create or get existing registration
|
||||
discount_code = None
|
||||
if payload and payload.discount_code:
|
||||
discount_code = payload.discount_code
|
||||
elif request.GET.get("discount_code"):
|
||||
discount_code = request.GET.get("discount_code")
|
||||
|
||||
registration, created = Registration.objects.get_or_create(
|
||||
event=event,
|
||||
user=user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
defaults={"final_price": event.price},
|
||||
)
|
||||
|
||||
if registration.status == Registration.StatusChoices.CONFIRMED:
|
||||
return HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید")
|
||||
|
||||
if registration.status == Registration.StatusChoices.CANCELLED:
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=event.price,
|
||||
)
|
||||
elif not created and registration.final_price is None:
|
||||
registration.final_price = event.price
|
||||
registration.save(update_fields=["final_price"])
|
||||
|
||||
applied_code = None
|
||||
discount_amount = 0
|
||||
final_price = event.price
|
||||
fields_to_update = []
|
||||
|
||||
if discount_code:
|
||||
applied_code = DiscountCode.objects.filter(
|
||||
code=discount_code,
|
||||
applicable_events=event,
|
||||
is_active=True,
|
||||
).first()
|
||||
if not applied_code:
|
||||
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
|
||||
final_price, discount_amount = applied_code.calculate_discount(event, user)
|
||||
registration.discount_code = applied_code
|
||||
registration.discount_amount = discount_amount
|
||||
fields_to_update.extend(["discount_code", "discount_amount"])
|
||||
|
||||
if registration.final_price != final_price:
|
||||
registration.final_price = final_price
|
||||
fields_to_update.append("final_price")
|
||||
|
||||
if not event.price or final_price == 0:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
fields_to_update.append("status")
|
||||
|
||||
if fields_to_update:
|
||||
registration.save(update_fields=list(set(fields_to_update)))
|
||||
|
||||
return registration
|
||||
|
||||
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
|
||||
def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema):
|
||||
"""Update registration status"""
|
||||
user = request.auth
|
||||
|
||||
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||
registration.status = payload.dict(exclude_unset=True).get('status')
|
||||
registration.full_clean()
|
||||
registration.save()
|
||||
|
||||
return registration
|
||||
|
||||
@events_router.delete("/registrations/{int:registration_id}", response=MessageSchema, auth=jwt_auth)
|
||||
def cancel_registration(request, registration_id: int):
|
||||
"""Cancel a registration"""
|
||||
user = request.auth
|
||||
|
||||
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||
registration.delete()
|
||||
return {"message": "ثبتنام شما لغو شد :("}
|
||||
|
||||
@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth)
|
||||
def verify_my_registration(request, ticket_id: UUID):
|
||||
try:
|
||||
reg = Registration.objects.select_related("event").get(ticket_id=ticket_id, user=request.auth)
|
||||
return {
|
||||
"event_image": request.build_absolute_uri(reg.event.featured_image.url) if reg.event.featured_image else None,
|
||||
"event_title": reg.event.title,
|
||||
"event_type": reg.event.get_event_type_display(),
|
||||
"ticket_id": reg.ticket_id,
|
||||
"status": reg.status,
|
||||
"registered_at": reg.registered_at,
|
||||
"success_markdown": reg.event.registration_success_markdown,
|
||||
}
|
||||
except Registration.DoesNotExist:
|
||||
raise HttpError(404, "registration not found")
|
||||
|
||||
|
||||
|
||||
@events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth)
|
||||
def my_registrations(request):
|
||||
qs = (
|
||||
Registration.objects
|
||||
.filter(user=request.auth)
|
||||
.select_related("event")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
out: List[MyEventRegistrationOut] = []
|
||||
for r in qs:
|
||||
out.append(
|
||||
MyEventRegistrationOut(
|
||||
id=r.id,
|
||||
created_at=r.created_at,
|
||||
status=r.status,
|
||||
event=EventBriefSchema(
|
||||
id=r.event.id,
|
||||
title=r.event.title,
|
||||
slug=r.event.slug,
|
||||
start_date=r.event.start_time,
|
||||
end_date=r.event.end_time,
|
||||
location=r.event.location,
|
||||
price=r.event.price,
|
||||
absolute_image_url=request.build_absolute_uri(r.event.featured_image.url) if r.event.featured_image else None,
|
||||
),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
|
||||
def is_registered(request, event_id: int):
|
||||
exists = Registration.objects.filter(
|
||||
user=request.auth,
|
||||
event_id=event_id,
|
||||
status=Registration.StatusChoices.CONFIRMED
|
||||
).exists()
|
||||
return {"is_registered": exists}
|
||||
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
|
||||
def event_admin_detail(request, event_id: int):
|
||||
user = request.auth
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related(
|
||||
'gallery_images',
|
||||
'registrations__user',
|
||||
'registrations__payments__discount_code'
|
||||
),
|
||||
id=event_id,
|
||||
is_deleted=False,
|
||||
)
|
||||
return event
|
||||
6
apps/events/apps.py
Normal file
6
apps/events/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class EventsConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "apps.events"
|
||||
379
apps/events/fixtures/events.json
Normal file
379
apps/events/fixtures/events.json
Normal file
@@ -0,0 +1,379 @@
|
||||
[
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created_at": "2024-02-28T10:00:00Z",
|
||||
"updated_at": "2024-02-28T10:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه یادگیری ماشین پیشرفته",
|
||||
"slug": "advanced-machine-learning-workshop",
|
||||
"description": "# کارگاه یادگیری ماشین پیشرفته\n\nدر این کارگاه با تکنیکهای پیشرفته یادگیری ماشین آشنا خواهید شد.\n\n## سرفصلها:\n- Deep Learning\n- Neural Networks\n- TensorFlow و Keras\n- پروژه عملی\n\n## پیشنیازها:\n- آشنایی با پایتون\n- دانش پایه ریاضی\n- تجربه کار با NumPy",
|
||||
"start_time": "2024-03-15T14:00:00Z",
|
||||
"end_time": "2024-03-15T18:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "سالن کنفرانس دانشکده مهندسی کامپیوتر",
|
||||
"location": "35.7219,51.3890",
|
||||
"status": "published",
|
||||
"capacity": 50,
|
||||
"price": "150000.00",
|
||||
"registration_start_date": "2024-03-01T00:00:00Z",
|
||||
"registration_end_date": "2024-03-14T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created_at": "2024-03-02T09:00:00Z",
|
||||
"updated_at": "2024-03-02T09:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "مسابقه برنامهنویسی بهاری",
|
||||
"slug": "spring-programming-contest",
|
||||
"description": "# مسابقه برنامهنویسی بهاری\n\nمسابقهای هیجانانگیز برای تمامی علاقهمندان به برنامهنویسی\n\n## جوایز:\n- نفر اول: ۵ میلیون تومان\n- نفر دوم: ۳ میلیون تومان \n- نفر سوم: ۲ میلیون تومان\n\n## قوانین:\n- مسابقه انفرادی\n- مدت زمان: ۳ ساعت\n- ۸ مسئله الگوریتمی\n- زبانهای مجاز: C++, Java, Python",
|
||||
"start_time": "2024-03-22T09:00:00Z",
|
||||
"end_time": "2024-03-22T12:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "آزمایشگاه کامپیوتر شماره ۱",
|
||||
"location": "35.7225,51.3885",
|
||||
"status": "published",
|
||||
"capacity": 80,
|
||||
"price": null,
|
||||
"registration_start_date": "2024-03-05T00:00:00Z",
|
||||
"registration_end_date": "2024-03-20T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created_at": "2024-03-08T11:00:00Z",
|
||||
"updated_at": "2024-03-08T11:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "وبینار امنیت سایبری",
|
||||
"slug": "cybersecurity-webinar",
|
||||
"description": "# وبینار امنیت سایبری\n\nآشنایی با آخرین تهدیدات سایبری و روشهای مقابله\n\n## موضوعات:\n- تهدیدات جدید سایبری\n- روشهای حفاظت\n- ابزارهای امنیتی\n- مطالعه موردی حملات\n\n## مدرس:\nدکتر محمد رضایی - متخصص امنیت سایبری",
|
||||
"start_time": "2024-03-28T19:00:00Z",
|
||||
"end_time": "2024-03-28T21:00:00Z",
|
||||
"event_type": "online",
|
||||
"online_link": "https://meet.google.com/abc-defg-hij",
|
||||
"status": "published",
|
||||
"capacity": 200,
|
||||
"price": null,
|
||||
"registration_start_date": "2024-03-10T00:00:00Z",
|
||||
"registration_end_date": "2024-03-27T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created_at": "2024-03-18T14:00:00Z",
|
||||
"updated_at": "2024-03-18T14:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه React.js و Next.js",
|
||||
"slug": "reactjs-nextjs-workshop",
|
||||
"description": "# کارگاه React.js و Next.js\n\nآموزش کامل توسعه وب مدرن با React و Next.js\n\n## محتوای کارگاه:\n- مبانی React.js\n- Hooks و State Management\n- Next.js و SSR\n- پروژه عملی\n\n## مدرس:\nمهندس امیر قربانی - توسعهدهنده فولاستک",
|
||||
"start_time": "2024-04-05T13:00:00Z",
|
||||
"end_time": "2024-04-05T17:00:00Z",
|
||||
"event_type": "hybrid",
|
||||
"address": "کلاس ۲۰۵ ساختمان مهندسی کامپیوتر",
|
||||
"location": "35.7230,51.3880",
|
||||
"online_link": "https://zoom.us/j/123456789",
|
||||
"status": "published",
|
||||
"capacity": 40,
|
||||
"price": "200000.00",
|
||||
"registration_start_date": "2024-03-20T00:00:00Z",
|
||||
"registration_end_date": "2024-04-04T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created_at": "2024-03-22T16:00:00Z",
|
||||
"updated_at": "2024-03-22T16:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "بازدید از شرکت دیجیکالا",
|
||||
"slug": "digikala-company-visit",
|
||||
"description": "# بازدید از شرکت دیجیکالا\n\nبازدید علمی از یکی از بزرگترین شرکتهای فناوری کشور\n\n## برنامه بازدید:\n- آشنایی با ساختار شرکت\n- بازدید از بخشهای مختلف\n- گفتگو با مهندسان\n- معرفی فرصتهای شغلی\n\n## نکات مهم:\n- حمل و نقل رایگان\n- ناهار در محل\n- اهدای هدایای تبلیغاتی",
|
||||
"start_time": "2024-04-12T08:00:00Z",
|
||||
"end_time": "2024-04-12T16:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "شرکت دیجیکالا، تهران",
|
||||
"location": "35.7580,51.4100",
|
||||
"status": "published",
|
||||
"capacity": 30,
|
||||
"price": null,
|
||||
"registration_start_date": "2024-03-25T00:00:00Z",
|
||||
"registration_end_date": "2024-04-10T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created_at": "2024-03-30T12:00:00Z",
|
||||
"updated_at": "2024-03-30T12:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "هکاتون هوش مصنوعی",
|
||||
"slug": "ai-hackathon",
|
||||
"description": "# هکاتون هوش مصنوعی\n\nرقابت ۴۸ ساعته برای ساخت پروژههای هوش مصنوعی\n\n## موضوعات:\n- پردازش زبان طبیعی\n- بینایی کامپیوتر\n- یادگیری تقویتی\n- هوش مصنوعی در پزشکی\n\n## جوایز:\n- تیم اول: ۱۰ میلیون تومان\n- تیم دوم: ۶ میلیون تومان\n- تیم سوم: ۴ میلیون تومان\n\n## امکانات:\n- غذا و نوشیدنی رایگان\n- فضای کار ۲۴ ساعته\n- منتورینگ توسط اساتید",
|
||||
"start_time": "2024-04-19T18:00:00Z",
|
||||
"end_time": "2024-04-21T18:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "مرکز نوآوری دانشگاه",
|
||||
"location": "35.7200,51.3900",
|
||||
"status": "published",
|
||||
"capacity": 60,
|
||||
"price": "100000.00",
|
||||
"registration_start_date": "2024-04-01T00:00:00Z",
|
||||
"registration_end_date": "2024-04-17T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created_at": "2024-04-08T15:00:00Z",
|
||||
"updated_at": "2024-04-08T15:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "سمینار کارآفرینی فناوری",
|
||||
"slug": "tech-entrepreneurship-seminar",
|
||||
"description": "# سمینار کارآفرینی فناوری\n\nآشنایی با دنیای کارآفرینی و استارتاپهای فناوری\n\n## سخنرانان:\n- دکتر علی احمدی - موسس استارتاپ تپسی\n- خانم سارا محمدی - مدیرعامل کافهبازار\n- مهندس رضا کریمی - سرمایهگذار فرشته\n\n## موضوعات:\n- ایدهیابی و اعتبارسنجی\n- تیمسازی\n- جذب سرمایه\n- بازاریابی دیجیتال",
|
||||
"start_time": "2024-04-26T14:00:00Z",
|
||||
"end_time": "2024-04-26T18:00:00Z",
|
||||
"event_type": "hybrid",
|
||||
"address": "آمفیتئاتر مرکزی دانشگاه",
|
||||
"location": "35.7210,51.3895",
|
||||
"online_link": "https://meet.google.com/xyz-uvw-rst",
|
||||
"status": "published",
|
||||
"capacity": 150,
|
||||
"price": null,
|
||||
"registration_start_date": "2024-04-10T00:00:00Z",
|
||||
"registration_end_date": "2024-04-25T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created_at": "2024-04-12T13:00:00Z",
|
||||
"updated_at": "2024-04-12T13:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه DevOps و Docker",
|
||||
"slug": "devops-docker-workshop",
|
||||
"description": "# کارگاه DevOps و Docker\n\nآموزش عملی ابزارهای DevOps و کانتینریزیشن\n\n## سرفصلها:\n- مقدمهای بر DevOps\n- Docker و Containerization\n- Docker Compose\n- CI/CD Pipeline\n- Kubernetes مقدماتی\n\n## پیشنیازها:\n- آشنایی با Linux\n- تجربه کار با Terminal\n- دانش پایه شبکه",
|
||||
"start_time": "2024-05-03T09:00:00Z",
|
||||
"end_time": "2024-05-03T17:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "آزمایشگاه شبکه دانشکده",
|
||||
"location": "35.7215,51.3888",
|
||||
"status": "published",
|
||||
"capacity": 25,
|
||||
"price": "300000.00",
|
||||
"registration_start_date": "2024-04-15T00:00:00Z",
|
||||
"registration_end_date": "2024-05-01T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created_at": "2024-04-18T10:00:00Z",
|
||||
"updated_at": "2024-04-18T10:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "مسابقه طراحی UI/UX",
|
||||
"slug": "ui-ux-design-contest",
|
||||
"description": "# مسابقه طراحی UI/UX\n\nرقابت خلاقانه برای طراحی بهترین رابط کاربری\n\n## موضوع مسابقه:\nطراحی اپلیکیشن موبایل برای مدیریت تسکهای دانشجویی\n\n## معیارهای داوری:\n- خلاقیت و نوآوری\n- قابلیت استفاده\n- زیبایی بصری\n- تجربه کاربری\n\n## جوایز:\n- نفر اول: تبلت iPad\n- نفر دوم: هدفون بیسیم\n- نفر سوم: پاوربانک",
|
||||
"start_time": "2024-05-10T10:00:00Z",
|
||||
"end_time": "2024-05-10T18:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "استودیو طراحی دانشکده هنر",
|
||||
"location": "35.7240,51.3870",
|
||||
"status": "published",
|
||||
"capacity": 40,
|
||||
"price": "50000.00",
|
||||
"registration_start_date": "2024-04-20T00:00:00Z",
|
||||
"registration_end_date": "2024-05-08T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.event",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"created_at": "2024-04-28T17:00:00Z",
|
||||
"updated_at": "2024-04-28T17:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "نشست فارغالتحصیلان",
|
||||
"slug": "alumni-meetup",
|
||||
"description": "# نشست فارغالتحصیلان\n\nدیدار با فارغالتحصیلان موفق رشته مهندسی کامپیوتر\n\n## برنامه:\n- معرفی فارغالتحصیلان\n- تجربیات شغلی\n- مشاوره تحصیلی\n- شبکهسازی\n- ضیافت شام\n\n## مهمانان ویژه:\n- دکتر حسن زارع - مدیر فنی گوگل\n- مهندس مریم حسینی - بنیانگذار استارتاپ\n- دکتر امیر قربانی - استاد MIT",
|
||||
"start_time": "2024-05-17T17:00:00Z",
|
||||
"end_time": "2024-05-17T22:00:00Z",
|
||||
"event_type": "on_site",
|
||||
"address": "سالن همایشهای دانشگاه",
|
||||
"location": "35.7205,51.3892",
|
||||
"status": "published",
|
||||
"capacity": 100,
|
||||
"price": null,
|
||||
"registration_start_date": "2024-05-01T00:00:00Z",
|
||||
"registration_end_date": "2024-05-15T23:59:59Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created_at": "2024-03-02T10:30:00Z",
|
||||
"updated_at": "2024-03-02T10:30:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-02T10:30:00Z",
|
||||
"event": 1,
|
||||
"user": 3,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created_at": "2024-03-03T14:15:00Z",
|
||||
"updated_at": "2024-03-03T14:15:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-03T14:15:00Z",
|
||||
"event": 1,
|
||||
"user": 4,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created_at": "2024-03-06T09:20:00Z",
|
||||
"updated_at": "2024-03-06T09:20:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-06T09:20:00Z",
|
||||
"event": 2,
|
||||
"user": 5,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created_at": "2024-03-07T16:45:00Z",
|
||||
"updated_at": "2024-03-07T16:45:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-07T16:45:00Z",
|
||||
"event": 2,
|
||||
"user": 6,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created_at": "2024-03-12T11:30:00Z",
|
||||
"updated_at": "2024-03-12T11:30:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-12T11:30:00Z",
|
||||
"event": 3,
|
||||
"user": 7,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created_at": "2024-03-13T13:25:00Z",
|
||||
"updated_at": "2024-03-13T13:25:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-13T13:25:00Z",
|
||||
"event": 3,
|
||||
"user": 8,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created_at": "2024-03-22T15:10:00Z",
|
||||
"updated_at": "2024-03-22T15:10:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-22T15:10:00Z",
|
||||
"event": 4,
|
||||
"user": 9,
|
||||
"status": "pending"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created_at": "2024-03-23T12:40:00Z",
|
||||
"updated_at": "2024-03-23T12:40:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-23T12:40:00Z",
|
||||
"event": 4,
|
||||
"user": 10,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created_at": "2024-03-27T08:55:00Z",
|
||||
"updated_at": "2024-03-27T08:55:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-03-27T08:55:00Z",
|
||||
"event": 5,
|
||||
"user": 11,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"created_at": "2024-04-02T14:20:00Z",
|
||||
"updated_at": "2024-04-02T14:20:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-04-02T14:20:00Z",
|
||||
"event": 6,
|
||||
"user": 12,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 11,
|
||||
"fields": {
|
||||
"created_at": "2024-04-12T10:15:00Z",
|
||||
"updated_at": "2024-04-12T10:15:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-04-12T10:15:00Z",
|
||||
"event": 7,
|
||||
"user": 2,
|
||||
"status": "confirmed"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "events.registration",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"created_at": "2024-04-16T16:30:00Z",
|
||||
"updated_at": "2024-04-16T16:30:00Z",
|
||||
"is_deleted": false,
|
||||
"registered_at": "2024-04-16T16:30:00Z",
|
||||
"event": 8,
|
||||
"user": 1,
|
||||
"status": "confirmed"
|
||||
}
|
||||
}
|
||||
]
|
||||
60
apps/events/migrations/0001_initial.py
Normal file
60
apps/events/migrations/0001_initial.py
Normal file
@@ -0,0 +1,60 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import location_field.models.plain
|
||||
import uuid
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Event',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('title', models.CharField(max_length=255)),
|
||||
('slug', models.SlugField(blank=True, max_length=255, unique=True)),
|
||||
('description', models.TextField(help_text='Event description in Markdown format')),
|
||||
('start_time', models.DateTimeField()),
|
||||
('end_time', models.DateTimeField()),
|
||||
('address', models.CharField(blank=True, help_text='Physical address or venue name', max_length=255, null=True)),
|
||||
('location', location_field.models.plain.PlainLocationField(blank=True, help_text='Select location on map', max_length=63, null=True)),
|
||||
('event_type', models.CharField(choices=[('online', 'آنلاین'), ('on_site', 'حضوری'), ('hybrid', 'آنلاین/حضوری')], default='on_site', max_length=10)),
|
||||
('online_link', models.URLField(blank=True, help_text='Link for online events (e.g., Zoom, Google Meet)', max_length=500, null=True)),
|
||||
('status', models.CharField(choices=[('draft', 'Draft'), ('published', 'Published'), ('cancelled', 'Cancelled'), ('completed', 'Completed')], default='draft', max_length=10)),
|
||||
('capacity', models.PositiveIntegerField(blank=True, help_text='Maximum number of attendees (leave blank for unlimited)', null=True)),
|
||||
('price', models.IntegerField(default=0, help_text='Price of the event. Leave blank for free events.')),
|
||||
('registration_start_date', models.DateTimeField(blank=True, null=True)),
|
||||
('registration_end_date', models.DateTimeField(blank=True, null=True)),
|
||||
('featured_image', models.ImageField(blank=True, null=True, upload_to='events/featured/')),
|
||||
],
|
||||
options={
|
||||
'ordering': ['start_time'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='Registration',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('registered_at', models.DateTimeField(auto_now_add=True)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('confirmed', 'Confirmed'), ('cancelled', 'Cancelled'), ('attended', 'Attended')], default='pending', max_length=10)),
|
||||
('ticket_id', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)),
|
||||
],
|
||||
options={
|
||||
'ordering': ['registered_at'],
|
||||
},
|
||||
),
|
||||
]
|
||||
27
apps/events/migrations/0002_initial.py
Normal file
27
apps/events/migrations/0002_initial.py
Normal file
@@ -0,0 +1,27 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('events', '0001_initial'),
|
||||
('gallery', '0001_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='gallery_images',
|
||||
field=models.ManyToManyField(blank=True, help_text='Images taken during or related to the event.', related_name='event_galleries', to='gallery.gallery'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='event',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='registrations', to='events.event'),
|
||||
),
|
||||
]
|
||||
39
apps/events/migrations/0003_initial.py
Normal file
39
apps/events/migrations/0003_initial.py
Normal file
@@ -0,0 +1,39 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('events', '0002_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='event_registrations', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='event',
|
||||
index=models.Index(fields=['status', 'start_time'], name='events_even_status_189ced_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='event',
|
||||
index=models.Index(fields=['event_type'], name='events_even_event_t_a87b5c_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='registration',
|
||||
index=models.Index(fields=['event', 'status'], name='events_regi_event_i_c98244_idx'),
|
||||
),
|
||||
migrations.AddIndex(
|
||||
model_name='registration',
|
||||
index=models.Index(fields=['user'], name='events_regi_user_id_a0262e_idx'),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:09
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0003_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='event',
|
||||
name='registration_success_markdown',
|
||||
field=models.TextField(blank=True, help_text='Optional markdown shown to users after a successful registration.', null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,23 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 13:22
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0004_event_registration_success_markdown'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='cancellation_email_sent_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='confirmation_email_sent_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,43 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-25 20:47
|
||||
|
||||
import django.db.models.deletion
|
||||
import django.utils.timezone
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0005_registration_cancellation_email_sent_at_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterModelOptions(
|
||||
name='event',
|
||||
options={'ordering': ['-start_time']},
|
||||
),
|
||||
migrations.AlterModelOptions(
|
||||
name='registration',
|
||||
options={'ordering': ['-registered_at']},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='EventEmailLog',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('kind', models.CharField(choices=[('invite_non_registered', 'Invite non-registered users')], max_length=64)),
|
||||
('status', models.CharField(choices=[('pending', 'Pending'), ('sent', 'Sent'), ('failed', 'Failed')], default='pending', max_length=16)),
|
||||
('error', models.TextField(blank=True, null=True)),
|
||||
('sent_at', models.DateTimeField(blank=True, null=True)),
|
||||
('created_at', models.DateTimeField(default=django.utils.timezone.now)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('event', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to='events.event')),
|
||||
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='email_logs', to=settings.AUTH_USER_MODEL)),
|
||||
],
|
||||
options={
|
||||
'indexes': [models.Index(fields=['event', 'kind', 'status'], name='events_even_event_i_d6c2f2_idx'), models.Index(fields=['user', 'kind', 'status'], name='events_even_user_id_67be40_idx')],
|
||||
'unique_together': {('event', 'user', 'kind')},
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,28 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-25 21:18
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0006_alter_event_options_alter_registration_options_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventemaillog',
|
||||
name='deleted_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='eventemaillog',
|
||||
name='is_deleted',
|
||||
field=models.BooleanField(default=False),
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='eventemaillog',
|
||||
name='created_at',
|
||||
field=models.DateTimeField(auto_now_add=True),
|
||||
),
|
||||
]
|
||||
18
apps/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
18
apps/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2025-11-05 11:29
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0007_eventemaillog_deleted_at_eventemaillog_is_deleted_and_more'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventemaillog',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'send skyroom credentials'), ('send_event_announcement', 'send_event_announcement'), ('send_event_announcement2', 'send_event_announcement2'), ('send_event_announcement3', 'send_event_announcement3')], max_length=64),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,30 @@
|
||||
# Generated by Django 4.2.13 on 2025-11-17 13:15
|
||||
|
||||
from django.db import migrations, models
|
||||
import django.db.models.deletion
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('payments', '0002_initial'),
|
||||
('events', '0008_alter_eventemaillog_kind'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='discount_amount',
|
||||
field=models.PositiveIntegerField(default=0),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='discount_code',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='registrations', to='payments.discountcode'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='final_price',
|
||||
field=models.PositiveIntegerField(blank=True, null=True),
|
||||
),
|
||||
]
|
||||
@@ -0,0 +1,55 @@
|
||||
from django.db import migrations
|
||||
|
||||
|
||||
def copy_payment_discounts(apps, schema_editor):
|
||||
Registration = apps.get_model("events", "Registration")
|
||||
Payment = apps.get_model("payments", "Payment")
|
||||
|
||||
payments = (
|
||||
Payment.objects.exclude(discount_code__isnull=True)
|
||||
.select_related("discount_code")
|
||||
.order_by("id")
|
||||
)
|
||||
for payment in payments:
|
||||
registration = (
|
||||
Registration.objects.filter(event_id=payment.event_id, user_id=payment.user_id)
|
||||
.order_by("-registered_at")
|
||||
.first()
|
||||
)
|
||||
if not registration:
|
||||
continue
|
||||
|
||||
updated_fields = []
|
||||
if payment.discount_code_id and not registration.discount_code_id:
|
||||
registration.discount_code_id = payment.discount_code_id
|
||||
updated_fields.append("discount_code")
|
||||
if payment.discount_amount and not registration.discount_amount:
|
||||
registration.discount_amount = payment.discount_amount
|
||||
updated_fields.append("discount_amount")
|
||||
if payment.amount is not None and registration.final_price is None:
|
||||
registration.final_price = payment.amount
|
||||
updated_fields.append("final_price")
|
||||
|
||||
if updated_fields:
|
||||
registration.save(update_fields=updated_fields)
|
||||
|
||||
if payment.registration_id is None:
|
||||
payment.registration_id = registration.id
|
||||
payment.save(update_fields=["registration"])
|
||||
|
||||
|
||||
def reverse_copy_payment_discounts(apps, schema_editor):
|
||||
# No-op for reverse; data retention preferred.
|
||||
pass
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
("payments", "0003_payment_registration"),
|
||||
("events", "0009_registration_discount_amount_and_more"),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(copy_payment_discounts, reverse_copy_payment_discounts),
|
||||
]
|
||||
22
apps/events/migrations/0011_eventemaillog_context_hash.py
Normal file
22
apps/events/migrations/0011_eventemaillog_context_hash.py
Normal file
@@ -0,0 +1,22 @@
|
||||
# Generated by Django 5.2.5 on 2025-11-17 19:30
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0010_backfill_registration_discounts'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='eventemaillog',
|
||||
name='context_hash',
|
||||
field=models.CharField(blank=True, max_length=64, null=True),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='eventemaillog',
|
||||
unique_together={('event', 'user', 'kind', 'context_hash')},
|
||||
),
|
||||
]
|
||||
18
apps/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
18
apps/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 4.2.13 on 2025-11-18 08:54
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0011_eventemaillog_context_hash'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventemaillog',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'Skyroom credentials'), ('send_event_announcement', 'Event announcement'), ('send_event_announcement2', 'Event announcement 2'), ('send_event_announcement3', 'Event announcement 3')], max_length=64),
|
||||
),
|
||||
]
|
||||
18
apps/events/migrations/0013_alter_eventemaillog_kind.py
Normal file
18
apps/events/migrations/0013_alter_eventemaillog_kind.py
Normal file
@@ -0,0 +1,18 @@
|
||||
# Generated by Django 5.2.5 on 2026-05-19 14:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0012_alter_eventemaillog_kind'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AlterField(
|
||||
model_name='eventemaillog',
|
||||
name='kind',
|
||||
field=models.CharField(choices=[('invite_non_registered', 'Invite non-registered users'), ('send_skyroom_credentials', 'Skyroom credentials'), ('send_event_announcement', 'Event announcement'), ('send_event_announcement2', 'Event announcement 2'), ('send_event_announcement3', 'Event announcement 3'), ('send_event_reminder', 'Event reminder')], max_length=64),
|
||||
),
|
||||
]
|
||||
0
apps/events/migrations/__init__.py
Normal file
0
apps/events/migrations/__init__.py
Normal file
269
apps/events/models.py
Normal file
269
apps/events/models.py
Normal file
@@ -0,0 +1,269 @@
|
||||
from django.db import models
|
||||
from django.db import models
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
from django.utils.text import slugify
|
||||
|
||||
import hashlib
|
||||
import uuid
|
||||
|
||||
import markdown
|
||||
from location_field.models.plain import PlainLocationField as LocationField
|
||||
|
||||
from core.models import BaseModel
|
||||
|
||||
|
||||
class Event(BaseModel):
|
||||
class TypeChoices(models.TextChoices):
|
||||
ONLINE = 'online', 'آنلاین'
|
||||
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")
|
||||
|
||||
start_time = models.DateTimeField()
|
||||
end_time = models.DateTimeField()
|
||||
|
||||
address = models.CharField(max_length=255, blank=True, null=True, help_text="Physical address or venue name")
|
||||
location = LocationField(based_fields=['address'], zoom=15, blank=True, null=True,
|
||||
help_text="Select location on map")
|
||||
|
||||
event_type = models.CharField(max_length=10, choices=TypeChoices.choices, default=TypeChoices.ON_SITE)
|
||||
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)
|
||||
capacity = models.PositiveIntegerField(null=True, blank=True,
|
||||
help_text="Maximum number of attendees (leave blank for unlimited)")
|
||||
|
||||
price = models.IntegerField(default=0, help_text="Price of the event. Leave blank for free events.")
|
||||
|
||||
registration_start_date = models.DateTimeField(null=True, blank=True)
|
||||
registration_end_date = models.DateTimeField(null=True, blank=True)
|
||||
featured_image = models.ImageField(upload_to='events/featured/', null=True, blank=True)
|
||||
gallery_images = models.ManyToManyField('gallery.Gallery', blank=True, related_name='event_galleries',
|
||||
help_text="Images taken during or related to the event.")
|
||||
|
||||
registration_success_markdown = models.TextField(
|
||||
blank=True, null=True,
|
||||
help_text="Optional markdown shown to users after a successful registration."
|
||||
)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-start_time']
|
||||
indexes = [
|
||||
models.Index(fields=['status', 'start_time']),
|
||||
models.Index(fields=['event_type']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.slug:
|
||||
self.slug = slugify(self.title)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
@property
|
||||
def description_html(self):
|
||||
"""Convert markdown description to HTML"""
|
||||
return markdown.markdown(
|
||||
self.description,
|
||||
extensions=[
|
||||
'markdown.extensions.extra',
|
||||
'markdown.extensions.toc',
|
||||
]
|
||||
)
|
||||
|
||||
@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)
|
||||
|
||||
@property
|
||||
def current_attendees_count(self):
|
||||
"""Count confirmed attendees"""
|
||||
return self.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], is_deleted=False).count()
|
||||
|
||||
@property
|
||||
def has_available_slots(self):
|
||||
"""Check whether registration slots are available, treating None as unlimited capacity."""
|
||||
if self.capacity is None:
|
||||
return True
|
||||
return self.current_attendees_count < self.capacity
|
||||
|
||||
|
||||
class Registration(BaseModel):
|
||||
class StatusChoices(models.TextChoices):
|
||||
PENDING = 'pending', 'Pending'
|
||||
CONFIRMED = 'confirmed', 'Confirmed'
|
||||
CANCELLED = 'cancelled', 'Cancelled'
|
||||
ATTENDED = 'attended', 'Attended'
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
||||
registered_at = models.DateTimeField(auto_now_add=True)
|
||||
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
||||
default=StatusChoices.PENDING)
|
||||
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
|
||||
confirmation_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
cancellation_email_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
discount_code = models.ForeignKey(
|
||||
"payments.DiscountCode",
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name="registrations",
|
||||
)
|
||||
discount_amount = models.PositiveIntegerField(default=0)
|
||||
final_price = models.PositiveIntegerField(null=True, blank=True)
|
||||
|
||||
class Meta:
|
||||
ordering = ['-registered_at']
|
||||
indexes = [
|
||||
models.Index(fields=['event', 'status']),
|
||||
models.Index(fields=['user']),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} registered for {self.event.title}"
|
||||
|
||||
@property
|
||||
def status_label(self):
|
||||
"""Human-readable label for the current registration status."""
|
||||
return self.get_status_display()
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
# detect create vs update
|
||||
is_create = self._state.adding
|
||||
old_status = None
|
||||
|
||||
if not is_create and self.pk:
|
||||
old_status = (
|
||||
self.__class__.objects.only("status").get(pk=self.pk).status
|
||||
)
|
||||
|
||||
# save first (so we have a pk + final values)
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
# 1) on create -> send confirmation if pending/confirmed (and not sent before)
|
||||
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_email_sent_at:
|
||||
# lazy import to avoid circular import
|
||||
from apps.events.tasks import send_registration_confirmation_email
|
||||
send_registration_confirmation_email.delay(str(self.pk))
|
||||
self.confirmation_email_sent_at = timezone.now()
|
||||
super().save(update_fields=["confirmation_email_sent_at"])
|
||||
|
||||
# 2) status changed -> cancelled
|
||||
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_email_sent_at):
|
||||
from apps.events.tasks import send_registration_cancellation_email
|
||||
send_registration_cancellation_email.delay(str(self.pk))
|
||||
self.cancellation_email_sent_at = timezone.now()
|
||||
super().save(update_fields=["cancellation_email_sent_at"])
|
||||
|
||||
# 3) status changed -> confirmed (if not sent before)
|
||||
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_email_sent_at):
|
||||
from apps.events.tasks import send_registration_confirmation_email
|
||||
send_registration_confirmation_email.delay(str(self.pk))
|
||||
self.confirmation_email_sent_at = timezone.now()
|
||||
super().save(update_fields=["confirmation_email_sent_at"])
|
||||
|
||||
|
||||
class EventEmailLog(BaseModel):
|
||||
class KindChoices(models.TextChoices):
|
||||
INVITE_NON_REGISTERED = "invite_non_registered", "Invite non-registered users"
|
||||
SKYROOM_CREDENTIALS = "send_skyroom_credentials", "Skyroom credentials"
|
||||
EVENT_ANNOUNCEMENT = "send_event_announcement", "Event announcement"
|
||||
EVENT_ANNOUNCEMENT2 = "send_event_announcement2", "Event announcement 2"
|
||||
EVENT_ANNOUNCEMENT3 = "send_event_announcement3", "Event announcement 3"
|
||||
EVENT_REMINDER = "send_event_reminder", "Event reminder"
|
||||
|
||||
class StatusChoices(models.TextChoices):
|
||||
PENDING = "pending", "Pending"
|
||||
SENT = "sent", "Sent"
|
||||
FAILED = "failed", "Failed"
|
||||
|
||||
KIND_INVITE_NON_REGISTERED = KindChoices.INVITE_NON_REGISTERED
|
||||
KIND_SKYROOM_CREDENTIALS = KindChoices.SKYROOM_CREDENTIALS
|
||||
KIND_EVENT_ANNOUNCEMENT = KindChoices.EVENT_ANNOUNCEMENT
|
||||
KIND_EVENT_ANNOUNCEMENT2 = KindChoices.EVENT_ANNOUNCEMENT2
|
||||
KIND_EVENT_ANNOUNCEMENT3 = KindChoices.EVENT_ANNOUNCEMENT3
|
||||
KIND_EVENT_REMINDER = KindChoices.EVENT_REMINDER
|
||||
KIND_CHOICES = KindChoices.choices
|
||||
|
||||
STATUS_PENDING = StatusChoices.PENDING
|
||||
STATUS_SENT = StatusChoices.SENT
|
||||
STATUS_FAILED = StatusChoices.FAILED
|
||||
STATUS_CHOICES = StatusChoices.choices
|
||||
|
||||
event = models.ForeignKey('events.Event', on_delete=models.CASCADE, related_name='email_logs')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='email_logs')
|
||||
kind = models.CharField(max_length=64, choices=KIND_CHOICES)
|
||||
status = models.CharField(max_length=16, choices=STATUS_CHOICES, default=STATUS_PENDING)
|
||||
error = models.TextField(blank=True, null=True)
|
||||
sent_at = models.DateTimeField(blank=True, null=True)
|
||||
context_hash = models.CharField(max_length=64, blank=True, null=True)
|
||||
|
||||
class Meta:
|
||||
unique_together = ("event", "user", "kind", "context_hash")
|
||||
indexes = [
|
||||
models.Index(fields=["event", "kind", "status"]),
|
||||
models.Index(fields=["user", "kind", "status"]),
|
||||
]
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.event.id} - {self.user.id} - {self.kind} - {self.status}"
|
||||
|
||||
@staticmethod
|
||||
def _hash_context(context):
|
||||
if context is None:
|
||||
return None
|
||||
if not isinstance(context, str):
|
||||
context = str(context)
|
||||
return hashlib.sha256(context.encode("utf-8")).hexdigest()
|
||||
|
||||
@classmethod
|
||||
def claim(cls, *, event_id, user_id, kind, context=None):
|
||||
context_hash = cls._hash_context(context)
|
||||
log, created = cls.objects.get_or_create(
|
||||
event_id=event_id,
|
||||
user_id=user_id,
|
||||
kind=kind,
|
||||
context_hash=context_hash,
|
||||
defaults={"status": cls.STATUS_PENDING},
|
||||
)
|
||||
if not created and log.status in (cls.STATUS_PENDING, cls.STATUS_SENT):
|
||||
return log, True
|
||||
if not created:
|
||||
log._commit_status(cls.STATUS_PENDING, error="")
|
||||
return log, False
|
||||
|
||||
def _commit_status(self, status, *, error="", sent_at=None):
|
||||
self.status = status
|
||||
self.error = error
|
||||
update_fields = ["status", "error"]
|
||||
if status == self.STATUS_SENT:
|
||||
self.sent_at = sent_at or timezone.now()
|
||||
update_fields.append("sent_at")
|
||||
elif self.sent_at is not None:
|
||||
self.sent_at = None
|
||||
update_fields.append("sent_at")
|
||||
if hasattr(self, "updated_at"):
|
||||
update_fields.append("updated_at")
|
||||
self.save(update_fields=update_fields)
|
||||
|
||||
def mark_sent(self):
|
||||
self._commit_status(self.STATUS_SENT)
|
||||
|
||||
def mark_failed(self, error):
|
||||
self._commit_status(self.STATUS_FAILED, error=error)
|
||||
86
apps/events/resources.py
Normal file
86
apps/events/resources.py
Normal file
@@ -0,0 +1,86 @@
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
|
||||
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.users.models import User
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.payments.models import DiscountCode
|
||||
|
||||
class EventResource(resources.ModelResource):
|
||||
gallery_images = fields.Field(
|
||||
column_name='gallery_images',
|
||||
attribute='gallery_images',
|
||||
widget=ManyToManyWidget(Gallery, field='title', separator='|')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Event
|
||||
fields = (
|
||||
'id', 'title', 'slug', 'description', 'start_time', 'end_time',
|
||||
'event_type', 'address', 'location', 'online_link', 'status',
|
||||
'capacity', 'price', 'registration_start_date', 'registration_end_date',
|
||||
'featured_image', 'gallery_images', 'created_at', 'updated_at',
|
||||
'is_deleted', 'deleted_at'
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
class RegistrationResource(resources.ModelResource):
|
||||
"""Export registrations with user attributes and shortened ticket identifiers."""
|
||||
|
||||
event = fields.Field(
|
||||
column_name='event',
|
||||
attribute='event',
|
||||
widget=ForeignKeyWidget(Event, 'title')
|
||||
)
|
||||
user_username = fields.Field(
|
||||
column_name='user_username',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'username')
|
||||
)
|
||||
user_email = fields.Field(
|
||||
column_name='user_email',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'email')
|
||||
)
|
||||
user_first_name = fields.Field(
|
||||
column_name='user_first_name',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'first_name')
|
||||
)
|
||||
user_last_name = fields.Field(
|
||||
column_name='user_last_name',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'last_name')
|
||||
)
|
||||
discount_code = fields.Field(
|
||||
column_name='discount_code',
|
||||
attribute='discount_code',
|
||||
widget=ForeignKeyWidget(DiscountCode, 'code')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Registration
|
||||
fields = (
|
||||
'id',
|
||||
'event',
|
||||
'user_username',
|
||||
'user_email',
|
||||
'user_first_name',
|
||||
'user_last_name',
|
||||
'registered_at',
|
||||
'status',
|
||||
'ticket_id',
|
||||
'discount_code',
|
||||
'discount_amount',
|
||||
'final_price',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
'is_deleted',
|
||||
'deleted_at',
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
def dehydrate_ticket_id(self, obj):
|
||||
"""Limit ticket identifiers to eight characters in exports."""
|
||||
val = getattr(obj, 'ticket_id', '')
|
||||
return str(val)[:8] if val else ''
|
||||
584
apps/events/tasks.py
Normal file
584
apps/events/tasks.py
Normal file
@@ -0,0 +1,584 @@
|
||||
from django.core.mail import EmailMultiAlternatives
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
from django.utils import timezone
|
||||
|
||||
from celery import shared_task, group
|
||||
from celery.exceptions import SoftTimeLimitExceeded
|
||||
import markdown
|
||||
import logging
|
||||
|
||||
from apps.users.models import User
|
||||
from apps.events.models import Event, Registration, EventEmailLog
|
||||
from core.templatetags.jalali import fa_digits, jdate
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
|
||||
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_registration_confirmation_email(self, registration_pk: str):
|
||||
"""Send a registration confirmation email, loading the model lazily to avoid circular imports."""
|
||||
try:
|
||||
from .models import Registration
|
||||
reg = (
|
||||
Registration.objects
|
||||
.select_related("event", "user")
|
||||
.get(pk=registration_pk)
|
||||
)
|
||||
|
||||
user_email = getattr(reg.user, "email", None)
|
||||
if not user_email:
|
||||
return
|
||||
|
||||
success_md = reg.event.registration_success_markdown or ""
|
||||
success_html = markdown.markdown(
|
||||
success_md,
|
||||
extensions=["extra", "sane_lists", "toc"]
|
||||
) if success_md else ""
|
||||
|
||||
context = {
|
||||
"user": reg.user,
|
||||
"event": reg.event,
|
||||
"registration": reg,
|
||||
"success_html": success_html,
|
||||
}
|
||||
|
||||
subject = f"تأیید ثبتنام شما در {reg.event.title}"
|
||||
html_body = render_to_string("emails/event_registration_confirmation.html", context)
|
||||
plain_body = strip_tags(html_body)
|
||||
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=plain_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[user_email],
|
||||
)
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
message.send(fail_silently=False)
|
||||
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send event registration email: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_registration_cancellation_email(self, registration_pk: str):
|
||||
try:
|
||||
from .models import Registration
|
||||
reg = (
|
||||
Registration.objects
|
||||
.select_related("event", "user")
|
||||
.get(pk=registration_pk)
|
||||
)
|
||||
|
||||
user_email = getattr(reg.user, "email", None)
|
||||
if not user_email:
|
||||
return
|
||||
|
||||
context = {
|
||||
"user": reg.user,
|
||||
"event": reg.event,
|
||||
"registration": reg,
|
||||
}
|
||||
|
||||
subject = f"لغو ثبتنام شما در {reg.event.title}"
|
||||
html_body = render_to_string("emails/event_registration_cancellation.html", context)
|
||||
plain_body = strip_tags(html_body)
|
||||
|
||||
message = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=plain_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[user_email],
|
||||
)
|
||||
message.attach_alternative(html_body, "text/html")
|
||||
message.send(fail_silently=False)
|
||||
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send event registration email: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
def _event_recipients(event, statuses=None, only_verified=True):
|
||||
qs = Registration.objects.filter(event=event, is_deleted=False)
|
||||
if statuses:
|
||||
qs = qs.filter(status__in=statuses)
|
||||
if only_verified:
|
||||
qs = qs.filter(user__is_email_verified=True)
|
||||
|
||||
qs = qs.exclude(user__email__isnull=True).exclude(user__email="")
|
||||
return qs.select_related("user")
|
||||
|
||||
|
||||
def _send_html_email(subject, html_body, to_email):
|
||||
text_body = strip_tags(html_body)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[to_email],
|
||||
)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
|
||||
|
||||
def _build_email_context(*parts):
|
||||
values = [str(part) for part in parts if part not in (None, "")]
|
||||
return "|".join(values) if values else None
|
||||
|
||||
|
||||
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60)
|
||||
def send_skyroom_credentials_individual_task(self, reg_id: int):
|
||||
"""
|
||||
ارسال نامکاربری/رمز برای اسکایروم
|
||||
- username = user.email
|
||||
- password = registration.ticket_id[:8]
|
||||
- url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد)
|
||||
"""
|
||||
r = Registration.objects.get(pk=reg_id)
|
||||
event = r.event
|
||||
user = r.user
|
||||
sky_user = user.email.strip().split('@')[0]
|
||||
sky_pass = str(r.ticket_id)[:8]
|
||||
skyroom_url = event.online_link
|
||||
try:
|
||||
ctx = {
|
||||
"user": user,
|
||||
"event": event,
|
||||
"skyroom_url": skyroom_url,
|
||||
"sky_username": sky_user,
|
||||
"sky_password": sky_pass,
|
||||
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||
}
|
||||
subject = f"اطلاعات دسترسی اسکایروم - {event.title}"
|
||||
html = render_to_string("emails/skyroom_credentials.html", ctx)
|
||||
text_body = strip_tags(html)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[user.email],
|
||||
)
|
||||
msg.attach_alternative(html, "text/html")
|
||||
msg.send()
|
||||
logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}')
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send skyroom credentials email: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def send_event_reminder_task(self, event_id: int):
|
||||
"""
|
||||
یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید)
|
||||
"""
|
||||
event = Event.objects.get(pk=event_id)
|
||||
regs = (
|
||||
_event_recipients(event, statuses=["confirmed", "attended"])
|
||||
.select_related("user", "event")
|
||||
.distinct()
|
||||
)
|
||||
reg_ids = list(regs.values_list("id", flat=True))
|
||||
|
||||
job = group(send_event_reminder_to_user.s(event_id, rid) for rid in reg_ids)
|
||||
res = job.apply_async()
|
||||
|
||||
logger.info(
|
||||
'Queued %s event reminder emails for event "%s" (group_id=%s)',
|
||||
len(reg_ids),
|
||||
event.title,
|
||||
res.id,
|
||||
)
|
||||
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_jitter=True,
|
||||
retry_kwargs={"max_retries": 3},
|
||||
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||
)
|
||||
def send_event_reminder_to_user(self, event_id: int, registration_id: int):
|
||||
"""
|
||||
Send reminder email to a single registration; safe to retry without duplicating emails.
|
||||
"""
|
||||
user = None
|
||||
log = None
|
||||
|
||||
try:
|
||||
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||
user = r.user
|
||||
event = r.event
|
||||
|
||||
to_email = (user.email or "").strip()
|
||||
if not to_email:
|
||||
return {"skipped": True, "status": "no_email"}
|
||||
|
||||
context_key = _build_email_context(
|
||||
"event_reminder",
|
||||
event.slug or event.id,
|
||||
event.start_time,
|
||||
)
|
||||
log, skip = EventEmailLog.claim(
|
||||
event_id=event_id,
|
||||
user_id=user.id,
|
||||
kind=EventEmailLog.KIND_EVENT_REMINDER,
|
||||
context=context_key,
|
||||
)
|
||||
if skip:
|
||||
return {"skipped": True, "status": log.status}
|
||||
|
||||
ctx = {
|
||||
"user": user,
|
||||
"event": event,
|
||||
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||
}
|
||||
|
||||
subject = f"یادآوری رویداد: {event.title}"
|
||||
html = render_to_string("emails/event_reminder.html", ctx)
|
||||
text_body = strip_tags(html)
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[to_email],
|
||||
)
|
||||
msg.attach_alternative(html, "text/html")
|
||||
msg.send()
|
||||
|
||||
log.mark_sent()
|
||||
logger.info('Event reminder for "%s" sent to %s', event.title, to_email)
|
||||
return f"Email sent to {to_email}"
|
||||
|
||||
except SoftTimeLimitExceeded:
|
||||
if log:
|
||||
log.mark_failed("Soft time limit exceeded")
|
||||
logger.warning(
|
||||
"Soft time limit exceeded (event_id=%s, registration_id=%s)",
|
||||
event_id,
|
||||
registration_id,
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
if log:
|
||||
log.mark_failed(str(exc))
|
||||
logger.error(
|
||||
"Failed to send event reminder email: %s", exc, exc_info=True
|
||||
)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
|
||||
"""
|
||||
تسک مادر: ثبتنامهای هدف را پیدا میکند و برای هر Registration یک تسک کوچک میسازد.
|
||||
"""
|
||||
event = Event.objects.get(pk=event_id)
|
||||
|
||||
# محدوده مخاطبان: اگر statuses داده نشد، همان پیشفرض قبلی شما
|
||||
statuses = statuses or ["confirmed", "attended", "pending"]
|
||||
|
||||
regs = (
|
||||
_event_recipients(event, statuses=statuses)
|
||||
.select_related("user", "event")
|
||||
.exclude(user__email__isnull=True)
|
||||
.exclude(user__email="")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
reg_ids = list(regs.values_list("id", flat=True))
|
||||
|
||||
# ساخت group از تسکهای کوچک؛ هر کدام فقط یک ایمیل ارسال میکند
|
||||
job = group(
|
||||
send_event_announcement_to_user.s(event_id, rid, subject, body_html)
|
||||
for rid in reg_ids
|
||||
)
|
||||
|
||||
# اگر نتیجهها لازم نیست: CELERY_TASK_IGNORE_RESULT = True
|
||||
res = job.apply_async()
|
||||
logger.info(
|
||||
'Queued %s event-announcement emails for event "%s" (group_id=%s)',
|
||||
len(reg_ids), event.title, res.id
|
||||
)
|
||||
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_jitter=True,
|
||||
retry_kwargs={"max_retries": 3},
|
||||
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||
)
|
||||
def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str):
|
||||
"""
|
||||
تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration.
|
||||
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
|
||||
"""
|
||||
user = None
|
||||
log = None
|
||||
|
||||
try:
|
||||
# از Registration میگیریم تا یک کوئری کمتر به Event بزنیم
|
||||
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||
user = r.user
|
||||
event = r.event
|
||||
|
||||
context_key = _build_email_context(
|
||||
"event_announcement3",
|
||||
event.slug or event.id,
|
||||
subject,
|
||||
body_html,
|
||||
)
|
||||
log, skip = EventEmailLog.claim(
|
||||
event_id=event_id,
|
||||
user_id=user.id,
|
||||
kind=EventEmailLog.KIND_EVENT_ANNOUNCEMENT3,
|
||||
context=context_key,
|
||||
)
|
||||
if skip:
|
||||
return {"skipped": True, "status": log.status}
|
||||
|
||||
# کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت میشود
|
||||
ctx = {
|
||||
"user": user,
|
||||
"event": event,
|
||||
"body_html": body_html,
|
||||
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||
}
|
||||
|
||||
html = render_to_string("emails/event_announcement.html", ctx)
|
||||
text_body = strip_tags(html)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[user.email],
|
||||
)
|
||||
msg.attach_alternative(html, "text/html")
|
||||
msg.send()
|
||||
|
||||
log.mark_sent()
|
||||
|
||||
logger.info('Event announcement for "%s" sent to %s', event.title, user.email)
|
||||
return f"Email sent to {user.email}"
|
||||
|
||||
except SoftTimeLimitExceeded:
|
||||
if log:
|
||||
log.mark_failed("Soft time limit exceeded")
|
||||
logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id)
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
if log:
|
||||
log.mark_failed(str(exc))
|
||||
logger.error("Failed to send event announcement email: %s", exc, exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def _event_url(event):
|
||||
root = getattr(settings, "FRONTEND_ROOT", "/")
|
||||
slug_or_id = getattr(event, "slug", None) or event.id
|
||||
return f"{root}events/{slug_or_id}"
|
||||
|
||||
@shared_task(bind=True)
|
||||
def queue_invites_to_non_registered_users(self, event_id: int, only_verified=True, only_active=True):
|
||||
"""
|
||||
تسک مادر: فقط کاربرها را پیدا میکند و برای هر نفر یک تسک کوچک میسازد.
|
||||
"""
|
||||
event = Event.objects.get(pk=event_id)
|
||||
|
||||
qs = User.objects.all()
|
||||
if only_verified:
|
||||
qs = qs.filter(is_email_verified=True)
|
||||
if only_active:
|
||||
qs = qs.filter(is_active=True)
|
||||
|
||||
# کسانی که برای این ایونت ثبتنام نکردهاند
|
||||
qs = qs.exclude(event_registrations__event_id=event_id) \
|
||||
.exclude(email__isnull=True).exclude(email="") \
|
||||
.distinct()
|
||||
|
||||
user_ids = list(qs.values_list("id", flat=True))
|
||||
|
||||
# گَروهِ تسکهای کوچک
|
||||
job = group(send_invite_to_user.s(event_id, uid) for uid in user_ids)
|
||||
res = job.apply_async()
|
||||
return {"event_id": event_id, "queued": len(user_ids), "group_id": res.id}
|
||||
|
||||
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, time_limit=60)
|
||||
def send_invite_to_user(self, event_id: int, user_id: int):
|
||||
"""
|
||||
تسک کوچک و اتمی: برای هر کاربر حداکثر یک ایمیل میفرستد (با لاگ ایدمپوتنسی).
|
||||
"""
|
||||
event = Event.objects.get(pk=event_id)
|
||||
user = User.objects.get(pk=user_id)
|
||||
|
||||
# ساخت محتوا
|
||||
context = {
|
||||
"user": user,
|
||||
"event": event,
|
||||
"event_url": _event_url(event),
|
||||
"start_time": fa_digits(jdate(event.start_time))
|
||||
}
|
||||
# ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip
|
||||
subject = f"دعوت به شرکت در «{event.title}»"
|
||||
text_body = render_to_string("emails/event_invite_non_registered.txt", context)
|
||||
html_body = render_to_string("emails/event_invite_non_registered.html", context)
|
||||
context_key = _build_email_context(
|
||||
"invite_non_registered",
|
||||
event.slug or event.id,
|
||||
html_body,
|
||||
)
|
||||
log, skip = EventEmailLog.claim(
|
||||
event_id=event_id,
|
||||
user_id=user_id,
|
||||
kind=EventEmailLog.KIND_INVITE_NON_REGISTERED,
|
||||
context=context_key,
|
||||
)
|
||||
if skip:
|
||||
return {"skipped": True, "status": log.status}
|
||||
|
||||
try:
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
to=[user.email],
|
||||
)
|
||||
msg.attach_alternative(html_body, "text/html")
|
||||
msg.send()
|
||||
|
||||
log.mark_sent()
|
||||
return f"Email sent to {user.email}"
|
||||
except Exception as exc:
|
||||
log.mark_failed(str(exc))
|
||||
raise
|
||||
|
||||
|
||||
@shared_task(bind=True)
|
||||
def queue_skyroom_credentials(self, event_id: int):
|
||||
"""
|
||||
تسک مادر: ثبتنامهای تاییدشده را پیدا میکند و برای هر Registration یک تسک کوچک میسازد.
|
||||
"""
|
||||
event = Event.objects.get(pk=event_id)
|
||||
|
||||
# فقط CONFIRMED ها + ایمیل معتبر
|
||||
regs = (
|
||||
_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])
|
||||
.select_related("user", "event")
|
||||
.exclude(user__email__isnull=True)
|
||||
.exclude(user__email="")
|
||||
.distinct()
|
||||
)
|
||||
|
||||
reg_ids = list(regs.values_list("id", flat=True))
|
||||
|
||||
# ساخت group از تسکهای کوچک؛ هر کدوم فقط یک ایمیل ارسال میکنند
|
||||
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
|
||||
|
||||
# توصیه: اگر نتیجهها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید
|
||||
res = job.apply_async()
|
||||
logger.info(
|
||||
'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)',
|
||||
len(reg_ids), event.title, res.id
|
||||
)
|
||||
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
|
||||
|
||||
|
||||
@shared_task(
|
||||
bind=True,
|
||||
autoretry_for=(Exception,),
|
||||
retry_backoff=True,
|
||||
retry_jitter=True,
|
||||
retry_kwargs={"max_retries": 3},
|
||||
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
|
||||
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
|
||||
)
|
||||
def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
|
||||
"""
|
||||
تسک کوچک و اتمی: ارسال نامکاربری/رمز اسکایروم برای یک Registration.
|
||||
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
|
||||
"""
|
||||
user = None
|
||||
log = None
|
||||
|
||||
try:
|
||||
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
|
||||
user = r.user
|
||||
event = r.event
|
||||
|
||||
# ساخت یوزرنیم/پسورد
|
||||
sky_username = (user.email or "").strip().split("@")[0]
|
||||
sky_password = str(r.ticket_id or "")[:8]
|
||||
skyroom_url = event.online_link
|
||||
|
||||
context_key = _build_email_context(
|
||||
"skyroom_credentials",
|
||||
event.slug or event.id,
|
||||
sky_username,
|
||||
sky_password,
|
||||
skyroom_url,
|
||||
)
|
||||
log, skip = EventEmailLog.claim(
|
||||
event_id=event_id,
|
||||
user_id=user.id,
|
||||
kind=EventEmailLog.KIND_SKYROOM_CREDENTIALS,
|
||||
context=context_key,
|
||||
)
|
||||
if skip:
|
||||
return {"skipped": True, "status": log.status}
|
||||
|
||||
ctx = {
|
||||
"user": user,
|
||||
"event": event,
|
||||
"skyroom_url": skyroom_url,
|
||||
"sky_username": sky_username,
|
||||
"sky_password": sky_password,
|
||||
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
|
||||
}
|
||||
|
||||
subject = f"اطلاعات دسترسی اسکایروم - {event.title}"
|
||||
html = render_to_string("emails/skyroom_credentials.html", ctx)
|
||||
text_body = strip_tags(html)
|
||||
|
||||
msg = EmailMultiAlternatives(
|
||||
subject=subject,
|
||||
body=text_body,
|
||||
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
|
||||
to=[user.email],
|
||||
)
|
||||
msg.attach_alternative(html, "text/html")
|
||||
msg.send()
|
||||
|
||||
log.mark_sent()
|
||||
|
||||
logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email)
|
||||
return f"Email sent to {user.email}"
|
||||
|
||||
except SoftTimeLimitExceeded as exc:
|
||||
# ثبت خطا و اجازه به Celery برای retry خودکار
|
||||
if log:
|
||||
log.mark_failed("Soft time limit exceeded")
|
||||
logger.warning(
|
||||
"Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id
|
||||
)
|
||||
raise
|
||||
|
||||
except Exception as exc:
|
||||
if log:
|
||||
log.mark_failed(str(exc))
|
||||
logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True)
|
||||
raise
|
||||
0
apps/events/tests/__init__.py
Normal file
0
apps/events/tests/__init__.py
Normal file
0
apps/events/tests/integration/__init__.py
Normal file
0
apps/events/tests/integration/__init__.py
Normal file
540
apps/events/tests/integration/test_events.py
Normal file
540
apps/events/tests/integration/test_events.py
Normal file
@@ -0,0 +1,540 @@
|
||||
import io
|
||||
import json
|
||||
import tempfile
|
||||
import uuid
|
||||
from datetime import timedelta
|
||||
from types import SimpleNamespace
|
||||
|
||||
from PIL import Image
|
||||
from django.core.files.uploadedfile import SimpleUploadedFile
|
||||
from django.test import TestCase, override_settings
|
||||
from django.utils import timezone
|
||||
|
||||
from core.authentication import create_jwt_token
|
||||
from apps.events.api.schemas import (
|
||||
EventSchema,
|
||||
EventGallerySchema,
|
||||
EventListSchema,
|
||||
RegistrationSchema,
|
||||
PaymentAdminSchema,
|
||||
EventAdminDetailSchema,
|
||||
)
|
||||
from apps.events.api.views import list_events
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.gallery.models import Gallery
|
||||
from apps.payments.models import DiscountCode
|
||||
from apps.users.models import Major, University, User
|
||||
|
||||
MEDIA_ROOT = tempfile.mkdtemp()
|
||||
|
||||
|
||||
@override_settings(MEDIA_ROOT=MEDIA_ROOT)
|
||||
class EventsAPIIntegrationTests(TestCase):
|
||||
password = "TestPass123!"
|
||||
|
||||
@classmethod
|
||||
def setUpTestData(cls):
|
||||
cls.user = User.objects.create_user(
|
||||
username="event_user",
|
||||
email="event.user@example.com",
|
||||
password=cls.password,
|
||||
)
|
||||
cls.user.is_email_verified = True
|
||||
cls.user.save(update_fields=["is_email_verified"])
|
||||
|
||||
cls.staff = User.objects.create_user(
|
||||
username="event_staff",
|
||||
email="event.staff@example.com",
|
||||
password=cls.password,
|
||||
is_staff=True,
|
||||
)
|
||||
cls.staff.is_email_verified = True
|
||||
cls.staff.save(update_fields=["is_email_verified"])
|
||||
cls.major, _ = Major.objects.get_or_create(code="CS", defaults={"name": "Computer Science"})
|
||||
cls.university, _ = University.objects.get_or_create(code="UT", defaults={"name": "University of Tehran"})
|
||||
cls.user.major = cls.major
|
||||
cls.user.university = cls.university
|
||||
cls.user.save(update_fields=["major", "university"])
|
||||
cls.staff.major = cls.major
|
||||
cls.staff.university = cls.university
|
||||
cls.staff.save(update_fields=["major", "university"])
|
||||
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
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,
|
||||
)
|
||||
|
||||
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.update(overrides)
|
||||
return Event.objects.create(**defaults)
|
||||
|
||||
def _create_gallery_image(self):
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", (10, 10), color="blue").save(buffer, format="PNG")
|
||||
buffer.seek(0)
|
||||
file = SimpleUploadedFile("gallery.png", buffer.read(), content_type="image/png")
|
||||
return Gallery.objects.create(
|
||||
title="Gallery image",
|
||||
description="desc",
|
||||
image=file,
|
||||
uploaded_by=self.user,
|
||||
)
|
||||
|
||||
def _create_paid_event(self):
|
||||
return self._create_event(price=30000, capacity=5)
|
||||
|
||||
def _create_registration(self, event, user, status=Registration.StatusChoices.PENDING):
|
||||
return Registration.objects.create(event=event, user=user, status=status, final_price=event.price)
|
||||
|
||||
# Basic event endpoints ------------------------------------------------
|
||||
|
||||
def test_list_events_filters_and_search(self):
|
||||
# Act
|
||||
response = self.client.get("/api/events/", {"status": "published", "search": "Searchable"})
|
||||
data = response.json()
|
||||
|
||||
# Assert
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(any(item["id"] == self.other_event.id for item in data))
|
||||
|
||||
def test_get_event_by_id_and_slug(self):
|
||||
response_id = self.client.get(f"/api/events/{self.event.id}")
|
||||
response_slug = self.client.get(f"/api/events/slug/{self.event.slug}")
|
||||
|
||||
self.assertEqual(response_id.status_code, 200)
|
||||
self.assertEqual(response_slug.status_code, 200)
|
||||
self.assertEqual(response_id.json()["id"], self.event.id)
|
||||
self.assertEqual(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,
|
||||
}
|
||||
created = self.client.post(
|
||||
"/api/events/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(created.status_code, 200)
|
||||
event_id = created.json()["id"]
|
||||
|
||||
updated = self.client.put(
|
||||
f"/api/events/{event_id}",
|
||||
data=json.dumps({"title": "Updated Event"}),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["title"], "Updated Event")
|
||||
|
||||
deleted = self.client.delete(f"/api/events/{event_id}")
|
||||
self.assertEqual(deleted.status_code, 200)
|
||||
|
||||
def test_admin_detail_and_registration_list_requires_staff(self):
|
||||
staff_headers = self._auth_headers(self.staff_token)
|
||||
user_headers = self._auth_headers(self.token)
|
||||
|
||||
_ = self._create_registration(self.event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
|
||||
# Non staff forbidden
|
||||
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **user_headers)
|
||||
self.assertEqual(list_resp.status_code, 403)
|
||||
|
||||
# Staff allowed
|
||||
list_resp = self.client.get(f"/api/events/{self.event.id}/admin-registrations", **staff_headers)
|
||||
detail_resp = self.client.get(f"/api/events/{self.event.id}/admin-detail", **staff_headers)
|
||||
self.assertEqual(list_resp.status_code, 200)
|
||||
self.assertEqual(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,
|
||||
)
|
||||
response = self.client.get(
|
||||
"/api/events/",
|
||||
{
|
||||
"event_type": Event.TypeChoices.ONLINE,
|
||||
"search": "Unique discovery",
|
||||
},
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(any(item["id"] == event.id for item in response.json()))
|
||||
|
||||
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,
|
||||
)
|
||||
self.assertIn(event, 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],
|
||||
}
|
||||
response = self.client.post(
|
||||
"/api/events/",
|
||||
data=json.dumps(payload),
|
||||
content_type="application/json",
|
||||
)
|
||||
body = response.json()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(body["gallery_images"])
|
||||
|
||||
updated = self.client.put(
|
||||
f"/api/events/{body['id']}",
|
||||
data=json.dumps(
|
||||
{
|
||||
"title": "Gallery Event Updated",
|
||||
"gallery_image_ids": [gallery.id],
|
||||
}
|
||||
),
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(updated.status_code, 200)
|
||||
self.assertEqual(updated.json()["slug"], "gallery-event-updated")
|
||||
self.assertTrue(updated.json()["gallery_images"])
|
||||
|
||||
def test_admin_registration_filters_include_university_major_and_search(self):
|
||||
event = self.event
|
||||
self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
headers = self._auth_headers(self.staff_token)
|
||||
response = self.client.get(
|
||||
f"/api/events/{event.id}/admin-registrations",
|
||||
{
|
||||
"university": self.user.university.code,
|
||||
"major": self.user.major.code,
|
||||
"search": self.user.username,
|
||||
"status": [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.PENDING],
|
||||
},
|
||||
**headers,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 1)
|
||||
|
||||
def test_register_before_start_and_after_end_dates_fail(self):
|
||||
future_event = self._create_event(registration_start_date=timezone.now() + timedelta(days=1))
|
||||
future_response = self.client.post(
|
||||
f"/api/events/{future_event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(future_response.status_code, 400)
|
||||
|
||||
closed_event = self._create_event(registration_end_date=timezone.now() - timedelta(hours=1))
|
||||
closed_response = self.client.post(
|
||||
f"/api/events/{closed_event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(closed_response.status_code, 400)
|
||||
|
||||
def test_register_recreates_after_cancelled_registration(self):
|
||||
event = self._create_event(price=0)
|
||||
Registration.objects.create(
|
||||
event=event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CANCELLED,
|
||||
final_price=0,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/events/{event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
|
||||
|
||||
def test_register_updates_final_price_when_none(self):
|
||||
event = self._create_paid_event()
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=None,
|
||||
)
|
||||
response = self.client.post(
|
||||
f"/api/events/{event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
content_type="application/json",
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["final_price"], event.price)
|
||||
|
||||
def _create_discount_code(self, event):
|
||||
code = DiscountCode.objects.create(
|
||||
code=f"CODE-{uuid.uuid4().hex[:4]}",
|
||||
value=50,
|
||||
type=DiscountCode.Type.PERCENT,
|
||||
is_active=True,
|
||||
)
|
||||
code.applicable_events.add(event)
|
||||
return code
|
||||
|
||||
def test_register_for_event_with_free_price_confirms(self):
|
||||
event = self._create_event(price=0)
|
||||
response = self.client.post(
|
||||
f"/api/events/{event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["status"], Registration.StatusChoices.CONFIRMED)
|
||||
|
||||
def test_register_for_event_with_discount_updates_final_price(self):
|
||||
event = self._create_paid_event()
|
||||
code = self._create_discount_code(event)
|
||||
response = self.client.post(
|
||||
f"/api/events/{event.id}/register",
|
||||
data=json.dumps({"discount_code": code.code}),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
|
||||
result = response.json()
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(result["discount_code"], code.code)
|
||||
self.assertEqual(result["discount_amount"], event.price // 2)
|
||||
self.assertEqual(result["final_price"], event.price // 2)
|
||||
|
||||
def test_register_fails_when_capacity_full(self):
|
||||
event = self._create_event(capacity=1)
|
||||
other = self._create_event_user("other_user", "other@example.com")
|
||||
Registration.objects.create(
|
||||
event=event,
|
||||
user=other,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=0,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/events/{event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def _create_event_user(self, username, email):
|
||||
user = User.objects.create_user(username=username, email=email, password=self.password)
|
||||
user.is_email_verified = True
|
||||
user.save(update_fields=["is_email_verified"])
|
||||
user.major = self.user.major
|
||||
user.university = self.user.university
|
||||
user.save(update_fields=["major", "university"])
|
||||
return user
|
||||
|
||||
def test_register_rejects_duplicate_confirmed(self):
|
||||
event = self._create_event(price=0)
|
||||
Registration.objects.create(
|
||||
event=event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=0,
|
||||
)
|
||||
|
||||
response = self.client.post(
|
||||
f"/api/events/{event.id}/register",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
content_type="application/json",
|
||||
)
|
||||
|
||||
self.assertEqual(response.status_code, 400)
|
||||
|
||||
def test_registration_status_update_and_cancel(self):
|
||||
event = self._create_event(price=0)
|
||||
registration = self._create_registration(event, self.user)
|
||||
|
||||
update = self.client.put(
|
||||
f"/api/events/registrations/{registration.id}",
|
||||
data=json.dumps({"status": Registration.StatusChoices.ATTENDED}),
|
||||
content_type="application/json",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(update.status_code, 200)
|
||||
self.assertEqual(update.json()["status"], Registration.StatusChoices.ATTENDED)
|
||||
|
||||
cancel = self.client.delete(
|
||||
f"/api/events/registrations/{registration.id}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(cancel.status_code, 200)
|
||||
self.assertEqual(cancel.json()["message"], "ثبتنام شما لغو شد :(")
|
||||
|
||||
def test_verify_registration_and_my_registrations(self):
|
||||
event = self._create_event(price=0)
|
||||
registration = self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
|
||||
verify = self.client.get(
|
||||
f"/api/events/registerations/verify/{registration.ticket_id}",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(verify.status_code, 200)
|
||||
self.assertEqual(verify.json()["ticket_id"], str(registration.ticket_id))
|
||||
|
||||
my_regs = self.client.get(
|
||||
"/api/events/my-registrations",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(my_regs.status_code, 200)
|
||||
self.assertGreater(len(my_regs.json()), 0)
|
||||
|
||||
status_resp = self.client.get(
|
||||
f"/api/events/{event.id}/is-registered",
|
||||
HTTP_AUTHORIZATION=f"Bearer {self.token}",
|
||||
)
|
||||
self.assertEqual(status_resp.status_code, 200)
|
||||
self.assertTrue(status_resp.json()["is_registered"])
|
||||
|
||||
def test_list_event_registrations(self):
|
||||
event = self.event
|
||||
self._create_registration(event, self.user)
|
||||
|
||||
response = self.client.get(f"/api/events/{event.id}/registrations")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(response.json())
|
||||
|
||||
def test_list_event_registrations_admin_filters(self):
|
||||
event = self.event
|
||||
self._create_registration(event, self.user, status=Registration.StatusChoices.PENDING)
|
||||
headers = self._auth_headers(self.staff_token)
|
||||
response = self.client.get(
|
||||
f"/api/events/{event.id}/admin-registrations",
|
||||
{"status": [Registration.StatusChoices.PENDING]},
|
||||
**headers,
|
||||
)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.json()["count"], 1)
|
||||
|
||||
|
||||
class EventSchemasIntegrationTests(TestCase):
|
||||
password = "SchemaPass!123"
|
||||
|
||||
def setUp(self):
|
||||
self.user = User.objects.create_user(
|
||||
username="schema_user",
|
||||
email="schema.user@example.com",
|
||||
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",
|
||||
)
|
||||
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,
|
||||
)
|
||||
|
||||
def _mock_request(self):
|
||||
return SimpleNamespace(build_absolute_uri=lambda path: f"https://test{path}")
|
||||
|
||||
def test_gallery_schema_returns_full_url(self):
|
||||
obj = SimpleNamespace(image=SimpleNamespace(url="/media/gallery.png"))
|
||||
result = EventGallerySchema.resolve_absolute_image_url(obj, {"request": self._mock_request()})
|
||||
self.assertEqual(result, "https://test/media/gallery.png")
|
||||
|
||||
def test_event_schema_resolvers(self):
|
||||
context = {"request": self._mock_request()}
|
||||
event_obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
|
||||
self.assertEqual(EventSchema.resolve_absolute_featured_image_url(event_obj, context), "https://test/media/feat.png")
|
||||
self.assertEqual(EventSchema.resolve_registration_count(self.event), 2)
|
||||
self.assertIn("<p>", EventSchema.resolve_description_html(self.event))
|
||||
|
||||
def test_event_list_schema_resolvers(self):
|
||||
obj = SimpleNamespace(featured_image=SimpleNamespace(url="/media/feat.png"), registrations=self.event.registrations)
|
||||
context = {"request": self._mock_request()}
|
||||
self.assertEqual(EventListSchema.resolve_absolute_featured_image_url(obj, context), "https://test/media/feat.png")
|
||||
self.assertEqual(EventListSchema.resolve_registration_count(self.event), 2)
|
||||
|
||||
def test_registration_schema_resolves_discount_code(self):
|
||||
discount = DiscountCode.objects.create(code="SCHEMA", type=DiscountCode.Type.FIXED, value=100, is_active=True)
|
||||
discount.applicable_events.add(self.event)
|
||||
registration = Registration.objects.create(
|
||||
event=self.event,
|
||||
user=self.user,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
final_price=900,
|
||||
discount_code=discount,
|
||||
)
|
||||
self.assertEqual(RegistrationSchema.resolve_discount_code(registration), discount.code)
|
||||
|
||||
def test_payment_admin_schema_normalizes_discount_code(self):
|
||||
self.assertIsNone(PaymentAdminSchema.normalize_discount_code(None))
|
||||
self.assertEqual(PaymentAdminSchema.normalize_discount_code("123"), "123")
|
||||
self.assertEqual(PaymentAdminSchema.normalize_discount_code(SimpleNamespace(code="ABC")), "ABC")
|
||||
|
||||
def test_event_admin_detail_resolves_registrations(self):
|
||||
registrations = EventAdminDetailSchema.resolve_registrations(self.event)
|
||||
self.assertTrue(list(registrations))
|
||||
# TODO registration-related tests
|
||||
0
apps/events/tests/unit/__init__.py
Normal file
0
apps/events/tests/unit/__init__.py
Normal file
1197
apps/events/tests/unit/test_events.py
Normal file
1197
apps/events/tests/unit/test_events.py
Normal file
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user