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.')

View File

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

View File

@@ -225,7 +225,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
_notify_event_update(
event,
notification_type="event_cancelled",
title=f"رویداد {event.title} لغو شد",
title=f"رویداد «{event.title}» لغو شد",
message="این رویداد لغو شده است. برای جزئیات بیشتر صفحه رویداد را بررسی کنید.",
level="warning",
sms_kind="event_cancellation",
@@ -234,7 +234,7 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
_notify_event_update(
event,
notification_type="event_rescheduled",
title=f"زمان یا محل {event.title} تغییر کرد",
title=f"جزئیات برگزاری «{event.title}» تغییر کرد",
message="جزئیات زمان‌بندی یا محل برگزاری این رویداد به‌روزرسانی شده است.",
level="info",
sms_kind="event_reschedule",

View File

@@ -0,0 +1,26 @@
# Generated by Django 5.2.5 on 2026-09-26 07:57
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('events', '0013_alter_eventemaillog_kind'),
]
operations = [
migrations.RenameField(
model_name='registration',
old_name='cancellation_email_sent_at',
new_name='cancellation_notification_sent_at',
),
migrations.RenameField(
model_name='registration',
old_name='confirmation_email_sent_at',
new_name='confirmation_notification_sent_at',
),
migrations.DeleteModel(
name='EventEmailLog',
),
]

View File

@@ -1,10 +1,8 @@
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
@@ -135,8 +133,8 @@ class Registration(BaseModel):
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)
confirmation_notification_sent_at = models.DateTimeField(null=True, blank=True)
cancellation_notification_sent_at = models.DateTimeField(null=True, blank=True)
discount_code = models.ForeignKey(
"payments.DiscountCode",
null=True,
@@ -176,113 +174,22 @@ class Registration(BaseModel):
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:
if is_create and self.status == self.StatusChoices.CONFIRMED and not self.confirmation_notification_sent_at:
# lazy import to avoid circular import
from apps.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"])
from apps.events.tasks import send_registration_confirmation_notification
send_registration_confirmation_notification.delay(str(self.pk))
self.confirmation_notification_sent_at = timezone.now()
super().save(update_fields=["confirmation_notification_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 apps.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"])
if (not is_create) and (old_status != self.StatusChoices.CANCELLED) and (self.status == self.StatusChoices.CANCELLED) and (not self.cancellation_notification_sent_at):
from apps.events.tasks import send_registration_cancellation_notification
send_registration_cancellation_notification.delay(str(self.pk))
self.cancellation_notification_sent_at = timezone.now()
super().save(update_fields=["cancellation_notification_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 apps.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)
if (not is_create) and (old_status != self.StatusChoices.CONFIRMED) and (self.status == self.StatusChoices.CONFIRMED) and (not self.confirmation_notification_sent_at):
from apps.events.tasks import send_registration_confirmation_notification
send_registration_confirmation_notification.delay(str(self.pk))
self.confirmation_notification_sent_at = timezone.now()
super().save(update_fields=["confirmation_notification_sent_at"])

View File

@@ -1,306 +1,52 @@
"""Automatic event registration notifications."""
import logging
from celery import group, shared_task
from django.conf import settings
from django.db.models import Q
from celery import shared_task
from apps.events.models import Event, EventEmailLog, Registration
from apps.events.models import Registration
from apps.notifications.services import notify_user
from apps.users.email_identity import normalize_email_identity
from apps.users.models import User
from apps.users.tasks import send_critical_sms
logger = logging.getLogger(__name__)
def _build_context(*parts):
values = [str(part) for part in parts if part not in (None, "")]
return "|".join(values) if values else None
def _build_email_context(*parts):
return _build_context(*parts)
def _event_url(event: Event) -> str:
root = getattr(settings, "FRONTEND_ROOT", "/")
slug_or_id = getattr(event, "slug", None) or event.id
return f"{root}events/{slug_or_id}"
def _send_html_email(subject: str, html_body: str, to_email: str):
normalized_email = normalize_email_identity(to_email)
if not normalized_email:
return False
user = User.objects.filter(email=normalized_email).first()
if not user:
return False
notify_user(
user.id,
{
"type": "admin_message",
"title": subject,
"message": html_body[:500],
"level": "info",
},
)
return True
def _event_recipients(event, statuses=None):
qs = Registration.objects.filter(event=event, is_deleted=False).select_related("user", "event")
if statuses:
qs = qs.filter(status__in=statuses)
return qs
@shared_task(bind=True, max_retries=3)
def send_registration_confirmation_email(self, registration_pk: str):
def send_registration_confirmation_notification(self, registration_pk: str):
try:
reg = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(
reg.user_id,
{
"type": "event_registration",
"title": f"ثبت‌نام شما در {reg.event.title}",
"message": "ثبت‌نام شما تایید شد.",
"level": "success",
"action_url": f"/events/{reg.event.slug}",
"entity_type": "event",
"entity_id": reg.event_id,
"meta": {"ticket_id": str(reg.ticket_id)},
},
)
logger.info("Registration confirmation notification sent to user=%s event=%s", reg.user_id, reg.event_id)
registration = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(registration.user_id, {
"type": "event_registration",
"title": f"ثبت‌نام شما برای «{registration.event.title}» تأیید شد",
"message": "ثبت‌نام شما در این رویداد تأیید شده است.",
"level": "success",
"action_url": f"/events/{registration.event.slug}",
"entity_type": "event",
"entity_id": registration.event_id,
"meta": {"ticket_id": str(registration.ticket_id)},
})
return {"sent": True}
except Exception as exc:
logger.error("Failed to send registration confirmation notification: %s", exc)
logger.error("Failed to notify registration confirmation: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_registration_cancellation_email(self, registration_pk: str):
def send_registration_cancellation_notification(self, registration_pk: str):
try:
reg = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(
reg.user_id,
{
"type": "event_cancellation",
"title": f"لغو ثبت‌نام در {reg.event.title}",
"message": "ثبت‌نام شما در این رویداد لغو شد.",
"level": "warning",
"action_url": f"/events/{reg.event.slug}",
"entity_type": "event",
"entity_id": reg.event_id,
},
)
if reg.user.mobile and reg.user.is_mobile_verified:
send_critical_sms.delay(reg.user.mobile, "event_cancellation", reg.event.title)
logger.info("Registration cancellation delivered to user=%s event=%s", reg.user_id, reg.event_id)
registration = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(registration.user_id, {
"type": "event_cancellation",
"title": f"ثبت‌نام شما برای «{registration.event.title}» لغو شد",
"message": "ثبت‌نام شما در این رویداد لغو شد.",
"level": "warning",
"action_url": f"/events/{registration.event.slug}",
"entity_type": "event",
"entity_id": registration.event_id,
})
if registration.user.mobile and registration.user.is_mobile_verified:
send_critical_sms.delay(registration.user.mobile, "event_cancellation", registration.event.title)
return {"sent": True}
except Exception as exc:
logger.error("Failed to send registration cancellation notification: %s", exc)
logger.error("Failed to notify registration cancellation: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True)
def send_event_reminder_task(self, event_id: int):
event = Event.objects.get(pk=event_id)
regs = _event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
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 reminders 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=30, time_limit=45)
def send_event_reminder_to_user(self, event_id: int, registration_id: int):
log = None
try:
registration = Registration.objects.select_related("user", "event").get(pk=registration_id)
event = registration.event
user = registration.user
context_key = _build_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}
notify_user(
user.id,
{
"type": "event_reminder",
"title": f"یادآوری رویداد: {event.title}",
"message": "رویداد شما به‌زودی آغاز می‌شود.",
"level": "info",
"action_url": f"/events/{event.slug}",
"entity_type": "event",
"entity_id": event.id,
},
)
log.mark_sent()
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
raise
@shared_task(bind=True)
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
event = Event.objects.get(pk=event_id)
statuses = statuses or [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, Registration.StatusChoices.PENDING]
regs = _event_recipients(event, statuses=statuses).distinct()
reg_ids = list(regs.values_list("id", flat=True))
job = group(send_event_announcement_to_user.s(event_id, rid, subject, body_html) for rid in reg_ids)
res = job.apply_async()
logger.info('Queued %s event announcements 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=30, time_limit=45)
def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str):
log = None
try:
registration = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = registration.user
event = registration.event
context_key = _build_context("event_announcement", 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}
notify_user(
user.id,
{
"type": "event_announcement",
"title": subject,
"message": body_html[:500],
"level": "info",
"action_url": f"/events/{event.slug}",
"entity_type": "event",
"entity_id": event.id,
},
)
log.mark_sent()
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
raise
@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_active:
qs = qs.filter(is_active=True)
if only_verified:
qs = qs.filter(Q(mobile__isnull=False) | Q(email__isnull=False))
qs = qs.exclude(event_registrations__event_id=event_id).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_key = _build_context("invite_non_registered", event.slug or event.id, user.id)
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:
notify_user(
user.id,
{
"type": "event_invitation",
"title": f"دعوت به رویداد {event.title}",
"message": "برای مشاهده جزئیات و ثبت‌نام وارد صفحه رویداد شوید.",
"level": "info",
"action_url": f"/events/{event.slug}",
"entity_type": "event",
"entity_id": event.id,
},
)
log.mark_sent()
return {"sent": True}
except Exception as exc:
log.mark_failed(str(exc))
raise
@shared_task(bind=True)
def queue_skyroom_credentials(self, event_id: int):
event = Event.objects.get(pk=event_id)
regs = _event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED]).distinct()
reg_ids = list(regs.values_list("id", flat=True))
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
res = job.apply_async()
logger.info('Queued %s Skyroom credential notifications 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)
def send_skyroom_credentials_individual_task(self, reg_id: int):
registration = Registration.objects.select_related("event", "user").get(pk=reg_id)
return send_skyroom_credentials_to_user.delay(registration.event_id, registration.id)
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, soft_time_limit=30, time_limit=45)
def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
log = None
try:
registration = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = registration.user
event = registration.event
sky_username = (user.email or user.username or user.mobile or "").split("@")[0]
sky_password = str(registration.ticket_id or "")[:8]
context_key = _build_context("skyroom_credentials", event.slug or event.id, sky_username, sky_password, event.online_link)
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}
notify_user(
user.id,
{
"type": "skyroom_credentials",
"title": f"اطلاعات دسترسی رویداد {event.title}",
"message": f"نام کاربری: {sky_username} | رمز عبور: {sky_password}",
"level": "info",
"action_url": _event_url(event),
"entity_type": "event",
"entity_id": event.id,
"meta": {
"online_link": event.online_link,
"username": sky_username,
"password": sky_password,
},
},
)
log.mark_sent()
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
raise

File diff suppressed because it is too large Load Diff