feat(backend): migrate auth and notifications off email
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-21 10:28:04 +03:30
parent b4903f7cb1
commit b7b21a6cc6
35 changed files with 2784 additions and 1390 deletions

View File

@@ -1,233 +1,127 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
from celery import shared_task, group
from celery.exceptions import SoftTimeLimitExceeded
import markdown
import logging
from apps.users.models import User
from apps.events.models import Event, Registration, EventEmailLog
from core.templatetags.jalali import fa_digits, jdate
from celery import group, shared_task
from django.conf import settings
from django.db.models import Q
from apps.events.models import Event, EventEmailLog, 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__)
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
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):
"""Send a registration confirmation email, loading the model lazily to avoid circular imports."""
try:
from .models import Registration
reg = (
Registration.objects
.select_related("event", "user")
.get(pk=registration_pk)
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)},
},
)
user_email = getattr(reg.user, "email", None)
if not user_email:
return
success_md = reg.event.registration_success_markdown or ""
success_html = markdown.markdown(
success_md,
extensions=["extra", "sane_lists", "toc"]
) if success_md else ""
context = {
"user": reg.user,
"event": reg.event,
"registration": reg,
"success_html": success_html,
}
subject = f"تأیید ثبت‌نام شما در {reg.event.title}"
html_body = render_to_string("emails/event_registration_confirmation.html", context)
plain_body = strip_tags(html_body)
message = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user_email],
)
message.attach_alternative(html_body, "text/html")
message.send(fail_silently=False)
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
logger.info("Registration confirmation notification sent to user=%s event=%s", reg.user_id, reg.event_id)
return {"sent": True}
except Exception as exc:
logger.error(f"Failed to send event registration email: {exc}")
raise self.retry(exc=exc, countdown=60)
logger.error("Failed to send registration confirmation notification: %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):
try:
from .models import Registration
reg = (
Registration.objects
.select_related("event", "user")
.get(pk=registration_pk)
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,
},
)
user_email = getattr(reg.user, "email", None)
if not user_email:
return
context = {
"user": reg.user,
"event": reg.event,
"registration": reg,
}
subject = f"لغو ثبت‌نام شما در {reg.event.title}"
html_body = render_to_string("emails/event_registration_cancellation.html", context)
plain_body = strip_tags(html_body)
message = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user_email],
)
message.attach_alternative(html_body, "text/html")
message.send(fail_silently=False)
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
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)
return {"sent": True}
except Exception as exc:
logger.error(f"Failed to send event registration email: {exc}")
raise self.retry(exc=exc, countdown=60)
def _event_recipients(event, statuses=None, only_verified=True):
qs = Registration.objects.filter(event=event, is_deleted=False)
if statuses:
qs = qs.filter(status__in=statuses)
if only_verified:
qs = qs.filter(user__is_email_verified=True)
qs = qs.exclude(user__email__isnull=True).exclude(user__email="")
return qs.select_related("user")
def _send_html_email(subject, html_body, to_email):
text_body = strip_tags(html_body)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[to_email],
)
msg.attach_alternative(html_body, "text/html")
msg.send()
def _build_email_context(*parts):
values = [str(part) for part in parts if part not in (None, "")]
return "|".join(values) if values else None
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60)
def send_skyroom_credentials_individual_task(self, reg_id: int):
"""
ارسال نام‌کاربری/رمز برای اسکای‌روم
- username = user.email
- password = registration.ticket_id[:8]
- url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد)
"""
r = Registration.objects.get(pk=reg_id)
event = r.event
user = r.user
sky_user = user.email.strip().split('@')[0]
sky_pass = str(r.ticket_id)[:8]
skyroom_url = event.online_link
try:
ctx = {
"user": user,
"event": event,
"skyroom_url": skyroom_url,
"sky_username": sky_user,
"sky_password": sky_pass,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}"
html = render_to_string("emails/skyroom_credentials.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
)
msg.attach_alternative(html, "text/html")
msg.send()
logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}')
except Exception as exc:
logger.error(f"Failed to send skyroom credentials email: {exc}")
logger.error("Failed to send registration cancellation notification: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True)
def send_event_reminder_task(self, event_id: int):
"""
یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید)
"""
event = Event.objects.get(pk=event_id)
regs = (
_event_recipients(event, statuses=["confirmed", "attended"])
.select_related("user", "event")
.distinct()
)
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 reminder emails for event "%s" (group_id=%s)',
len(reg_ids),
event.title,
res.id,
)
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=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
@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):
"""
Send reminder email to a single registration; safe to retry without duplicating emails.
"""
user = None
log = None
try:
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
to_email = (user.email or "").strip()
if not to_email:
return {"skipped": True, "status": "no_email"}
context_key = _build_email_context(
"event_reminder",
event.slug or event.id,
event.start_time,
)
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,
@@ -236,111 +130,46 @@ def send_event_reminder_to_user(self, event_id: int, registration_id: int):
)
if skip:
return {"skipped": True, "status": log.status}
ctx = {
"user": user,
"event": event,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"یادآوری رویداد: {event.title}"
html = render_to_string("emails/event_reminder.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[to_email],
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,
},
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Event reminder for "%s" sent to %s', event.title, to_email)
return f"Email sent to {to_email}"
except SoftTimeLimitExceeded:
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning(
"Soft time limit exceeded (event_id=%s, registration_id=%s)",
event_id,
registration_id,
)
raise
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error(
"Failed to send event reminder email: %s", exc, exc_info=True
)
raise
@shared_task(bind=True)
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
"""
تسک مادر: ثبت‌نام‌های هدف را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
# محدوده مخاطبان: اگر statuses داده نشد، همان پیش‌فرض قبلی شما
statuses = statuses or ["confirmed", "attended", "pending"]
regs = (
_event_recipients(event, statuses=statuses)
.select_related("user", "event")
.exclude(user__email__isnull=True)
.exclude(user__email="")
.distinct()
)
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))
# ساخت group از تسک‌های کوچک؛ هر کدام فقط یک ایمیل ارسال می‌کند
job = group(
send_event_announcement_to_user.s(event_id, rid, subject, body_html)
for rid in reg_ids
)
# اگر نتیجه‌ها لازم نیست: CELERY_TASK_IGNORE_RESULT = 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-announcement emails for event "%s" (group_id=%s)',
len(reg_ids), event.title, res.id
)
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=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
@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):
"""
تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration.
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
"""
user = None
log = None
try:
# از Registration می‌گیریم تا یک کوئری کمتر به Event بزنیم
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
context_key = _build_email_context(
"event_announcement3",
event.slug or event.id,
subject,
body_html,
)
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,
@@ -349,99 +178,46 @@ def send_event_announcement_to_user(self, event_id: int, registration_id: int, s
)
if skip:
return {"skipped": True, "status": log.status}
# کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت می‌شود
ctx = {
"user": user,
"event": event,
"body_html": body_html,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
html = render_to_string("emails/event_announcement.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
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,
},
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Event announcement for "%s" sent to %s', event.title, user.email)
return f"Email sent to {user.email}"
except SoftTimeLimitExceeded:
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id)
raise
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error("Failed to send event announcement email: %s", exc, exc_info=True)
raise
def _event_url(event):
root = getattr(settings, "FRONTEND_ROOT", "/")
slug_or_id = getattr(event, "slug", None) or event.id
return f"{root}events/{slug_or_id}"
@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_verified:
qs = qs.filter(is_email_verified=True)
if only_active:
qs = qs.filter(is_active=True)
# کسانی که برای این ایونت ثبت‌نام نکرده‌اند
qs = qs.exclude(event_registrations__event_id=event_id) \
.exclude(email__isnull=True).exclude(email="") \
.distinct()
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 = {
"user": user,
"event": event,
"event_url": _event_url(event),
"start_time": fa_digits(jdate(event.start_time))
}
# ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip
subject = f"دعوت به شرکت در «{event.title}»"
text_body = render_to_string("emails/event_invite_non_registered.txt", context)
html_body = render_to_string("emails/event_invite_non_registered.html", context)
context_key = _build_email_context(
"invite_non_registered",
event.slug or event.id,
html_body,
)
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,
@@ -450,19 +226,21 @@ def send_invite_to_user(self, event_id: int, user_id: int):
)
if skip:
return {"skipped": True, "status": log.status}
try:
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
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,
},
)
msg.attach_alternative(html_body, "text/html")
msg.send()
log.mark_sent()
return f"Email sent to {user.email}"
return {"sent": True}
except Exception as exc:
log.mark_failed(str(exc))
raise
@@ -470,68 +248,31 @@ def send_invite_to_user(self, event_id: int, user_id: int):
@shared_task(bind=True)
def queue_skyroom_credentials(self, event_id: int):
"""
تسک مادر: ثبت‌نام‌های تاییدشده را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
# فقط CONFIRMED ها + ایمیل معتبر
regs = (
_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])
.select_related("user", "event")
.exclude(user__email__isnull=True)
.exclude(user__email="")
.distinct()
)
regs = _event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED]).distinct()
reg_ids = list(regs.values_list("id", flat=True))
# ساخت group از تسک‌های کوچک؛ هر کدوم فقط یک ایمیل ارسال می‌کنند
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
# توصیه: اگر نتیجه‌ها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید
res = job.apply_async()
logger.info(
'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)',
len(reg_ids), event.title, res.id
)
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,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
@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):
"""
تسک کوچک و اتمی: ارسال نام‌کاربری/رمز اسکای‌روم برای یک Registration.
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
"""
user = None
log = None
try:
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
# ساخت یوزرنیم/پسورد
sky_username = (user.email or "").strip().split("@")[0]
sky_password = str(r.ticket_id or "")[:8]
skyroom_url = event.online_link
context_key = _build_email_context(
"skyroom_credentials",
event.slug or event.id,
sky_username,
sky_password,
skyroom_url,
)
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,
@@ -540,45 +281,26 @@ def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
)
if skip:
return {"skipped": True, "status": log.status}
ctx = {
"user": user,
"event": event,
"skyroom_url": skyroom_url,
"sky_username": sky_username,
"sky_password": sky_password,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}"
html = render_to_string("emails/skyroom_credentials.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
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,
},
},
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email)
return f"Email sent to {user.email}"
except SoftTimeLimitExceeded as exc:
# ثبت خطا و اجازه به Celery برای retry خودکار
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning(
"Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id
)
raise
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True)
raise