initial commit
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-19 20:53:08 +03:30
commit 88b793ed9f
169 changed files with 16763 additions and 0 deletions

584
apps/events/tasks.py Normal file
View File

@@ -0,0 +1,584 @@
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
logger = logging.getLogger(__name__)
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
@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)
)
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}")
except Exception as exc:
logger.error(f"Failed to send event registration email: {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)
)
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}")
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}")
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()
)
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,
)
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,
)
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,
)
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}
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],
)
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
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()
)
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
res = job.apply_async()
logger.info(
'Queued %s event-announcement emails 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,
)
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,
)
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}
# کانتکست رندر ایمیل: 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],
)
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
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()
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,
)
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:
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
)
msg.attach_alternative(html_body, "text/html")
msg.send()
log.mark_sent()
return f"Email sent to {user.email}"
except Exception as exc:
log.mark_failed(str(exc))
raise
@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()
)
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
)
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,
)
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,
)
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}
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],
)
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
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