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,34 +1,58 @@
"""Schemas for communications-related endpoints."""
from datetime import datetime
from typing import Optional, List
from typing import Optional
from ninja import Schema, ModelSchema
from ninja import ModelSchema, Schema
from apps.blog.api.schemas import AuthorSchema
from apps.communications.models import (
Announcement,
NewsletterSubscription,
PushNotificationDevice
)
from apps.communications.models import Announcement, PushNotificationDevice
class AnnouncementSchema(ModelSchema):
author: AuthorSchema
content_html: str
deliver_in_app: bool
deliver_sms: bool
in_app_sent: bool
sms_sent: bool
class Config:
model = Announcement
model_fields = [
'id', 'title', 'content', 'announcement_type', 'priority',
'is_published', 'publish_date', 'send_email', 'send_push',
'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at'
"id",
"title",
"content",
"announcement_type",
"priority",
"is_published",
"publish_date",
"target_audience",
"created_at",
"updated_at",
]
@staticmethod
def resolve_content_html(obj):
return obj.content_html
@staticmethod
def resolve_deliver_in_app(obj):
return obj.send_email
@staticmethod
def resolve_deliver_sms(obj):
return obj.send_push
@staticmethod
def resolve_in_app_sent(obj):
return obj.email_sent
@staticmethod
def resolve_sms_sent(obj):
return obj.push_sent
class AnnouncementListSchema(Schema):
id: int
title: str
@@ -39,8 +63,11 @@ class AnnouncementListSchema(Schema):
is_published: bool
publish_date: Optional[datetime] = None
target_audience: str
deliver_in_app: bool
deliver_sms: bool
created_at: datetime
class AnnouncementCreateSchema(Schema):
title: str
content: str
@@ -49,8 +76,9 @@ class AnnouncementCreateSchema(Schema):
target_audience: str = "all"
is_published: bool = False
publish_date: Optional[datetime] = None
send_email: bool = False
send_push: bool = False
deliver_in_app: bool = True
deliver_sms: bool = False
class AnnouncementUpdateSchema(Schema):
title: Optional[str] = None
@@ -60,65 +88,43 @@ class AnnouncementUpdateSchema(Schema):
target_audience: Optional[str] = None
is_published: Optional[bool] = None
publish_date: Optional[datetime] = None
send_email: Optional[bool] = None
send_push: Optional[bool] = None
deliver_in_app: Optional[bool] = None
deliver_sms: Optional[bool] = None
class NewsletterSubscriptionSchema(ModelSchema):
user: Optional[AuthorSchema] = None
class Config:
model = NewsletterSubscription
model_fields = [
'id', 'email', 'is_active', 'subscribed_categories',
'confirmed_at', 'created_at'
]
class NewsletterSubscribeSchema(Schema):
email: str
subscribed_categories: Optional[List[str]] = []
class NewsletterUnsubscribeSchema(Schema):
email: str
class PushDeviceSchema(ModelSchema):
user: AuthorSchema
class Config:
model = PushNotificationDevice
model_fields = [
'id', 'device_token', 'device_type', 'is_active', 'created_at'
]
model_fields = ["id", "device_token", "device_type", "is_active", "created_at"]
class PushDeviceCreateSchema(Schema):
device_token: str
device_type: str = "web"
class PushDeviceUpdateSchema(Schema):
is_active: bool
class PushNotificationSchema(Schema):
title: str
body: str
data: Optional[dict] = None
target_audience: str = "all"
class MessageResponseSchema(Schema):
"""Simple message payload for API responses."""
message: str
success: bool = True
class AnnouncementStatsSchema(Schema):
"""Summary statistics for announcements."""
total_announcements: int
published_announcements: int
draft_announcements: int
urgent_announcements: int
email_sent_count: int
push_sent_count: int
class NewsletterStatsSchema(Schema):
"""Summary statistics for newsletter subscriptions."""
total_subscriptions: int
active_subscriptions: int
confirmed_subscriptions: int
recent_subscriptions: int
in_app_sent_count: int
sms_sent_count: int

