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]