feat: replace email flows with in-app notifications

Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
This commit is contained in:
2026-09-26 12:23:10 +03:30
parent b706a2d10a
commit 3ec28638d1
54 changed files with 787 additions and 4843 deletions

View File

@@ -1,27 +1,10 @@
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 django.contrib import admin
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 core.admin import BaseModelAdmin, SoftDeleteListFilter
from core.templatetags.jalali import jdate
from apps.events.models import Event, Registration
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)
@@ -29,390 +12,143 @@ 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'
'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
'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')
}),
('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.'
'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',)
'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',
'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")
@admin.display(description='Price')
def price_display(self, obj):
return obj.price if obj.price is not None else "رایگان"
return obj.price if obj.price is not None else 'رایگان'
@admin.display(description="Start")
@admin.display(description='Start')
def start_time_display(self, obj):
return jdate(obj.start_time)
@admin.display(description="End")
@admin.display(description='End')
def end_time_display(self, obj):
return jdate(obj.end_time)
@admin.display(description="Capacity")
@admin.display(description='Capacity')
def capacity_display(self, obj):
return obj.capacity if obj.capacity is not None else "نامحدود"
return obj.capacity if obj.capacity is not None else 'نامحدود'
@admin.display(description="Attendees")
@admin.display(description='Attendees')
def attendees_display(self, obj):
return obj.current_attendees_count
@admin.display(description="Open", boolean=True)
@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")
@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.")
self.message_user(request, f'Published {queryset.count()} events.')
@admin.action(description="Mark selected events as draft")
@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.")
self.message_user(request, f'Marked {queryset.count()} events as draft.')
@admin.action(description="Mark selected events as cancelled")
@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.")
self.message_user(request, f'Cancelled {queryset.count()} events.')
@admin.action(description="Mark selected events as completed")
@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.")
self.message_user(request, f'Completed {queryset.count()} events.')
@admin.action(description="Restore selected events")
@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"))
self.message_user(request, f'Restored {queryset.count()} events.')
@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',
'user', 'event', 'status', 'registered_at', 'ticket_id',
'discount_code', 'discount_amount', 'final_price',
)
list_filter = (
'status',
'event',
'is_deleted',
'registered_at',
SoftDeleteListFilter
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',
)
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',
'ticket_id', 'registered_at', 'confirmation_notification_sent_at',
'cancellation_notification_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',)
('Registration Details', {'fields': (
'user', 'event', 'status', 'registered_at', 'ticket_id',
'confirmation_notification_sent_at', 'cancellation_notification_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',
'confirm_registrations', 'cancel_registrations', 'mark_attended', 'restore_registrations',
]
@admin.action(description="Confirm selected registrations")
@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.")
count = 0
for registration in queryset:
registration.status = Registration.StatusChoices.CONFIRMED
registration.save(update_fields=['status'])
count += 1
self.message_user(request, f'Confirmed {count} registrations.')
@admin.action(description="Cancel selected 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.")
count = 0
for registration in queryset:
registration.status = Registration.StatusChoices.CANCELLED
registration.save(update_fields=['status'])
count += 1
self.message_user(request, f'Cancelled {count} registrations.')
@admin.action(description="Mark selected registrations as attended")
@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.")
self.message_user(request, f'Marked {queryset.count()} registrations as attended.')
@admin.action(description="Restore selected registrations")
@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,
)
self.message_user(request, f'Restored {queryset.count()} registrations.')