419 lines
16 KiB
Python
419 lines
16 KiB
Python
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,
|
|
)
|