init
This commit is contained in:
418
backend/events/admin.py
Normal file
418
backend/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 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,
|
||||
)
|
||||
25
backend/events/admin_forms.py
Normal file
25
backend/events/admin_forms.py
Normal 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
6
backend/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 = 'events'
|
||||
379
backend/events/fixtures/events.json
Normal file
379
backend/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
backend/events/migrations/0001_initial.py
Normal file
60
backend/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
backend/events/migrations/0002_initial.py
Normal file
27
backend/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
backend/events/migrations/0003_initial.py
Normal file
39
backend/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
backend/events/migrations/0008_alter_eventemaillog_kind.py
Normal file
18
backend/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
backend/events/migrations/0011_eventemaillog_context_hash.py
Normal file
22
backend/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
backend/events/migrations/0012_alter_eventemaillog_kind.py
Normal file
18
backend/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),
|
||||
),
|
||||
]
|
||||
0
backend/events/migrations/__init__.py
Normal file
0
backend/events/migrations/__init__.py
Normal file
269
backend/events/models.py
Normal file
269
backend/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 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)
|
||||
86
backend/events/resources.py
Normal file
86
backend/events/resources.py
Normal 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
584
backend/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 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
|
||||
Reference in New Issue
Block a user