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:
2026-09-26 12:23:10 +03:30
parent b706a2d10a
commit 3ec28638d1
54 changed files with 787 additions and 4843 deletions

View File

@@ -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']

View File

@@ -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

View File

@@ -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])

View File

@@ -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"
}
},
{

View File

@@ -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',
),
]

View File

@@ -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,

View File

@@ -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

View File

@@ -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"

View File

View 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)

View File

@@ -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