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:
@@ -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.')
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
@@ -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",
|
||||
|
||||
@@ -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',
|
||||
),
|
||||
]
|
||||
@@ -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"])
|
||||
|
||||
@@ -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
Reference in New Issue
Block a user