feat(backend): migrate auth and notifications off email
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user