feat: replace email flows with in-app notifications
Add recipient notification detail and filtering, a queued admin composer, and contextual automatic titles. Retire email delivery, obsolete auth routes, newsletter data, and related templates and settings.
This commit is contained in:
@@ -6,7 +6,7 @@ from simplemde.widgets import SimpleMDEEditor
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from core.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from apps.communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||
from apps.communications.models import Announcement, PushNotificationDevice
|
||||
|
||||
|
||||
class AnnouncementAdminForm(forms.ModelForm):
|
||||
@@ -25,25 +25,21 @@ class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
form = AnnouncementAdminForm
|
||||
list_display = [
|
||||
'title', 'announcement_type', 'priority', 'author',
|
||||
'is_published', 'publish_date', 'email_sent', 'push_sent', 'created_at'
|
||||
'is_published', 'publish_date', 'created_at'
|
||||
]
|
||||
list_filter = [
|
||||
'announcement_type', 'priority', 'is_published',
|
||||
'send_email', 'send_push', 'target_audience',
|
||||
SoftDeleteListFilter, 'created_at'
|
||||
]
|
||||
search_fields = ['title', 'content', 'author__username']
|
||||
readonly_fields = ['email_sent', 'push_sent', 'created_at', 'updated_at']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Content', {
|
||||
'fields': ('title', 'content', 'author')
|
||||
}),
|
||||
('Settings', {
|
||||
'fields': ('announcement_type', 'priority', 'target_audience', 'is_published', 'publish_date')
|
||||
}),
|
||||
('Notifications', {
|
||||
'fields': ('send_email', 'send_push', 'email_sent', 'push_sent')
|
||||
'fields': ('announcement_type', 'priority', 'is_published', 'publish_date')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
@@ -51,59 +47,13 @@ class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
}),
|
||||
)
|
||||
|
||||
actions = BaseModelAdmin.actions + ['publish_announcements', 'send_notifications']
|
||||
actions = BaseModelAdmin.actions + ['publish_announcements']
|
||||
|
||||
def publish_announcements(self, request, queryset):
|
||||
queryset.update(is_published=True, publish_date=timezone.now())
|
||||
self.message_user(request, f"{queryset.count()} announcements published.")
|
||||
publish_announcements.short_description = "Publish selected announcements"
|
||||
|
||||
def send_notifications(self, request, queryset):
|
||||
# This will be implemented with Celery tasks
|
||||
for announcement in queryset:
|
||||
if announcement.send_email and not announcement.email_sent:
|
||||
# Trigger email task
|
||||
pass
|
||||
if announcement.send_push and not announcement.push_sent:
|
||||
# Trigger push notification task
|
||||
pass
|
||||
self.message_user(request, f"Notifications queued for {queryset.count()} announcements.")
|
||||
send_notifications.short_description = "Send notifications for selected announcements"
|
||||
|
||||
|
||||
@admin.register(NewsletterSubscription)
|
||||
class NewsletterSubscriptionAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
list_display = ['email', 'user', 'is_active', 'confirmed_at', 'created_at']
|
||||
list_filter = ['is_active', SoftDeleteListFilter, 'created_at', 'confirmed_at']
|
||||
search_fields = ['email', 'user__username', 'user__email']
|
||||
readonly_fields = ['confirmation_token', 'unsubscribe_token', 'created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Subscription', {
|
||||
'fields': ('email', 'user', 'is_active', 'subscribed_categories')
|
||||
}),
|
||||
('Confirmation', {
|
||||
'fields': ('confirmed_at', 'confirmation_token', 'unsubscribe_token')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = BaseModelAdmin.actions + ['activate_subscriptions', 'deactivate_subscriptions']
|
||||
|
||||
def activate_subscriptions(self, request, queryset):
|
||||
queryset.update(is_active=True)
|
||||
self.message_user(request, f"{queryset.count()} subscriptions activated.")
|
||||
activate_subscriptions.short_description = "Activate selected subscriptions"
|
||||
|
||||
def deactivate_subscriptions(self, request, queryset):
|
||||
queryset.update(is_active=False)
|
||||
self.message_user(request, f"{queryset.count()} subscriptions deactivated.")
|
||||
deactivate_subscriptions.short_description = "Deactivate selected subscriptions"
|
||||
|
||||
|
||||
@admin.register(PushNotificationDevice)
|
||||
class PushNotificationDeviceAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
list_display = ['user', 'device_type', 'is_active', 'created_at']
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"""Schemas for communications-related endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional
|
||||
from typing import Literal, Optional
|
||||
|
||||
from ninja import ModelSchema, Schema
|
||||
|
||||
@@ -12,10 +12,6 @@ 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
|
||||
@@ -27,7 +23,6 @@ class AnnouncementSchema(ModelSchema):
|
||||
"priority",
|
||||
"is_published",
|
||||
"publish_date",
|
||||
"target_audience",
|
||||
"created_at",
|
||||
"updated_at",
|
||||
]
|
||||
@@ -36,23 +31,6 @@ class AnnouncementSchema(ModelSchema):
|
||||
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
|
||||
@@ -62,34 +40,25 @@ class AnnouncementListSchema(Schema):
|
||||
author: AuthorSchema
|
||||
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
|
||||
announcement_type: str = "general"
|
||||
priority: str = "normal"
|
||||
target_audience: str = "all"
|
||||
announcement_type: Literal["general", "event", "academic", "urgent"] = "general"
|
||||
priority: Literal["low", "normal", "high", "urgent"] = "normal"
|
||||
is_published: bool = False
|
||||
publish_date: Optional[datetime] = None
|
||||
deliver_in_app: bool = True
|
||||
deliver_sms: bool = False
|
||||
|
||||
|
||||
class AnnouncementUpdateSchema(Schema):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
announcement_type: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
target_audience: Optional[str] = None
|
||||
announcement_type: Optional[Literal["general", "event", "academic", "urgent"]] = None
|
||||
priority: Optional[Literal["low", "normal", "high", "urgent"]] = None
|
||||
is_published: Optional[bool] = None
|
||||
publish_date: Optional[datetime] = None
|
||||
deliver_in_app: Optional[bool] = None
|
||||
deliver_sms: Optional[bool] = None
|
||||
|
||||
|
||||
class PushDeviceSchema(ModelSchema):
|
||||
@@ -109,13 +78,6 @@ class PushDeviceUpdateSchema(Schema):
|
||||
is_active: bool
|
||||
|
||||
|
||||
class PushNotificationSchema(Schema):
|
||||
title: str
|
||||
body: str
|
||||
data: Optional[dict] = None
|
||||
target_audience: str = "all"
|
||||
|
||||
|
||||
class MessageResponseSchema(Schema):
|
||||
message: str
|
||||
success: bool = True
|
||||
@@ -126,5 +88,3 @@ class AnnouncementStatsSchema(Schema):
|
||||
published_announcements: int
|
||||
draft_announcements: int
|
||||
urgent_announcements: int
|
||||
in_app_sent_count: int
|
||||
sms_sent_count: int
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
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
|
||||
@@ -16,14 +15,10 @@ from apps.communications.api.schemas import (
|
||||
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()
|
||||
communications_router = Router()
|
||||
|
||||
|
||||
@@ -50,9 +45,6 @@ def list_announcements(request, published_only: bool = True):
|
||||
"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,
|
||||
}
|
||||
)
|
||||
@@ -82,16 +74,10 @@ def create_announcement(request, payload: AnnouncementCreateSchema):
|
||||
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,
|
||||
)
|
||||
|
||||
if announcement.is_published and (announcement.publish_date is None or announcement.publish_date <= timezone.now()):
|
||||
send_announcement_notifications.delay(announcement.id)
|
||||
|
||||
return announcement
|
||||
|
||||
|
||||
@@ -106,17 +92,10 @@ def update_announcement(request, announcement_id: int, payload: AnnouncementCrea
|
||||
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()
|
||||
|
||||
if announcement.is_published and (announcement.publish_date is None or announcement.publish_date <= timezone.now()):
|
||||
send_announcement_notifications.delay(announcement.id)
|
||||
return announcement
|
||||
|
||||
|
||||
@@ -140,34 +119,10 @@ def get_announcement_stats(request):
|
||||
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
|
||||
|
||||
|
||||
@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema)
|
||||
def subscribe_newsletter(request):
|
||||
return {
|
||||
"message": "خبرنامه ایمیلی حذف شده است. اطلاعرسانیها از این پس درونسایتی و در موارد مهم از طریق پیامک انجام میشود."
|
||||
}
|
||||
|
||||
|
||||
@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema)
|
||||
def unsubscribe_newsletter(request):
|
||||
return {"message": "خبرنامه ایمیلی دیگر فعال نیست."}
|
||||
|
||||
|
||||
@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema)
|
||||
def confirm_newsletter_subscription(request, token: str):
|
||||
return {"message": "تایید خبرنامه ایمیلی دیگر لازم نیست."}
|
||||
|
||||
|
||||
@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema)
|
||||
def unsubscribe_newsletter_token(request, token: str):
|
||||
return {"message": "خبرنامه ایمیلی دیگر فعال نیست."}
|
||||
|
||||
|
||||
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
|
||||
def register_push_device(request, payload: PushDeviceCreateSchema):
|
||||
user = request.auth
|
||||
@@ -207,16 +162,6 @@ def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema)
|
||||
return device
|
||||
|
||||
|
||||
@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth)
|
||||
def send_push_notification(request, payload: PushNotificationSchema):
|
||||
user = request.auth
|
||||
if not _is_staff_user(user):
|
||||
return {"error": "Permission denied"}, 403
|
||||
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"}
|
||||
|
||||
|
||||
@communications_router.get("/announcement-types/", response=List[dict])
|
||||
|
||||
@@ -12,12 +12,7 @@
|
||||
"priority": "high",
|
||||
"author": 1,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-03-01T10:00:00Z",
|
||||
"send_email": true,
|
||||
"send_push": true,
|
||||
"email_sent": true,
|
||||
"push_sent": true,
|
||||
"target_audience": "all"
|
||||
"publish_date": "2024-03-01T10:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -33,12 +28,7 @@
|
||||
"priority": "urgent",
|
||||
"author": 2,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-03-10T14:30:00Z",
|
||||
"send_email": true,
|
||||
"send_push": true,
|
||||
"email_sent": true,
|
||||
"push_sent": true,
|
||||
"target_audience": "all"
|
||||
"publish_date": "2024-03-10T14:30:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -54,12 +44,7 @@
|
||||
"priority": "normal",
|
||||
"author": 5,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-03-15T09:00:00Z",
|
||||
"send_email": true,
|
||||
"send_push": false,
|
||||
"email_sent": true,
|
||||
"push_sent": false,
|
||||
"target_audience": "members"
|
||||
"publish_date": "2024-03-15T09:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -75,12 +60,7 @@
|
||||
"priority": "normal",
|
||||
"author": 1,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-03-20T11:15:00Z",
|
||||
"send_email": true,
|
||||
"send_push": false,
|
||||
"email_sent": true,
|
||||
"push_sent": false,
|
||||
"target_audience": "all"
|
||||
"publish_date": "2024-03-20T11:15:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -96,12 +76,7 @@
|
||||
"priority": "high",
|
||||
"author": 9,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-04-01T08:00:00Z",
|
||||
"send_email": true,
|
||||
"send_push": true,
|
||||
"email_sent": true,
|
||||
"push_sent": true,
|
||||
"target_audience": "all"
|
||||
"publish_date": "2024-04-01T08:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -117,12 +92,7 @@
|
||||
"priority": "normal",
|
||||
"author": 1,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-04-05T16:00:00Z",
|
||||
"send_email": true,
|
||||
"send_push": false,
|
||||
"email_sent": true,
|
||||
"push_sent": false,
|
||||
"target_audience": "committee"
|
||||
"publish_date": "2024-04-05T16:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -138,12 +108,7 @@
|
||||
"priority": "high",
|
||||
"author": 2,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-04-15T12:30:00Z",
|
||||
"send_email": true,
|
||||
"send_push": true,
|
||||
"email_sent": true,
|
||||
"push_sent": true,
|
||||
"target_audience": "all"
|
||||
"publish_date": "2024-04-15T12:30:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -159,12 +124,7 @@
|
||||
"priority": "high",
|
||||
"author": 8,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-04-20T10:45:00Z",
|
||||
"send_email": true,
|
||||
"send_push": true,
|
||||
"email_sent": true,
|
||||
"push_sent": true,
|
||||
"target_audience": "members"
|
||||
"publish_date": "2024-04-20T10:45:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -180,12 +140,7 @@
|
||||
"priority": "normal",
|
||||
"author": 12,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-04-25T13:20:00Z",
|
||||
"send_email": true,
|
||||
"send_push": false,
|
||||
"email_sent": true,
|
||||
"push_sent": false,
|
||||
"target_audience": "all"
|
||||
"publish_date": "2024-04-25T13:20:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
@@ -201,180 +156,7 @@
|
||||
"priority": "normal",
|
||||
"author": 5,
|
||||
"is_published": true,
|
||||
"publish_date": "2024-05-01T15:00:00Z",
|
||||
"send_email": true,
|
||||
"send_push": false,
|
||||
"email_sent": true,
|
||||
"push_sent": false,
|
||||
"target_audience": "all"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created_at": "2024-01-15T10:30:00Z",
|
||||
"updated_at": "2024-01-15T10:30:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "sara.mohammadi@student.ac.ir",
|
||||
"user": 2,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event", "academic", "general"],
|
||||
"confirmed_at": "2024-01-15T10:30:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created_at": "2024-01-20T14:15:00Z",
|
||||
"updated_at": "2024-01-20T14:15:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "reza.karimi@student.ac.ir",
|
||||
"user": 3,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event", "urgent"],
|
||||
"confirmed_at": "2024-01-20T14:15:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created_at": "2024-02-01T09:45:00Z",
|
||||
"updated_at": "2024-02-01T09:45:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "maryam.hosseini@student.ac.ir",
|
||||
"user": 4,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event", "academic"],
|
||||
"confirmed_at": "2024-02-01T09:45:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created_at": "2024-02-05T16:20:00Z",
|
||||
"updated_at": "2024-02-05T16:20:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "hassan.zare@student.ac.ir",
|
||||
"user": 5,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["general", "event", "academic", "urgent"],
|
||||
"confirmed_at": "2024-02-05T16:20:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created_at": "2024-02-10T11:30:00Z",
|
||||
"updated_at": "2024-02-10T11:30:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "zahra.safari@student.ac.ir",
|
||||
"user": 6,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event", "academic"],
|
||||
"confirmed_at": "2024-02-10T11:30:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created_at": "2024-02-15T13:45:00Z",
|
||||
"updated_at": "2024-02-15T13:45:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "fateme.moradi@student.ac.ir",
|
||||
"user": 8,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event"],
|
||||
"confirmed_at": "2024-02-15T13:45:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created_at": "2024-02-20T08:15:00Z",
|
||||
"updated_at": "2024-02-20T08:15:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "amir.ghorbani@student.ac.ir",
|
||||
"user": 9,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["general", "event", "academic"],
|
||||
"confirmed_at": "2024-02-20T08:15:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created_at": "2024-02-25T15:30:00Z",
|
||||
"updated_at": "2024-02-25T15:30:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "nasrin.jafari@student.ac.ir",
|
||||
"user": 10,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["academic", "event"],
|
||||
"confirmed_at": "2024-02-25T15:30:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created_at": "2024-03-01T12:00:00Z",
|
||||
"updated_at": "2024-03-01T12:00:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "mehdi.bagheri@student.ac.ir",
|
||||
"user": 11,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event"],
|
||||
"confirmed_at": "2024-03-01T12:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"created_at": "2024-03-05T14:45:00Z",
|
||||
"updated_at": "2024-03-05T14:45:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "leila.mousavi@student.ac.ir",
|
||||
"user": 12,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event", "academic"],
|
||||
"confirmed_at": "2024-03-05T14:45:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 11,
|
||||
"fields": {
|
||||
"created_at": "2024-03-10T10:20:00Z",
|
||||
"updated_at": "2024-03-10T10:20:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "external.user1@gmail.com",
|
||||
"user": null,
|
||||
"is_active": true,
|
||||
"subscribed_categories": ["event"],
|
||||
"confirmed_at": "2024-03-10T10:20:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.newslettersubscription",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"created_at": "2024-03-15T16:30:00Z",
|
||||
"updated_at": "2024-03-15T16:30:00Z",
|
||||
"is_deleted": false,
|
||||
"email": "external.user2@yahoo.com",
|
||||
"user": null,
|
||||
"is_active": false,
|
||||
"subscribed_categories": ["general"],
|
||||
"confirmed_at": null
|
||||
"publish_date": "2024-05-01T15:00:00Z"
|
||||
}
|
||||
},
|
||||
{
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
# Generated by Django 5.2.5 on 2026-09-26 07:57
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
def delete_newsletter_announcements(apps, schema_editor):
|
||||
apps.get_model('communications', 'Announcement')._base_manager.filter(announcement_type='newsletter').delete()
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('communications', '0002_initial'),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.RunPython(delete_newsletter_announcements, migrations.RunPython.noop),
|
||||
migrations.RemoveField(
|
||||
model_name='announcement',
|
||||
name='email_sent',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='announcement',
|
||||
name='push_sent',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='announcement',
|
||||
name='send_email',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='announcement',
|
||||
name='send_push',
|
||||
),
|
||||
migrations.RemoveField(
|
||||
model_name='announcement',
|
||||
name='target_audience',
|
||||
),
|
||||
migrations.AlterField(
|
||||
model_name='announcement',
|
||||
name='announcement_type',
|
||||
field=models.CharField(choices=[('general', 'General'), ('event', 'Event'), ('academic', 'Academic'), ('urgent', 'Urgent')], default='general', max_length=20, verbose_name='Type'),
|
||||
),
|
||||
migrations.DeleteModel(
|
||||
name='NewsletterSubscription',
|
||||
),
|
||||
]
|
||||
@@ -11,7 +11,6 @@ class AnnouncementType(models.TextChoices):
|
||||
EVENT = 'event', 'Event'
|
||||
ACADEMIC = 'academic', 'Academic'
|
||||
URGENT = 'urgent', 'Urgent'
|
||||
NEWSLETTER = 'newsletter', 'Newsletter'
|
||||
|
||||
|
||||
class AnnouncementPriority(models.TextChoices):
|
||||
@@ -44,21 +43,6 @@ class Announcement(BaseModel):
|
||||
)
|
||||
is_published = models.BooleanField(default=False, verbose_name='Published')
|
||||
publish_date = models.DateTimeField(null=True, blank=True, verbose_name='Publish Date')
|
||||
send_email = models.BooleanField(default=False, verbose_name='Send Email Notification')
|
||||
send_push = models.BooleanField(default=False, verbose_name='Send Push Notification')
|
||||
email_sent = models.BooleanField(default=False, verbose_name='Email Sent')
|
||||
push_sent = models.BooleanField(default=False, verbose_name='Push Sent')
|
||||
target_audience = models.CharField(
|
||||
max_length=20,
|
||||
choices=[
|
||||
('all', 'All Users'),
|
||||
('members', 'Members Only'),
|
||||
('committee', 'Committee Only'),
|
||||
('subscribers', 'Newsletter Subscribers Only'),
|
||||
],
|
||||
default='all',
|
||||
verbose_name='Target Audience'
|
||||
)
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Announcement'
|
||||
@@ -75,45 +59,6 @@ class Announcement(BaseModel):
|
||||
return markdown.markdown(self.content)
|
||||
|
||||
|
||||
class NewsletterSubscription(BaseModel):
|
||||
email = models.EmailField(unique=True, verbose_name='Email')
|
||||
user = models.OneToOneField(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
null=True,
|
||||
blank=True,
|
||||
related_name='newsletter_subscription',
|
||||
verbose_name='User'
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name='Active')
|
||||
subscribed_categories = models.JSONField(
|
||||
default=list,
|
||||
blank=True,
|
||||
verbose_name='Subscribed Categories',
|
||||
help_text='List of announcement types to receive'
|
||||
)
|
||||
confirmation_token = models.CharField(max_length=100, blank=True, verbose_name='Confirmation Token')
|
||||
confirmed_at = models.DateTimeField(null=True, blank=True, verbose_name='Confirmed At')
|
||||
unsubscribe_token = models.CharField(max_length=100, blank=True, verbose_name='Unsubscribe Token')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Newsletter Subscription'
|
||||
verbose_name_plural = 'Newsletter Subscriptions'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.email
|
||||
|
||||
def save(self, *args, **kwargs):
|
||||
if not self.confirmation_token:
|
||||
import uuid
|
||||
self.confirmation_token = str(uuid.uuid4())
|
||||
if not self.unsubscribe_token:
|
||||
import uuid
|
||||
self.unsubscribe_token = str(uuid.uuid4())
|
||||
super().save(*args, **kwargs)
|
||||
|
||||
|
||||
class PushNotificationDevice(BaseModel):
|
||||
user = models.ForeignKey(
|
||||
User,
|
||||
|
||||
@@ -3,7 +3,7 @@ from django.contrib.auth import get_user_model
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from apps.communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||
from apps.communications.models import Announcement, PushNotificationDevice
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
@@ -19,24 +19,7 @@ class AnnouncementResource(resources.ModelResource):
|
||||
model = Announcement
|
||||
fields = (
|
||||
'id', 'title', 'content', 'announcement_type', 'priority',
|
||||
'author', 'is_published', 'publish_date', 'send_email', 'send_push',
|
||||
'target_audience', 'created_at', 'updated_at'
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
|
||||
class NewsletterSubscriptionResource(resources.ModelResource):
|
||||
user = fields.Field(
|
||||
column_name='user',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'username')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = NewsletterSubscription
|
||||
fields = (
|
||||
'id', 'email', 'user', 'is_active', 'subscribed_categories',
|
||||
'confirmed_at', 'created_at', 'updated_at'
|
||||
'author', 'is_published', 'publish_date', 'created_at', 'updated_at'
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
|
||||
@@ -1,174 +1,43 @@
|
||||
"""Scheduled automatic in-app reminders."""
|
||||
|
||||
import logging
|
||||
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.notifications.services import notify_user
|
||||
from apps.users.tasks import send_critical_sms
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
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):
|
||||
try:
|
||||
announcement = Announcement.objects.get(id=announcement_id)
|
||||
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("Announcement %s not found", announcement_id)
|
||||
return f"Announcement {announcement_id} not found"
|
||||
except Exception as exc:
|
||||
logger.error("Failed to send announcement notifications: %s", exc)
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_event_reminders():
|
||||
try:
|
||||
reminder_target = timezone.now() + timedelta(hours=24)
|
||||
window = timedelta(minutes=30)
|
||||
events = Event.objects.filter(
|
||||
start_time__range=(reminder_target - window, reminder_target + window),
|
||||
status="published",
|
||||
reminder_target = timezone.now() + timedelta(hours=24)
|
||||
window = timedelta(minutes=30)
|
||||
events = Event.objects.filter(
|
||||
start_time__range=(reminder_target - window, reminder_target + window),
|
||||
status=Event.StatusChoices.PUBLISHED,
|
||||
is_deleted=False,
|
||||
)
|
||||
total_sent = 0
|
||||
for event in events:
|
||||
registrations = Registration.objects.filter(
|
||||
event=event,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
is_deleted=False,
|
||||
)
|
||||
total_sent = 0
|
||||
for event in events:
|
||||
registrations = Registration.objects.filter(
|
||||
event=event,
|
||||
status=Registration.StatusChoices.CONFIRMED,
|
||||
is_deleted=False,
|
||||
).select_related("user")
|
||||
for registration in registrations:
|
||||
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("Failed to send event reminders: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_weekly_newsletter():
|
||||
logger.info("Weekly newsletter task skipped because newsletter delivery has been removed.")
|
||||
return "Newsletter delivery removed"
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_expired_tokens():
|
||||
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):
|
||||
try:
|
||||
announcement = Announcement.objects.get(id=announcement_id)
|
||||
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("Failed to send bulk announcement: %s", exc)
|
||||
raise
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_scheduled_announcements():
|
||||
try:
|
||||
now = timezone.now()
|
||||
scheduled_announcements = Announcement.objects.filter(
|
||||
is_published=True,
|
||||
publish_date__lte=now,
|
||||
email_sent=False,
|
||||
send_email=True,
|
||||
is_deleted=False,
|
||||
)
|
||||
processed_count = 0
|
||||
for announcement in scheduled_announcements:
|
||||
send_announcement_notifications.delay(announcement.id)
|
||||
processed_count += 1
|
||||
logger.info("Processed %s scheduled announcements", processed_count)
|
||||
return f"Processed {processed_count} scheduled announcements"
|
||||
except Exception as exc:
|
||||
logger.error("Failed to process scheduled announcements: %s", exc)
|
||||
raise
|
||||
).select_related("user")
|
||||
for registration in registrations:
|
||||
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"
|
||||
|
||||
0
apps/communications/tests/__init__.py
Normal file
0
apps/communications/tests/__init__.py
Normal file
32
apps/communications/tests/test_announcements.py
Normal file
32
apps/communications/tests/test_announcements.py
Normal file
@@ -0,0 +1,32 @@
|
||||
import json
|
||||
from unittest import mock
|
||||
|
||||
from django.test import TestCase
|
||||
|
||||
from apps.communications.models import Announcement
|
||||
from apps.users.models import User
|
||||
from core.authentication import create_jwt_token
|
||||
|
||||
|
||||
class AnnouncementDeliveryTests(TestCase):
|
||||
def setUp(self):
|
||||
self.staff = User.objects.create_user(
|
||||
username="announcement_staff", mobile="09193000001", password="password", is_staff=True,
|
||||
)
|
||||
self.auth = {"HTTP_AUTHORIZATION": f"Bearer {create_jwt_token(self.staff)}"}
|
||||
|
||||
@mock.patch("apps.notifications.services.notify_user")
|
||||
def test_publishing_announcement_does_not_send_manual_notifications(self, notify_user):
|
||||
response = self.client.post("/api/communications/announcements/", json.dumps({
|
||||
"title": "News", "content": "# Story", "is_published": True,
|
||||
}), content_type="application/json", **self.auth)
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertTrue(Announcement.objects.filter(title="News").exists())
|
||||
notify_user.assert_not_called()
|
||||
|
||||
def test_newsletter_category_and_routes_are_removed(self):
|
||||
response = self.client.post("/api/communications/announcements/", json.dumps({
|
||||
"title": "News", "content": "Story", "announcement_type": "newsletter",
|
||||
}), content_type="application/json", **self.auth)
|
||||
self.assertEqual(response.status_code, 422)
|
||||
self.assertEqual(self.client.post("/api/communications/newsletter/subscribe/").status_code, 404)
|
||||
@@ -1,140 +0,0 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
from django.core.mail import send_mail
|
||||
from django.template.loader import render_to_string
|
||||
from django.utils.html import strip_tags
|
||||
from django.conf import settings
|
||||
|
||||
import logging
|
||||
|
||||
from apps.communications.models import NewsletterSubscription
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
def send_announcement_email(announcement, recipients):
|
||||
"""Send announcement email to recipients"""
|
||||
try:
|
||||
template_name = f'emails/announcement_email.html'
|
||||
|
||||
context = {
|
||||
'announcement': announcement,
|
||||
'unsubscribe_url': f"{settings.FRONTEND_ROOT}newsletter/unsubscribe/",
|
||||
'manage_subscription_url': f"{settings.FRONTEND_ROOT}newsletter/manage-subscription",
|
||||
}
|
||||
|
||||
html_message = render_to_string(template_name, context)
|
||||
plain_message = strip_tags(html_message)
|
||||
|
||||
subject = f"انجمن علمی کامپیوتر گیلان | {announcement.title}"
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=recipients,
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Announcement email sent to {len(recipients)} recipients")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send announcement email: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def send_newsletter_confirmation(subscription):
|
||||
"""Send newsletter confirmation email"""
|
||||
try:
|
||||
template_name = f'emails/newsletter_confirmation.html'
|
||||
|
||||
confirmation_url = f"{settings.FRONTEND_ROOT}confirm-subscription/{subscription.confirmation_token}"
|
||||
|
||||
context = {
|
||||
'subscription': subscription,
|
||||
'confirmation_url': confirmation_url,
|
||||
}
|
||||
|
||||
html_message = render_to_string(template_name, context)
|
||||
plain_message = strip_tags(html_message)
|
||||
|
||||
subject = "تأیید اشتراک خبرنامه"
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[subscription.email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Newsletter confirmation sent to {subscription.email}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send newsletter confirmation: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def send_event_reminder(event, user):
|
||||
"""Send event reminder email"""
|
||||
try:
|
||||
template_name = f'emails/event_reminder.html'
|
||||
|
||||
event_url = f"{settings.FRONTEND_ROOT}events/{event.slug}"
|
||||
|
||||
context = {
|
||||
'event': event,
|
||||
'user': user,
|
||||
'event_url': event_url,
|
||||
}
|
||||
|
||||
html_message = render_to_string(template_name, context)
|
||||
plain_message = strip_tags(html_message)
|
||||
|
||||
subject = f"یادآوری رویداد: {event.title}"
|
||||
|
||||
send_mail(
|
||||
subject=subject,
|
||||
message=plain_message,
|
||||
from_email=settings.DEFAULT_FROM_EMAIL,
|
||||
recipient_list=[user.email],
|
||||
html_message=html_message,
|
||||
fail_silently=False,
|
||||
)
|
||||
|
||||
logger.info(f"Event reminder sent to {user.email} for event {event.title}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send event reminder: {str(e)}")
|
||||
return False
|
||||
|
||||
|
||||
def get_announcement_recipients(announcement):
|
||||
"""Get list of email addresses based on announcement target audience"""
|
||||
|
||||
User = get_user_model()
|
||||
recipients = []
|
||||
|
||||
if announcement.target_audience == 'all':
|
||||
# All users with email
|
||||
recipients = list(User.objects.filter(email__isnull=False).values_list('email', flat=True))
|
||||
|
||||
elif announcement.target_audience == 'members':
|
||||
# Only members (users with is_member=True)
|
||||
recipients = list(User.objects.filter(is_member=True, email__isnull=False).values_list('email', flat=True))
|
||||
|
||||
elif announcement.target_audience == 'committee':
|
||||
# Only committee members
|
||||
recipients = list(User.objects.filter(is_committee=True, email__isnull=False).values_list('email', flat=True))
|
||||
|
||||
elif announcement.target_audience == 'subscribers':
|
||||
# Only newsletter subscribers
|
||||
recipients = list(NewsletterSubscription.objects.filter(
|
||||
is_active=True,
|
||||
confirmed_at__isnull=False
|
||||
).values_list('email', flat=True))
|
||||
|
||||
return recipients
|
||||
Reference in New Issue
Block a user