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