from django.contrib import admin, messages from django.template.response import TemplateResponse from django.contrib.admin.helpers import ACTION_CHECKBOX_NAME from django.template.loader import render_to_string from django.conf import settings from django.shortcuts import redirect from django.urls import reverse_lazy from import_export.admin import ImportExportModelAdmin from core.templatetags.jalali import jdate from unfold.decorators import action as unfold_action from core.admin import SoftDeleteListFilter, BaseModelAdmin from apps.events.models import Event, Registration, EventEmailLog from apps.events.resources import EventResource, RegistrationResource from apps.events.tasks import ( queue_skyroom_credentials, send_skyroom_credentials_individual_task, send_event_reminder_task, queue_event_announcement, queue_invites_to_non_registered_users, ) from apps.events.admin_forms import AnnouncementForm from apps.events.tasks import _send_html_email @admin.register(Event) class EventAdmin(BaseModelAdmin, ImportExportModelAdmin): resource_class = EventResource list_display = ( 'title', 'event_type', 'start_time_display', 'end_time_display', 'status', 'price_display', 'capacity_display', 'attendees_display', 'is_registration_open_display' ) list_filter = ( 'event_type', 'status', 'is_deleted', 'start_time', 'end_time', 'registration_start_date', 'registration_end_date', SoftDeleteListFilter ) search_fields = ('title', 'description', 'address') prepopulated_fields = {'slug': ('title',)} date_hierarchy = 'start_time' filter_horizontal = ('gallery_images',) fieldsets = ( ('Event Details', { 'fields': ('title', 'slug', 'description', 'featured_image') }), ('Timing & Type', { 'fields': ('start_time', 'end_time', 'event_type', 'status') }), ('Location & Online', { 'fields': ('address', 'location', 'online_link'), 'description': 'For On-Site or Hybrid events, provide address and select on map. For Online events, provide a link.' }), ('Registration & Pricing', { 'fields': ('capacity', 'price', 'registration_start_date', 'registration_end_date', 'registration_success_markdown'), 'description': 'Leave capacity blank for unlimited. Leave price blank for free events.' }), ('Gallery', { 'fields': ('gallery_images',), 'description': 'Add images related to this event from the Gallery app.' }), ('Soft Delete', { 'fields': ('is_deleted', 'deleted_at'), 'classes': ('collapse',) }), ) readonly_fields = ('deleted_at',) actions = BaseModelAdmin.actions + [ 'make_published', 'make_draft', 'make_cancelled', 'make_completed', 'restore_events', ] actions_row = [ 'action_send_announcement', 'action_send_reminder_now', 'action_send_skyroom_credentials', 'action_invite_other_users', ] @admin.display(description="Price") def price_display(self, obj): return obj.price if obj.price is not None else "رایگان" @admin.display(description="Start") def start_time_display(self, obj): return jdate(obj.start_time) @admin.display(description="End") def end_time_display(self, obj): return jdate(obj.end_time) @admin.display(description="Capacity") def capacity_display(self, obj): return obj.capacity if obj.capacity is not None else "نامحدود" @admin.display(description="Attendees") def attendees_display(self, obj): return obj.current_attendees_count @admin.display(description="Open", boolean=True) def is_registration_open_display(self, obj): return obj.is_registration_open @admin.action(description="Mark selected events as published") def make_published(self, request, queryset): queryset.update(status=Event.StatusChoices.PUBLISHED) self.message_user(request, f"Published {queryset.count()} events.") @admin.action(description="Mark selected events as draft") def make_draft(self, request, queryset): queryset.update(status=Event.StatusChoices.DRAFT) self.message_user(request, f"Marked {queryset.count()} events as draft.") @admin.action(description="Mark selected events as cancelled") def make_cancelled(self, request, queryset): queryset.update(status=Event.StatusChoices.CANCELLED) self.message_user(request, f"Cancelled {queryset.count()} events.") @admin.action(description="Mark selected events as completed") def make_completed(self, request, queryset): queryset.update(status=Event.StatusChoices.COMPLETED) self.message_user(request, f"Marked {queryset.count()} events as completed.") @admin.action(description="Restore selected events") def restore_events(self, request, queryset): for event in queryset: event.restore() self.message_user(request, f"Restored {queryset.count()} events.") @unfold_action(description="Send Skyroom Credentials") def action_send_skyroom_credentials(self, request, object_id: int): event = Event.objects.get(pk=object_id) queue_skyroom_credentials.delay(event.pk) self.message_user(request, f"ارسال مشخصات اسکای‌روم برای رویداد '{event.title}' صف شد.", messages.SUCCESS) return redirect(reverse_lazy("admin:events_event_changelist")) @unfold_action(description="Send new Reminder") def action_send_reminder_now(self, request, object_id: int): event = Event.objects.get(pk=object_id) send_event_reminder_task.delay(event.pk) self.message_user(request, f"یادآوری برای رویداد '{event.title}' صف شد.", messages.SUCCESS) return redirect(reverse_lazy("admin:events_event_changelist")) @unfold_action(description="send new Announcement") def action_send_announcement(self, request, object_id: int): """ این اکشن یک فرم می‌گیرد (عنوان/متن/وضعیت‌ها) و با تمپلیت Unfold نشان داده می‌شود. """ form = AnnouncementForm(request.POST or None) event = Event.objects.get(pk=object_id) if request.method == "POST" and form.is_valid(): subject = form.cleaned_data["subject"] body_html = form.cleaned_data["body_html"] statuses = form.cleaned_data["statuses"] or None queue_event_announcement.delay(event.pk, subject, body_html, statuses=statuses) self.message_user(request, f"اطلاعیه برای رویداد '{event.title}' صف شد.", messages.SUCCESS) return redirect(reverse_lazy("admin:events_event_changelist")) context = { **self.admin_site.each_context(request), "title": "ارسال اطلاعیه گروهی", "opts": self.model._meta, "form": form, "action_name": "action_send_announcement", "action_checkbox_name": ACTION_CHECKBOX_NAME, } return TemplateResponse(request, "forms/admin_announcement.html", context) @unfold_action(description="Invite other users") def action_invite_other_users(self, request, object_id: int): event = Event.objects.get(pk=object_id) queue_invites_to_non_registered_users.delay(event.pk) self.message_user(request, f"دعوت برای شرکت در رویداد '{event.title}' صف شد.", messages.SUCCESS) return redirect(reverse_lazy("admin:events_event_changelist")) @admin.register(Registration) class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin): resource_class = RegistrationResource list_display = ( 'user', 'event', 'status', 'registered_at', 'ticket_id', 'discount_code', 'discount_amount', 'final_price', ) list_filter = ( 'status', 'event', 'is_deleted', 'registered_at', SoftDeleteListFilter ) search_fields = ('user__username', 'user__email', 'user__first_name', 'user__last_name', 'event__title', 'ticket_id') readonly_fields = ( 'ticket_id', 'registered_at', 'confirmation_email_sent_at', 'cancellation_email_sent_at', 'discount_code', 'discount_amount', 'final_price', 'deleted_at', ) fieldsets = ( ( 'Registration Details', { 'fields': ( 'user', 'event', 'status', 'registered_at', 'ticket_id', 'confirmation_email_sent_at', 'cancellation_email_sent_at', ) }, ), ( 'Pricing & Discount', { 'fields': ('discount_code', 'discount_amount', 'final_price'), 'classes': ('collapse',), }, ), ('Soft Delete', { 'fields': ('is_deleted', 'deleted_at'), 'classes': ('collapse',) }), ) actions = BaseModelAdmin.actions + [ 'confirm_registrations', 'cancel_registrations', 'mark_attended', 'restore_registrations', ] actions_row = [ 'action_email_selected', 'action_send_skyroom_credentials', ] @admin.action(description="Confirm selected registrations") def confirm_registrations(self, request, queryset): queryset.update(status=Registration.StatusChoices.CONFIRMED) self.message_user(request, f"Confirmed {queryset.count()} registrations.") @admin.action(description="Cancel selected registrations") def cancel_registrations(self, request, queryset): queryset.update(status=Registration.StatusChoices.CANCELLED) self.message_user(request, f"Cancelled {queryset.count()} registrations.") @admin.action(description="Mark selected registrations as attended") def mark_attended(self, request, queryset): queryset.update(status=Registration.StatusChoices.ATTENDED) self.message_user(request, f"Marked {queryset.count()} registrations as attended.") @admin.action(description="Restore selected registrations") def restore_registrations(self, request, queryset): for registration in queryset: registration.restore() self.message_user(request, f"Restored {queryset.count()} registrations.") @unfold_action(description="send email to registrated user") def action_email_selected(self, request, object_id: int): """ همان فرم اطلاعیه را می‌گیرد و به افراد انتخاب‌شده ایمیل می‌زند. برای نمایش فرم، از تمپلیت Unfold استفاده می‌کنیم. """ form = AnnouncementForm(request.POST or None) registration = Registration.objects.get(id=object_id) if request.method == "POST" and form.is_valid(): subject = form.cleaned_data["subject"] body_html = form.cleaned_data["body_html"] user = registration.user ctx = { "user": user, "event": registration.event, "body_html": body_html, "event_url": f"{settings.FRONTEND_ROOT}events/{registration.event.slug}", } html = render_to_string("emails/event_announcement.html", ctx) _send_html_email(subject, html, user.email) self.message_user(request, f"ارسال ایمیل انجام شد.", messages.SUCCESS) return redirect(reverse_lazy("admin:events_registration_changelist")) context = { **self.admin_site.each_context(request), "title": "ارسال ایمیل به ثبت‌نام‌های انتخاب‌شده", "form": AnnouncementForm(), "opts": self.model._meta, "action_name": "action_email_selected", "action_checkbox_name": ACTION_CHECKBOX_NAME, } return TemplateResponse(request, "forms/admin_announcement.html", context) @unfold_action(description="Send Skyroom Credentials") def action_send_skyroom_credentials(self, request, object_id: int): send_skyroom_credentials_individual_task.delay(object_id) self.message_user(request, f"ارسال مشخصات اسکای‌روم به کاربر مربوطه صف شد.", messages.SUCCESS) return redirect(reverse_lazy("admin:events_registration_changelist")) from apps.events.tasks import send_invite_to_user @admin.register(EventEmailLog) class EventEmailLogAdmin(BaseModelAdmin, ImportExportModelAdmin): list_display = ( "id", "event", "user", "user_email", "kind", "status", "sent_at", "created_at", ) list_filter = ( "kind", "status", "event", ("sent_at", admin.EmptyFieldListFilter), ("error", admin.EmptyFieldListFilter), SoftDeleteListFilter, ) search_fields = ( "user__email", "user__username", "user__first_name", "user__last_name", "event__title", ) autocomplete_fields = ("event", "user") date_hierarchy = "created_at" ordering = ("-created_at",) list_per_page = 50 list_select_related = ("event", "user") # چون این مدل برای ایدمپوتنسی حیاتی است، ویرایش دستی را محدود می‌کنیم readonly_fields = ( "event", "user", "kind", "status", "error", "sent_at", "created_at", "updated_at", ) fields = readonly_fields def has_add_permission(self, request): return False def has_change_permission(self, request, obj=None): return True actions = BaseModelAdmin.actions + [ 'resend_selected_emails' ] @admin.display(description="Email", ordering="user__email") def user_email(self, obj): return obj.user.email or "—" @admin.action(description="ارسال مجدد ایمیل برای رکوردهای انتخاب‌شده") def resend_selected_emails(self, request, queryset): """ رکوردهای SENT را اسکیپ می‌کند، بقیه را به وضعیت pending برمی‌گرداند و تسک ارسال تکی را در صف می‌گذارد (ایدِمپوتنت). """ queued = 0 skipped = 0 for log in queryset.select_related("event", "user"): if log.status == EventEmailLog.STATUS_SENT: skipped += 1 continue # برگرداندن به pending و پاک کردن خطا if log.status != EventEmailLog.STATUS_PENDING or log.error: log.status = EventEmailLog.STATUS_PENDING log.error = "" log.save(update_fields=["status", "error", "updated_at"]) # صف کردن تسک اتمی send_invite_to_user.delay(log.event_id, log.user_id) queued += 1 if queued: self.message_user( request, "%(n)d مورد در صف ارسال قرار گرفت." % {"n": queued}, level=messages.SUCCESS, ) if skipped: self.message_user( request, "%(n)d مورد قبلاً ارسال شده بود و نادیده گرفته شد." % {"n": skipped}, level=messages.WARNING, )