init
Some checks failed
CI/CD / Backend & Frontend Checks (push) Has been cancelled
CI/CD / Deploy to Production (push) Has been cancelled

This commit is contained in:
2026-05-18 11:34:07 +03:30
commit 7a8ddeabed
279 changed files with 37390 additions and 0 deletions

418
backend/events/admin.py Normal file
View 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 utils.templatetags.jalali import jdate
from unfold.decorators import action as unfold_action
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
from events.models import Event, Registration, EventEmailLog
from events.resources import EventResource, RegistrationResource
from 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 events.admin_forms import AnnouncementForm
from 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 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,
)

View File

@@ -0,0 +1,25 @@
from django import forms
from unfold.widgets import UnfoldAdminTextInputWidget, UnfoldAdminTextareaWidget
from 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,
)

6
backend/events/apps.py Normal file
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class EventsConfig(AppConfig):
default_auto_field = 'django.db.models.BigAutoField'
name = 'events'

View 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"
}
}
]

View 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'],
},
),
]

View 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'),
),
]

View 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'),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
),
]

View File

@@ -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')},
},
),
]

View File

@@ -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),
),
]

View 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),
),
]

View File

@@ -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),
),
]

View File

@@ -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),
]

View 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')},
),
]

View 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),
),
]

View File

269
backend/events/models.py Normal file
View 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 utils.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 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 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 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)

View File

@@ -0,0 +1,86 @@
from import_export import resources, fields
from import_export.widgets import ForeignKeyWidget, ManyToManyWidget
from events.models import Event, Registration
from users.models import User
from gallery.models import Gallery
from 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
backend/events/tasks.py Normal file
View 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 users.models import User
from events.models import Event, Registration, EventEmailLog
from utils.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