View File

@@ -1,275 +1,191 @@
from django.shortcuts import get_object_or_404
from typing import List
from django.contrib.auth import get_user_model
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.db.models import Q, Count
from ninja import Router
from ninja.pagination import paginate
from typing import List
import logging
from apps.communications.models import (
Announcement, NewsletterSubscription, PushNotificationDevice,
AnnouncementType, AnnouncementPriority
)
from apps.communications.utils import (
send_announcement_email, send_newsletter_confirmation,
get_announcement_recipients
)
from apps.communications.push_notifications import push_service
from apps.communications.api.schemas import (
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
PushNotificationSchema, MessageResponseSchema,
AnnouncementStatsSchema, NewsletterStatsSchema
AnnouncementCreateSchema,
AnnouncementListSchema,
AnnouncementSchema,
AnnouncementStatsSchema,
MessageResponseSchema,
PushDeviceCreateSchema,
PushDeviceSchema,
PushDeviceUpdateSchema,
PushNotificationSchema,
)
from apps.communications.models import Announcement, AnnouncementPriority, AnnouncementType, PushNotificationDevice
from apps.communications.push_notifications import push_service
from apps.communications.tasks import send_announcement_notifications
from core.authentication import jwt_auth
User = get_user_model()
logger = logging.getLogger(__name__)
communications_router = Router()
# Announcement endpoints
def _is_staff_user(user) -> bool:
return bool(user and (user.is_staff or user.is_superuser))
@communications_router.get("/announcements/", response=List[AnnouncementListSchema])
@paginate
def list_announcements(request, published_only: bool = True):
"""List announcements"""
queryset = Announcement.objects.select_related('author').filter(is_deleted=False)
queryset = Announcement.objects.select_related("author").filter(is_deleted=False)
if published_only:
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now())
return queryset.order_by('-created_at')
items = []
for announcement in queryset.order_by("-created_at"):
items.append(
{
"id": announcement.id,
"title": announcement.title,
"content": announcement.content,
"announcement_type": announcement.announcement_type,
"priority": announcement.priority,
"author": announcement.author,
"is_published": announcement.is_published,
"publish_date": announcement.publish_date,
"target_audience": announcement.target_audience,
"deliver_in_app": announcement.send_email,
"deliver_sms": announcement.send_push,
"created_at": announcement.created_at,
}
)
return items
@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema)
def get_announcement(request, announcement_id: int):
"""Get single announcement"""
announcement = get_object_or_404(
Announcement.objects.select_related('author').filter(is_deleted=False),
id=announcement_id
Announcement.objects.select_related("author").filter(is_deleted=False),
id=announcement_id,
)
# Check if published or user has permission
if not announcement.is_published:
# Only allow access to unpublished announcements for staff/committee
if not hasattr(request, 'auth') or not request.auth:
return {"error": "Announcement not found"}, 404
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Announcement not found"}, 404
if not announcement.is_published and not _is_staff_user(getattr(request, "auth", None)):
return {"error": "Announcement not found"}, 404
return announcement
@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth)
def create_announcement(request, payload: AnnouncementCreateSchema):
"""Create new announcement (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
announcement = Announcement.objects.create(
author=user,
**payload.dict()
title=payload.title,
content=payload.content,
announcement_type=payload.announcement_type,
priority=payload.priority,
target_audience=payload.target_audience,
is_published=payload.is_published,
publish_date=payload.publish_date,
send_email=payload.deliver_in_app,
send_push=payload.deliver_sms,
)
# Send notifications if requested and published
if announcement.is_published and announcement.publish_date <= timezone.now():
if announcement.send_email:
recipients = get_announcement_recipients(announcement)
if recipients:
send_announcement_email(announcement, recipients)
announcement.email_sent = True
if announcement.send_push:
push_service.send_announcement_notification(announcement)
announcement.push_sent = True
announcement.save()
if announcement.is_published and (announcement.publish_date is None or announcement.publish_date <= timezone.now()):
send_announcement_notifications.delay(announcement.id)
return announcement
@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth)
def update_announcement(request, announcement_id: int, payload: AnnouncementUpdateSchema):
"""Update announcement (author/committee/staff only)"""
def update_announcement(request, announcement_id: int, payload: AnnouncementCreateSchema):
user = request.auth
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
# Check permissions
if not (user.is_staff or user.is_committee or announcement.author == user):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
# Update fields
for field, value in payload.dict(exclude_unset=True).items():
setattr(announcement, field, value)
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
announcement.title = payload.title
announcement.content = payload.content
announcement.announcement_type = payload.announcement_type
announcement.priority = payload.priority
announcement.target_audience = payload.target_audience
announcement.is_published = payload.is_published
announcement.publish_date = payload.publish_date
announcement.send_email = payload.deliver_in_app
announcement.send_push = payload.deliver_sms
announcement.email_sent = False
announcement.push_sent = False
announcement.save()
# Send notifications if newly published
if (announcement.is_published and announcement.publish_date <= timezone.now() and
not announcement.email_sent and announcement.send_email):
recipients = get_announcement_recipients(announcement)
if recipients:
send_announcement_email(announcement, recipients)
announcement.email_sent = True
announcement.save()
if (announcement.is_published and announcement.publish_date <= timezone.now() and
not announcement.push_sent and announcement.send_push):
push_service.send_announcement_notification(announcement)
announcement.push_sent = True
announcement.save()
if announcement.is_published and (announcement.publish_date is None or announcement.publish_date <= timezone.now()):
send_announcement_notifications.delay(announcement.id)
return announcement
@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth)
def delete_announcement(request, announcement_id: int):
"""Delete announcement (author/committee/staff only)"""
user = request.auth
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
# Check permissions
if not (user.is_staff or user.is_committee or announcement.author == user):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
announcement.soft_delete()
return {"message": "Announcement deleted successfully"}
@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth)
def get_announcement_stats(request):
"""Get announcement statistics (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
stats = Announcement.objects.filter(is_deleted=False).aggregate(
total_announcements=Count('id'),
published_announcements=Count('id', filter=Q(is_published=True)),
draft_announcements=Count('id', filter=Q(is_published=False)),
urgent_announcements=Count('id', filter=Q(priority='urgent')),
email_sent_count=Count('id', filter=Q(email_sent=True)),
push_sent_count=Count('id', filter=Q(push_sent=True))
total_announcements=Count("id"),
published_announcements=Count("id", filter=Q(is_published=True)),
draft_announcements=Count("id", filter=Q(is_published=False)),
urgent_announcements=Count("id", filter=Q(priority="urgent")),
in_app_sent_count=Count("id", filter=Q(email_sent=True)),
sms_sent_count=Count("id", filter=Q(push_sent=True)),
)
return stats
# Newsletter endpoints
@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema)
def subscribe_newsletter(request, payload: NewsletterSubscribeSchema):
"""Subscribe to newsletter"""
try:
subscription, created = NewsletterSubscription.objects.get_or_create(
email=payload.email,
defaults={
'subscribed_categories': payload.subscribed_categories,
'is_active': True
}
)
if not created and not subscription.is_active:
subscription.is_active = True
subscription.subscribed_categories = payload.subscribed_categories
subscription.save()
# Send confirmation email
send_newsletter_confirmation(subscription)
message = (
"عضویت در خبرنامه با موفقیت انجام شد! لطفاً برای تأیید، ایمیل خود را بررسی کنید."
if created
else "اشتراک خبرنامه به‌روزرسانی شد!"
)
return {"message": message}
except Exception as e:
logger.error(f"Newsletter subscription failed: {str(e)}")
return {"message": "Subscription failed", "success": False}, 400
def subscribe_newsletter(request):
return {
"message": "خبرنامه ایمیلی حذف شده است. اطلاع‌رسانی‌ها از این پس درون‌سایتی و در موارد مهم از طریق پیامک انجام می‌شود."
}
@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema)
def unsubscribe_newsletter(request, payload: NewsletterUnsubscribeSchema):
"""Unsubscribe from newsletter"""
try:
subscription = NewsletterSubscription.objects.get(email=payload.email)
subscription.is_active = False
subscription.save()
return {"message": "Successfully unsubscribed from newsletter"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Email not found in subscription list"}, 404
def unsubscribe_newsletter(request):
return {"message": "خبرنامه ایمیلی دیگر فعال نیست."}
@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema)
def confirm_newsletter_subscription(request, token: str):
"""Confirm newsletter subscription"""
try:
subscription = NewsletterSubscription.objects.get(confirmation_token=token)
subscription.confirmed_at = timezone.now()
subscription.is_active = True
subscription.save()
return {"message": "Newsletter subscription confirmed successfully!"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Invalid confirmation token"}, 400
return {"message": "تایید خبرنامه ایمیلی دیگر لازم نیست."}
@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema)
def unsubscribe_newsletter_token(request, token: str):
"""Unsubscribe using token from email"""
try:
subscription = NewsletterSubscription.objects.get(unsubscribe_token=token)
subscription.is_active = False
subscription.save()
return {"message": "Successfully unsubscribed from newsletter"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Invalid unsubscribe token"}, 400
return {"message": "خبرنامه ایمیلی دیگر فعال نیست."}
@communications_router.get("/newsletter/subscriptions/", response=List[NewsletterSubscriptionSchema], auth=jwt_auth)
@paginate
def list_newsletter_subscriptions(request):
"""List newsletter subscriptions (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
return NewsletterSubscription.objects.select_related('user').filter(is_deleted=False).order_by('-created_at')
@communications_router.get("/newsletter/stats/", response=NewsletterStatsSchema, auth=jwt_auth)
def get_newsletter_stats(request):
"""Get newsletter statistics (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
stats = NewsletterSubscription.objects.filter(is_deleted=False).aggregate(
total_subscriptions=Count('id'),
active_subscriptions=Count('id', filter=Q(is_active=True)),
confirmed_subscriptions=Count('id', filter=Q(confirmed_at__isnull=False)),
recent_subscriptions=Count('id', filter=Q(created_at__gte=timezone.now() - timezone.timedelta(days=30)))
)
return stats
# Push notification endpoints
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
def register_push_device(request, payload: PushDeviceCreateSchema):
"""Register push notification device"""
user = request.auth
device, created = PushNotificationDevice.objects.get_or_create(
user=user,
device_token=payload.device_token,
defaults={'device_type': payload.device_type, 'is_active': True}
defaults={"device_type": payload.device_type, "is_active": True},
)
if not created:
device.is_active = True
device.device_type = payload.device_type
device.save()
return device
@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth)
def unregister_push_device(request, device_token: str):
"""Unregister push notification device"""
user = request.auth
try:
device = PushNotificationDevice.objects.get(user=user, device_token=device_token)
device.delete()
@@ -277,53 +193,37 @@ def unregister_push_device(request, device_token: str):
except PushNotificationDevice.DoesNotExist:
return {"message": "Device not found"}, 404
@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth)
def list_user_push_devices(request):
"""List user's push notification devices"""
user = request.auth
return PushNotificationDevice.objects.filter(user=user, is_deleted=False).order_by('-created_at')
return PushNotificationDevice.objects.filter(user=request.auth, is_deleted=False).order_by("-created_at")
@communications_router.put("/push-devices/{device_id}/", response=PushDeviceSchema, auth=jwt_auth)
def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema):
"""Update push notification device"""
user = request.auth
device = get_object_or_404(PushNotificationDevice, id=device_id, user=user, is_deleted=False)
device = get_object_or_404(PushNotificationDevice, id=device_id, user=request.auth, is_deleted=False)
device.is_active = payload.is_active
device.save()
return device
@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth)
def send_push_notification(request, payload: PushNotificationSchema):
"""Send push notification (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
# Get target users
users = []
if payload.target_audience == 'all':
users = User.objects.filter(is_active=True)
elif payload.target_audience == 'members':
users = User.objects.filter(is_member=True, is_active=True)
elif payload.target_audience == 'committee':
users = User.objects.filter(is_committee=True, is_active=True)
# Send notifications
total_sent = push_service.send_to_multiple_users(
users, payload.title, payload.body, payload.data
)
users = User.objects.filter(is_active=True)
if payload.target_audience == "committee":
users = users.filter(is_staff=True)
total_sent = push_service.send_to_multiple_users(users, payload.title, payload.body, payload.data)
return {"message": f"Push notification sent to {total_sent} devices"}
# Utility endpoints
@communications_router.get("/announcement-types/", response=List[dict])
def get_announcement_types(request):
"""Get available announcement types"""
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementType.choices]
@communications_router.get("/announcement-priorities/", response=List[dict])
def get_announcement_priorities(request):
"""Get available announcement priorities"""
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementPriority.choices]

View File

@@ -1,278 +1,174 @@
from django.utils import timezone
from django.contrib.auth import get_user_model
import logging
from celery import shared_task
from datetime import timedelta
from celery import shared_task
from django.contrib.auth import get_user_model
from django.utils import timezone
import logging
from apps.communications.models import Announcement
from apps.events.models import Event, Registration
from apps.communications.models import Announcement, NewsletterSubscription
from apps.communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
from apps.communications.push_notifications import push_service
from apps.notifications.services import notify_user
from apps.users.tasks import send_critical_sms
User = get_user_model()
logger = logging.getLogger(__name__)
SYSTEM_USER_ID = 1
def _audience_queryset(target_audience: str):
qs = User.objects.filter(is_active=True, is_deleted=False)
if target_audience == "committee":
return qs.filter(is_staff=True)
if target_audience == "members":
return qs
return qs
def _dispatch_announcement(announcement: Announcement) -> tuple[int, int]:
in_app_count = 0
sms_count = 0
users = _audience_queryset(announcement.target_audience)
action_url = "/announcements"
for user in users.iterator():
if announcement.send_email:
notify_user(
user.id,
{
"type": "announcement",
"title": announcement.title,
"message": announcement.content[:500],
"level": "warning" if announcement.priority == "urgent" else "info",
"action_url": action_url,
"entity_type": "announcement",
"entity_id": announcement.id,
},
)
in_app_count += 1
if announcement.send_push and user.mobile and user.is_mobile_verified:
send_critical_sms.delay(user.mobile, "event_reschedule", announcement.title)
sms_count += 1
if announcement.send_email:
announcement.email_sent = True
if announcement.send_push:
announcement.push_sent = True
announcement.save(update_fields=["email_sent", "push_sent"])
return in_app_count, sms_count
@shared_task(bind=True, max_retries=3)
def send_announcement_notifications(self, announcement_id):
"""Send email and push notifications for an announcement"""
try:
announcement = Announcement.objects.get(id=announcement_id)
# Send email notifications
if announcement.send_email and not announcement.email_sent:
recipients = get_announcement_recipients(announcement)
if recipients:
success = send_announcement_email(announcement, recipients)
if success:
announcement.email_sent = True
announcement.save()
logger.info(f"Email notifications sent for announcement {announcement.id}")
# Send push notifications
if announcement.send_push and not announcement.push_sent:
sent_count = push_service.send_announcement_notification(announcement)
if sent_count > 0:
announcement.push_sent = True
announcement.save()
logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}")
return f"Notifications sent for announcement: {announcement.title}"
in_app_count, sms_count = _dispatch_announcement(announcement)
logger.info(
"Announcement %s dispatched in_app=%s sms=%s",
announcement.id,
in_app_count,
sms_count,
)
return f"Announcement dispatched: {announcement.title}"
except Announcement.DoesNotExist:
logger.error(f"Announcement {announcement_id} not found")
logger.error("Announcement %s not found", announcement_id)
return f"Announcement {announcement_id} not found"
except Exception as exc:
logger.error(f"Failed to send announcement notifications: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_newsletter_confirmation_task(self, subscription_id):
"""Send newsletter confirmation email"""
try:
from .utils import send_newsletter_confirmation
subscription = NewsletterSubscription.objects.get(id=subscription_id)
success = send_newsletter_confirmation(subscription)
if success:
logger.info(f"Newsletter confirmation sent to {subscription.email}")
return f"Newsletter confirmation sent to {subscription.email}"
else:
raise Exception("Failed to send newsletter confirmation")
except NewsletterSubscription.DoesNotExist:
logger.error(f"Newsletter subscription {subscription_id} not found")
return f"Newsletter subscription {subscription_id} not found"
except Exception as exc:
logger.error(f"Failed to send newsletter confirmation: {exc}")
logger.error("Failed to send announcement notifications: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task
def send_event_reminders():
"""Send reminders for events starting about 24 hours from now within a 30-minute window."""
try:
reminder_target = timezone.now() + timedelta(hours=24)
window = timedelta(minutes=30)
start_range = reminder_target - window
end_range = reminder_target + window
events = Event.objects.filter(
start_time__range=(start_range, end_range),
status='published',
is_deleted=False
start_time__range=(reminder_target - window, reminder_target + window),
status="published",
is_deleted=False,
)
total_sent = 0
for event in events:
# Get confirmed registrations
registrations = Registration.objects.filter(
event=event,
status='confirmed',
is_deleted=False
).select_related('user')
status=Registration.StatusChoices.CONFIRMED,
is_deleted=False,
).select_related("user")
for registration in registrations:
try:
# Send email reminder
send_event_reminder(event, registration.user)
# Send push notification reminder
push_service.send_event_reminder_notification(event, registration.user)
total_sent += 1
except Exception as e:
logger.error(f"Failed to send reminder to {registration.user.email}: {str(e)}")
logger.info(f"Event reminders sent to {total_sent} users")
notify_user(
registration.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,
},
)
total_sent += 1
logger.info("Event reminders sent to %s users", total_sent)
return f"Event reminders sent to {total_sent} users"
except Exception as exc:
logger.error(f"Failed to send event reminders: {exc}")
raise exc
logger.error("Failed to send event reminders: %s", exc)
raise
@shared_task
def send_weekly_newsletter():
"""Send the weekly newsletter as the system user with recent announcements and upcoming events."""
try:
# Get active newsletter subscribers
subscribers = NewsletterSubscription.objects.filter(
is_active=True,
confirmed_at__isnull=False,
is_deleted=False
)
if not subscribers.exists():
logger.info("No active newsletter subscribers found")
return "No active newsletter subscribers found"
# Get recent announcements (last 7 days)
week_ago = timezone.now() - timedelta(days=7)
recent_announcements = Announcement.objects.filter(
is_published=True,
publish_date__gte=week_ago,
announcement_type__in=['general', 'academic', 'newsletter'],
is_deleted=False
).order_by('-publish_date')[:5]
# Get upcoming events (next 14 days)
two_weeks_ahead = timezone.now() + timedelta(days=14)
upcoming_events = Event.objects.filter(
start_time__range=(timezone.now(), two_weeks_ahead),
status='published',
is_deleted=False
).order_by('start_time')[:5]
newsletter_content = f"""
# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}
## Recent Announcements
"""
for announcement in recent_announcements:
newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n"
newsletter_content += "\n## Upcoming Events\n"
for event in upcoming_events:
newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
if not recent_announcements.exists() and not upcoming_events.exists():
newsletter_content += "\nNo recent announcements or upcoming events this week."
newsletter = Announcement.objects.create(
title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}",
content=newsletter_content,
announcement_type='newsletter',
priority='normal',
author_id=SYSTEM_USER_ID,
is_published=True,
publish_date=timezone.now(),
send_email=True,
target_audience='subscribers'
)
# Send to subscribers
subscriber_emails = list(subscribers.values_list('email', flat=True))
success = send_announcement_email(newsletter, subscriber_emails)
if success:
newsletter.email_sent = True
newsletter.save()
logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers")
return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers"
else:
raise Exception("Failed to send weekly newsletter")
except Exception as exc:
logger.error(f"Failed to send weekly newsletter: {exc}")
raise exc
logger.info("Weekly newsletter task skipped because newsletter delivery has been removed.")
return "Newsletter delivery removed"
@shared_task
def cleanup_expired_tokens():
"""Clean up expired newsletter confirmation tokens"""
try:
# Remove unconfirmed subscriptions older than 7 days
week_ago = timezone.now() - timedelta(days=7)
expired_subscriptions = NewsletterSubscription.objects.filter(
confirmed_at__isnull=True,
created_at__lt=week_ago
)
count = expired_subscriptions.count()
expired_subscriptions.delete()
logger.info(f"Cleaned up {count} expired newsletter subscriptions")
return f"Cleaned up {count} expired newsletter subscriptions"
except Exception as exc:
logger.error(f"Failed to cleanup expired tokens: {exc}")
raise exc
logger.info("Newsletter cleanup skipped because newsletter delivery has been removed.")
return "Newsletter delivery removed"
@shared_task
def send_bulk_announcement(announcement_id, recipient_emails):
"""Send announcement to a specific list of recipients"""
try:
announcement = Announcement.objects.get(id=announcement_id)
# Split recipients into batches to avoid overwhelming the email server
batch_size = 50
total_sent = 0
for i in range(0, len(recipient_emails), batch_size):
batch = recipient_emails[i:i + batch_size]
success = send_announcement_email(announcement, batch)
if success:
total_sent += len(batch)
logger.info(f"Sent announcement to batch of {len(batch)} recipients")
# Small delay between batches
import time
time.sleep(1)
logger.info(f"Bulk announcement sent to {total_sent} recipients")
return f"Bulk announcement sent to {total_sent} recipients"
users = User.objects.filter(email__in=recipient_emails, is_active=True)
total = 0
for user in users.iterator():
notify_user(
user.id,
{
"type": "announcement",
"title": announcement.title,
"message": announcement.content[:500],
"level": "warning" if announcement.priority == "urgent" else "info",
"entity_type": "announcement",
"entity_id": announcement.id,
},
)
total += 1
return f"Bulk announcement sent to {total} users"
except Exception as exc:
logger.error(f"Failed to send bulk announcement: {exc}")
raise exc
logger.error("Failed to send bulk announcement: %s", exc)
raise
@shared_task
def process_scheduled_announcements():
"""Process announcements scheduled for publication"""
try:
now = timezone.now()
# Get announcements scheduled for publication
scheduled_announcements = Announcement.objects.filter(
is_published=True,
publish_date__lte=now,
email_sent=False,
send_email=True,
is_deleted=False
is_deleted=False,
)
processed_count = 0
for announcement in scheduled_announcements:
# Send notifications
send_announcement_notifications.delay(announcement.id)
processed_count += 1
logger.info(f"Processed {processed_count} scheduled announcements")
logger.info("Processed %s scheduled announcements", processed_count)
return f"Processed {processed_count} scheduled announcements"
except Exception as exc:
logger.error(f"Failed to process scheduled announcements: {exc}")
raise exc
logger.error("Failed to process scheduled announcements: %s", exc)
raise