init
This commit is contained in:
122
backend/communications/admin.py
Normal file
122
backend/communications/admin.py
Normal file
@@ -0,0 +1,122 @@
|
||||
from django import forms
|
||||
from django.contrib import admin
|
||||
from django.utils import timezone
|
||||
|
||||
from simplemde.widgets import SimpleMDEEditor
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
|
||||
from utils.admin import SoftDeleteListFilter, BaseModelAdmin
|
||||
from communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||
|
||||
|
||||
class AnnouncementAdminForm(forms.ModelForm):
|
||||
content = forms.CharField(
|
||||
widget=SimpleMDEEditor(),
|
||||
help_text="Announcement content in Markdown format with live preview"
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = Announcement
|
||||
fields = '__all__'
|
||||
|
||||
|
||||
@admin.register(Announcement)
|
||||
class AnnouncementAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
form = AnnouncementAdminForm
|
||||
list_display = [
|
||||
'title', 'announcement_type', 'priority', 'author',
|
||||
'is_published', 'publish_date', 'email_sent', 'push_sent', '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']
|
||||
|
||||
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')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
|
||||
actions = BaseModelAdmin.actions + ['publish_announcements', 'send_notifications']
|
||||
|
||||
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']
|
||||
list_filter = ['device_type', 'is_active', SoftDeleteListFilter, 'created_at']
|
||||
search_fields = ['user__username', 'user__email', 'device_token']
|
||||
readonly_fields = ['created_at', 'updated_at']
|
||||
|
||||
fieldsets = (
|
||||
('Device', {
|
||||
'fields': ('user', 'device_token', 'device_type', 'is_active')
|
||||
}),
|
||||
('Timestamps', {
|
||||
'fields': ('created_at', 'updated_at'),
|
||||
'classes': ('collapse',)
|
||||
}),
|
||||
)
|
||||
7
backend/communications/apps.py
Normal file
7
backend/communications/apps.py
Normal file
@@ -0,0 +1,7 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class CommunicationsConfig(AppConfig):
|
||||
default_auto_field = 'django.db.models.BigAutoField'
|
||||
name = 'communications'
|
||||
verbose_name = 'Communications'
|
||||
536
backend/communications/fixtures/communications.json
Normal file
536
backend/communications/fixtures/communications.json
Normal file
@@ -0,0 +1,536 @@
|
||||
[
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created_at": "2024-03-01T10:00:00Z",
|
||||
"updated_at": "2024-03-01T10:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "شروع ثبتنام کارگاه یادگیری ماشین",
|
||||
"content": "# شروع ثبتنام کارگاه یادگیری ماشین\n\nبا سلام و احترام\n\nثبتنام کارگاه یادگیری ماشین پیشرفته از امروز آغاز شد.\n\n## جزئیات:\n- تاریخ: ۱۵ اسفند ۱۴۰۲\n- مدت: ۴ ساعت\n- هزینه: ۱۵۰ هزار تومان\n- ظرفیت: ۵۰ نفر\n\nبرای ثبتنام به وبسایت انجمن مراجعه کنید.",
|
||||
"announcement_type": "event",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created_at": "2024-03-10T14:30:00Z",
|
||||
"updated_at": "2024-03-10T14:30:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "تغییر زمان مسابقه برنامهنویسی",
|
||||
"content": "# تغییر زمان مسابقه برنامهنویسی\n\nبه اطلاع شرکتکنندگان محترم میرساند که زمان مسابقه برنامهنویسی بهاری به دلیل تعطیلات از ۲۲ اسفند به ۲۹ اسفند تغییر یافت.\n\nعذرخواهی بابت این تغییر و لطفاً برنامهریزی خود را بر این اساس انجام دهید.",
|
||||
"announcement_type": "urgent",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created_at": "2024-03-15T09:00:00Z",
|
||||
"updated_at": "2024-03-15T09:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "وبینار امنیت سایبری - رایگان",
|
||||
"content": "# وبینار امنیت سایبری\n\nانجمن علمی مهندسی کامپیوتر برگزار میکند:\n\n**وبینار امنیت سایبری**\n\n- تاریخ: ۷ فروردین ۱۴۰۳\n- ساعت: ۱۹:۰۰ الی ۲۱:۰۰\n- مدرس: دکتر محمد رضایی\n- شرکت: رایگان\n\nلینک ورود یک ساعت قبل از شروع ارسال خواهد شد.",
|
||||
"announcement_type": "event",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created_at": "2024-03-20T11:15:00Z",
|
||||
"updated_at": "2024-03-20T11:15:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "فراخوان مقاله برای نشریه انجمن",
|
||||
"content": "# فراخوان مقاله برای نشریه انجمن\n\nدانشجویان و اساتید محترم میتوانند مقالات خود را در زمینههای زیر برای چاپ در نشریه انجمن ارسال کنند:\n\n## موضوعات:\n- هوش مصنوعی\n- امنیت سایبری\n- مهندسی نرمافزار\n- شبکههای کامپیوتری\n- علم داده\n\n## مهلت ارسال:\n۳۰ فروردین ۱۴۰۳\n\nایمیل ارسال: journal@cs-association.ac.ir",
|
||||
"announcement_type": "academic",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created_at": "2024-04-01T08:00:00Z",
|
||||
"updated_at": "2024-04-01T08:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "هکاتون هوش مصنوعی - ثبتنام آغاز شد",
|
||||
"content": "# هکاتون هوش مصنوعی\n\nبزرگترین رویداد سال انجمن!\n\n## جزئیات:\n- تاریخ: ۳۰ فروردین تا ۲ اردیبهشت\n- مدت: ۴۸ ساعت\n- جایزه کل: ۲۰ میلیون تومان\n- ظرفیت: ۶۰ نفر (۲۰ تیم)\n\n## امکانات:\n- غذا و نوشیدنی رایگان\n- منتورینگ اساتید\n- فضای کار ۲۴ ساعته\n\nثبتنام تیمی (۳ نفره) الزامی است.",
|
||||
"announcement_type": "event",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created_at": "2024-04-05T16:00:00Z",
|
||||
"updated_at": "2024-04-05T16:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "جلسه کمیته اجرایی انجمن",
|
||||
"content": "# جلسه کمیته اجرایی انجمن\n\nاعضای محترم کمیته اجرایی\n\nجلسه ماهانه کمیته اجرایی:\n\n- تاریخ: ۱۰ اردیبهشت ۱۴۰۳\n- ساعت: ۱۴:۰۰\n- مکان: دفتر انجمن\n\n## دستور جلسه:\n1. بررسی گزارش مالی\n2. برنامهریزی رویدادهای آتی\n3. بررسی درخواستهای عضویت\n4. سایر موارد\n\nحضور همه اعضا الزامی است.",
|
||||
"announcement_type": "general",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created_at": "2024-04-15T12:30:00Z",
|
||||
"updated_at": "2024-04-15T12:30:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "سمینار کارآفرینی فناوری",
|
||||
"content": "# سمینار کارآفرینی فناوری\n\nبا حضور کارآفرینان موفق صنعت فناوری\n\n## سخنرانان:\n- دکتر علی احمدی (موسس تپسی)\n- خانم سارا محمدی (مدیرعامل کافهبازار)\n- مهندس رضا کریمی (سرمایهگذار)\n\n## موضوعات:\n- از ایده تا محصول\n- جذب سرمایه\n- چالشهای استارتاپی\n- آینده فناوری در ایران\n\nشرکت رایگان - ظرفیت محدود",
|
||||
"announcement_type": "event",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created_at": "2024-04-20T10:45:00Z",
|
||||
"updated_at": "2024-04-20T10:45:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "کارگاه DevOps - ثبتنام محدود",
|
||||
"content": "# کارگاه DevOps و Docker\n\nآموزش عملی ابزارهای DevOps\n\n## محتوا:\n- Docker و Containerization\n- CI/CD Pipeline\n- Kubernetes مقدماتی\n- پروژه عملی\n\n## جزئیات:\n- تاریخ: ۱۴ اردیبهشت\n- مدت: ۸ ساعت\n- هزینه: ۳۰۰ هزار تومان\n- ظرفیت: ۲۵ نفر\n\n⚠️ ظرفیت بسیار محدود - عجله کنید!",
|
||||
"announcement_type": "event",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created_at": "2024-04-25T13:20:00Z",
|
||||
"updated_at": "2024-04-25T13:20:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "مسابقه طراحی UI/UX - جوایز جذاب",
|
||||
"content": "# مسابقه طراحی UI/UX\n\nفرصتی برای نمایش خلاقیت شما!\n\n## موضوع:\nطراحی اپلیکیشن مدیریت تسک دانشجویی\n\n## جوایز:\n- نفر اول: iPad Air\n- نفر دوم: AirPods Pro\n- نفر سوم: پاوربانک ۲۰۰۰۰ میلیآمپر\n\n## مهلت ارسال:\n۲۰ اردیبهشت ۱۴۰۳\n\nفایلهای Figma یا Adobe XD قابل قبول هستند.",
|
||||
"announcement_type": "event",
|
||||
"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"
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.announcement",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"created_at": "2024-05-01T15:00:00Z",
|
||||
"updated_at": "2024-05-01T15:00:00Z",
|
||||
"is_deleted": false,
|
||||
"title": "نشست فارغالتحصیلان - دعوت ویژه",
|
||||
"content": "# نشست فارغالتحصیلان\n\nدیدار با فارغالتحصیلان موفق\n\n## مهمانان ویژه:\n- دکتر حسن زارع (مدیر فنی گوگل)\n- مهندس مریم حسینی (بنیانگذار استارتاپ)\n- دکتر امیر قربانی (استاد MIT)\n\n## برنامه:\n- ۱۷:۰۰ - پذیرایی\n- ۱۸:۰۰ - سخنرانیها\n- ۱۹:۳۰ - پرسش و پاسخ\n- ۲۰:۳۰ - ضیافت شام\n\nشرکت رایگان - ثبتنام الزامی",
|
||||
"announcement_type": "event",
|
||||
"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
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 1,
|
||||
"fields": {
|
||||
"created_at": "2024-01-10T08:00:00Z",
|
||||
"updated_at": "2024-01-10T08:00:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 1,
|
||||
"device_token": "web_push_token_admin_chrome",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 2,
|
||||
"fields": {
|
||||
"created_at": "2024-01-15T12:30:00Z",
|
||||
"updated_at": "2024-01-15T12:30:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 2,
|
||||
"device_token": "web_push_token_sara_firefox",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 3,
|
||||
"fields": {
|
||||
"created_at": "2024-01-20T16:45:00Z",
|
||||
"updated_at": "2024-01-20T16:45:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 3,
|
||||
"device_token": "web_push_token_reza_chrome",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 4,
|
||||
"fields": {
|
||||
"created_at": "2024-02-01T11:20:00Z",
|
||||
"updated_at": "2024-02-01T11:20:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 4,
|
||||
"device_token": "android_token_maryam_phone",
|
||||
"device_type": "android",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 5,
|
||||
"fields": {
|
||||
"created_at": "2024-02-05T18:10:00Z",
|
||||
"updated_at": "2024-02-05T18:10:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 5,
|
||||
"device_token": "web_push_token_hassan_edge",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 6,
|
||||
"fields": {
|
||||
"created_at": "2024-02-10T13:25:00Z",
|
||||
"updated_at": "2024-02-10T13:25:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 6,
|
||||
"device_token": "ios_token_zahra_iphone",
|
||||
"device_type": "ios",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 7,
|
||||
"fields": {
|
||||
"created_at": "2024-02-15T15:40:00Z",
|
||||
"updated_at": "2024-02-15T15:40:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 8,
|
||||
"device_token": "web_push_token_fateme_chrome",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 8,
|
||||
"fields": {
|
||||
"created_at": "2024-02-20T10:15:00Z",
|
||||
"updated_at": "2024-02-20T10:15:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 9,
|
||||
"device_token": "web_push_token_amir_firefox",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 9,
|
||||
"fields": {
|
||||
"created_at": "2024-02-25T17:30:00Z",
|
||||
"updated_at": "2024-02-25T17:30:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 10,
|
||||
"device_token": "android_token_nasrin_phone",
|
||||
"device_type": "android",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 10,
|
||||
"fields": {
|
||||
"created_at": "2024-03-01T14:00:00Z",
|
||||
"updated_at": "2024-03-01T14:00:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 11,
|
||||
"device_token": "web_push_token_mehdi_chrome",
|
||||
"device_type": "web",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 11,
|
||||
"fields": {
|
||||
"created_at": "2024-03-05T16:50:00Z",
|
||||
"updated_at": "2024-03-05T16:50:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 12,
|
||||
"device_token": "ios_token_leila_iphone",
|
||||
"device_type": "ios",
|
||||
"is_active": true
|
||||
}
|
||||
},
|
||||
{
|
||||
"model": "communications.pushnotificationdevice",
|
||||
"pk": 12,
|
||||
"fields": {
|
||||
"created_at": "2024-01-10T08:00:00Z",
|
||||
"updated_at": "2024-03-10T12:00:00Z",
|
||||
"is_deleted": false,
|
||||
"user": 1,
|
||||
"device_token": "android_token_admin_phone",
|
||||
"device_type": "android",
|
||||
"is_active": false
|
||||
}
|
||||
}
|
||||
]
|
||||
78
backend/communications/migrations/0001_initial.py
Normal file
78
backend/communications/migrations/0001_initial.py
Normal file
@@ -0,0 +1,78 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.CreateModel(
|
||||
name='Announcement',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('title', models.CharField(max_length=200, verbose_name='Title')),
|
||||
('content', models.TextField(verbose_name='Content')),
|
||||
('announcement_type', models.CharField(choices=[('general', 'General'), ('event', 'Event'), ('academic', 'Academic'), ('urgent', 'Urgent'), ('newsletter', 'Newsletter')], default='general', max_length=20, verbose_name='Type')),
|
||||
('priority', models.CharField(choices=[('low', 'Low'), ('normal', 'Normal'), ('high', 'High'), ('urgent', 'Urgent')], default='normal', max_length=10, verbose_name='Priority')),
|
||||
('is_published', models.BooleanField(default=False, verbose_name='Published')),
|
||||
('publish_date', models.DateTimeField(blank=True, null=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(choices=[('all', 'All Users'), ('members', 'Members Only'), ('committee', 'Committee Only'), ('subscribers', 'Newsletter Subscribers Only')], default='all', max_length=20, verbose_name='Target Audience')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Announcement',
|
||||
'verbose_name_plural': 'Announcements',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='NewsletterSubscription',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('email', models.EmailField(max_length=254, unique=True, verbose_name='Email')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
('subscribed_categories', models.JSONField(blank=True, default=list, help_text='List of announcement types to receive', verbose_name='Subscribed Categories')),
|
||||
('confirmation_token', models.CharField(blank=True, max_length=100, verbose_name='Confirmation Token')),
|
||||
('confirmed_at', models.DateTimeField(blank=True, null=True, verbose_name='Confirmed At')),
|
||||
('unsubscribe_token', models.CharField(blank=True, max_length=100, verbose_name='Unsubscribe Token')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Newsletter Subscription',
|
||||
'verbose_name_plural': 'Newsletter Subscriptions',
|
||||
'ordering': ['-created_at'],
|
||||
},
|
||||
),
|
||||
migrations.CreateModel(
|
||||
name='PushNotificationDevice',
|
||||
fields=[
|
||||
('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')),
|
||||
('created_at', models.DateTimeField(auto_now_add=True)),
|
||||
('updated_at', models.DateTimeField(auto_now=True)),
|
||||
('is_deleted', models.BooleanField(default=False)),
|
||||
('deleted_at', models.DateTimeField(blank=True, null=True)),
|
||||
('device_token', models.TextField(verbose_name='Device Token')),
|
||||
('device_type', models.CharField(choices=[('web', 'Web'), ('android', 'Android'), ('ios', 'iOS')], max_length=10, verbose_name='Device Type')),
|
||||
('is_active', models.BooleanField(default=True, verbose_name='Active')),
|
||||
],
|
||||
options={
|
||||
'verbose_name': 'Push Notification Device',
|
||||
'verbose_name_plural': 'Push Notification Devices',
|
||||
},
|
||||
),
|
||||
]
|
||||
37
backend/communications/migrations/0002_initial.py
Normal file
37
backend/communications/migrations/0002_initial.py
Normal file
@@ -0,0 +1,37 @@
|
||||
# Generated by Django 5.2.5 on 2025-10-16 12:07
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
initial = True
|
||||
|
||||
dependencies = [
|
||||
('communications', '0001_initial'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='announcement',
|
||||
name='author',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='announcements', to=settings.AUTH_USER_MODEL, verbose_name='Author'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='newslettersubscription',
|
||||
name='user',
|
||||
field=models.OneToOneField(blank=True, null=True, on_delete=django.db.models.deletion.CASCADE, related_name='newsletter_subscription', to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='pushnotificationdevice',
|
||||
name='user',
|
||||
field=models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='push_devices', to=settings.AUTH_USER_MODEL, verbose_name='User'),
|
||||
),
|
||||
migrations.AlterUniqueTogether(
|
||||
name='pushnotificationdevice',
|
||||
unique_together={('user', 'device_token')},
|
||||
),
|
||||
]
|
||||
0
backend/communications/migrations/__init__.py
Normal file
0
backend/communications/migrations/__init__.py
Normal file
142
backend/communications/models.py
Normal file
142
backend/communications/models.py
Normal file
@@ -0,0 +1,142 @@
|
||||
from django.db import models
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from utils.models import BaseModel
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AnnouncementType(models.TextChoices):
|
||||
GENERAL = 'general', 'General'
|
||||
EVENT = 'event', 'Event'
|
||||
ACADEMIC = 'academic', 'Academic'
|
||||
URGENT = 'urgent', 'Urgent'
|
||||
NEWSLETTER = 'newsletter', 'Newsletter'
|
||||
|
||||
|
||||
class AnnouncementPriority(models.TextChoices):
|
||||
LOW = 'low', 'Low'
|
||||
NORMAL = 'normal', 'Normal'
|
||||
HIGH = 'high', 'High'
|
||||
URGENT = 'urgent', 'Urgent'
|
||||
|
||||
|
||||
class Announcement(BaseModel):
|
||||
title = models.CharField(max_length=200, verbose_name='Title')
|
||||
content = models.TextField(verbose_name='Content')
|
||||
announcement_type = models.CharField(
|
||||
max_length=20,
|
||||
choices=AnnouncementType.choices,
|
||||
default=AnnouncementType.GENERAL,
|
||||
verbose_name='Type'
|
||||
)
|
||||
priority = models.CharField(
|
||||
max_length=10,
|
||||
choices=AnnouncementPriority.choices,
|
||||
default=AnnouncementPriority.NORMAL,
|
||||
verbose_name='Priority'
|
||||
)
|
||||
author = models.ForeignKey(
|
||||
User,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='announcements',
|
||||
verbose_name='Author'
|
||||
)
|
||||
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'
|
||||
verbose_name_plural = 'Announcements'
|
||||
ordering = ['-created_at']
|
||||
|
||||
def __str__(self):
|
||||
return self.title
|
||||
|
||||
@property
|
||||
def content_html(self):
|
||||
"""Convert markdown content to HTML"""
|
||||
import markdown
|
||||
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,
|
||||
on_delete=models.CASCADE,
|
||||
related_name='push_devices',
|
||||
verbose_name='User'
|
||||
)
|
||||
device_token = models.TextField(verbose_name='Device Token')
|
||||
device_type = models.CharField(
|
||||
max_length=10,
|
||||
choices=[
|
||||
('web', 'Web'),
|
||||
('android', 'Android'),
|
||||
('ios', 'iOS'),
|
||||
],
|
||||
verbose_name='Device Type'
|
||||
)
|
||||
is_active = models.BooleanField(default=True, verbose_name='Active')
|
||||
|
||||
class Meta:
|
||||
verbose_name = 'Push Notification Device'
|
||||
verbose_name_plural = 'Push Notification Devices'
|
||||
unique_together = ['user', 'device_token']
|
||||
|
||||
def __str__(self):
|
||||
return f"{self.user.username} - {self.device_type}"
|
||||
194
backend/communications/push_notifications.py
Normal file
194
backend/communications/push_notifications.py
Normal file
@@ -0,0 +1,194 @@
|
||||
from django.conf import settings
|
||||
|
||||
import json
|
||||
import logging
|
||||
from typing import List, Dict, Any, Optional
|
||||
from pywebpush import webpush, WebPushException
|
||||
|
||||
from communications.models import PushNotificationDevice
|
||||
from events.models import Registration
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PushNotificationService:
|
||||
"""Service for handling web push notifications"""
|
||||
|
||||
def __init__(self):
|
||||
self.vapid_private_key = getattr(settings, 'VAPID_PRIVATE_KEY', None)
|
||||
self.vapid_public_key = getattr(settings, 'VAPID_PUBLIC_KEY', None)
|
||||
self.vapid_claims = getattr(settings, 'VAPID_CLAIMS', {})
|
||||
|
||||
def send_notification(
|
||||
self,
|
||||
subscription_info: Dict[str, Any],
|
||||
data: Dict[str, Any],
|
||||
ttl: int = 86400
|
||||
) -> bool:
|
||||
"""
|
||||
Send a push notification to a single device
|
||||
|
||||
Args:
|
||||
subscription_info: Device subscription information
|
||||
data: Notification payload
|
||||
ttl: Time to live in seconds (default 24 hours)
|
||||
|
||||
Returns:
|
||||
bool: True if successful, False otherwise
|
||||
"""
|
||||
try:
|
||||
webpush(
|
||||
subscription_info=subscription_info,
|
||||
data=json.dumps(data),
|
||||
vapid_private_key=self.vapid_private_key,
|
||||
vapid_claims=self.vapid_claims,
|
||||
ttl=ttl
|
||||
)
|
||||
return True
|
||||
except WebPushException as e:
|
||||
logger.error(f"Push notification failed: {e}")
|
||||
if e.response and e.response.status_code in [410, 413]:
|
||||
# Subscription is no longer valid, should be removed
|
||||
self._remove_invalid_subscription(subscription_info)
|
||||
return False
|
||||
except Exception as e:
|
||||
logger.error(f"Unexpected error sending push notification: {e}")
|
||||
return False
|
||||
|
||||
def send_to_multiple(
|
||||
self,
|
||||
devices: List[PushNotificationDevice],
|
||||
data: Dict[str, Any],
|
||||
ttl: int = 86400
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Send push notification to multiple devices
|
||||
|
||||
Args:
|
||||
devices: List of PushNotificationDevice objects
|
||||
data: Notification payload
|
||||
ttl: Time to live in seconds
|
||||
|
||||
Returns:
|
||||
dict: Statistics of sent/failed notifications
|
||||
"""
|
||||
stats = {'sent': 0, 'failed': 0}
|
||||
|
||||
for device in devices:
|
||||
subscription_info = {
|
||||
'endpoint': device.endpoint,
|
||||
'keys': {
|
||||
'p256dh': device.p256dh_key,
|
||||
'auth': device.auth_key
|
||||
}
|
||||
}
|
||||
|
||||
if self.send_notification(subscription_info, data, ttl):
|
||||
stats['sent'] += 1
|
||||
else:
|
||||
stats['failed'] += 1
|
||||
|
||||
return stats
|
||||
|
||||
def send_announcement_notification(
|
||||
self,
|
||||
announcement,
|
||||
devices: Optional[List[PushNotificationDevice]] = None
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Send push notification for an announcement
|
||||
|
||||
Args:
|
||||
announcement: Announcement model instance
|
||||
devices: Optional list of specific devices to send to
|
||||
|
||||
Returns:
|
||||
dict: Statistics of sent/failed notifications
|
||||
"""
|
||||
if devices is None:
|
||||
# Get devices based on announcement audience
|
||||
if announcement.audience == 'all':
|
||||
devices = PushNotificationDevice.objects.filter(is_active=True)
|
||||
elif announcement.audience == 'members':
|
||||
devices = PushNotificationDevice.objects.filter(
|
||||
user__is_member=True,
|
||||
is_active=True
|
||||
)
|
||||
elif announcement.audience == 'committee':
|
||||
devices = PushNotificationDevice.objects.filter(
|
||||
user__is_committee_member=True,
|
||||
is_active=True
|
||||
)
|
||||
else:
|
||||
devices = PushNotificationDevice.objects.none()
|
||||
|
||||
# Prepare notification data
|
||||
data = {
|
||||
'title': announcement.title,
|
||||
'body': announcement.content[:100] + '...' if len(announcement.content) > 100 else announcement.content,
|
||||
'icon': '/static/images/logo.png',
|
||||
'badge': '/static/images/badge.png',
|
||||
'data': {
|
||||
'type': 'announcement',
|
||||
'id': announcement.id,
|
||||
'url': f'/announcements/{announcement.id}/'
|
||||
}
|
||||
}
|
||||
|
||||
return self.send_to_multiple(devices, data)
|
||||
|
||||
def send_event_reminder_notification(
|
||||
self,
|
||||
event,
|
||||
devices: Optional[List[PushNotificationDevice]] = None
|
||||
) -> Dict[str, int]:
|
||||
"""
|
||||
Send push notification for event reminder
|
||||
|
||||
Args:
|
||||
event: Event model instance
|
||||
devices: Optional list of specific devices to send to
|
||||
|
||||
Returns:
|
||||
dict: Statistics of sent/failed notifications
|
||||
"""
|
||||
if devices is None:
|
||||
# Get devices of registered users
|
||||
registered_users = Registration.objects.filter(
|
||||
event=event,
|
||||
status='confirmed'
|
||||
).values_list('user_id', flat=True)
|
||||
|
||||
devices = PushNotificationDevice.objects.filter(
|
||||
user_id__in=registered_users,
|
||||
is_active=True
|
||||
)
|
||||
|
||||
# Prepare notification data
|
||||
data = {
|
||||
'title': f'Event Reminder: {event.title}',
|
||||
'body': f'Your event "{event.title}" starts in 24 hours!',
|
||||
'icon': '/static/images/logo.png',
|
||||
'badge': '/static/images/badge.png',
|
||||
'data': {
|
||||
'type': 'event_reminder',
|
||||
'id': event.id,
|
||||
'url': f'/events/{event.id}/'
|
||||
}
|
||||
}
|
||||
|
||||
return self.send_to_multiple(devices, data)
|
||||
|
||||
def _remove_invalid_subscription(self, subscription_info: Dict[str, Any]):
|
||||
"""Remove invalid subscription from database"""
|
||||
try:
|
||||
PushNotificationDevice.objects.filter(
|
||||
endpoint=subscription_info['endpoint']
|
||||
).delete()
|
||||
logger.info(f"Removed invalid subscription: {subscription_info['endpoint']}")
|
||||
except Exception as e:
|
||||
logger.error(f"Error removing invalid subscription: {e}")
|
||||
|
||||
|
||||
# Create a singleton instance
|
||||
push_service = PushNotificationService()
|
||||
56
backend/communications/resources.py
Normal file
56
backend/communications/resources.py
Normal file
@@ -0,0 +1,56 @@
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
from import_export import resources, fields
|
||||
from import_export.widgets import ForeignKeyWidget
|
||||
|
||||
from communications.models import Announcement, NewsletterSubscription, PushNotificationDevice
|
||||
|
||||
User = get_user_model()
|
||||
|
||||
|
||||
class AnnouncementResource(resources.ModelResource):
|
||||
author = fields.Field(
|
||||
column_name='author',
|
||||
attribute='author',
|
||||
widget=ForeignKeyWidget(User, 'username')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
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'
|
||||
)
|
||||
export_order = fields
|
||||
|
||||
|
||||
class PushNotificationDeviceResource(resources.ModelResource):
|
||||
user = fields.Field(
|
||||
column_name='user',
|
||||
attribute='user',
|
||||
widget=ForeignKeyWidget(User, 'username')
|
||||
)
|
||||
|
||||
class Meta:
|
||||
model = PushNotificationDevice
|
||||
fields = (
|
||||
'id', 'user', 'device_type', 'is_active', 'created_at', 'updated_at'
|
||||
)
|
||||
export_order = fields
|
||||
278
backend/communications/tasks.py
Normal file
278
backend/communications/tasks.py
Normal file
@@ -0,0 +1,278 @@
|
||||
from django.utils import timezone
|
||||
from django.contrib.auth import get_user_model
|
||||
|
||||
import logging
|
||||
from celery import shared_task
|
||||
from datetime import timedelta
|
||||
|
||||
from events.models import Event, Registration
|
||||
from communications.models import Announcement, NewsletterSubscription
|
||||
from communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
|
||||
from communications.push_notifications import push_service
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
SYSTEM_USER_ID = 1
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_announcement_notifications(self, announcement_id):
|
||||
"""Send email and push notifications for an announcement"""
|
||||
try:
|
||||
announcement = Announcement.objects.get(id=announcement_id)
|
||||
|
||||
# Send email notifications
|
||||
if announcement.send_email and not announcement.email_sent:
|
||||
recipients = get_announcement_recipients(announcement)
|
||||
if recipients:
|
||||
success = send_announcement_email(announcement, recipients)
|
||||
if success:
|
||||
announcement.email_sent = True
|
||||
announcement.save()
|
||||
logger.info(f"Email notifications sent for announcement {announcement.id}")
|
||||
|
||||
# Send push notifications
|
||||
if announcement.send_push and not announcement.push_sent:
|
||||
sent_count = push_service.send_announcement_notification(announcement)
|
||||
if sent_count > 0:
|
||||
announcement.push_sent = True
|
||||
announcement.save()
|
||||
logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}")
|
||||
|
||||
return f"Notifications sent for announcement: {announcement.title}"
|
||||
|
||||
except Announcement.DoesNotExist:
|
||||
logger.error(f"Announcement {announcement_id} not found")
|
||||
return f"Announcement {announcement_id} not found"
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send announcement notifications: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_newsletter_confirmation_task(self, subscription_id):
|
||||
"""Send newsletter confirmation email"""
|
||||
try:
|
||||
from .utils import send_newsletter_confirmation
|
||||
|
||||
subscription = NewsletterSubscription.objects.get(id=subscription_id)
|
||||
success = send_newsletter_confirmation(subscription)
|
||||
|
||||
if success:
|
||||
logger.info(f"Newsletter confirmation sent to {subscription.email}")
|
||||
return f"Newsletter confirmation sent to {subscription.email}"
|
||||
else:
|
||||
raise Exception("Failed to send newsletter confirmation")
|
||||
|
||||
except NewsletterSubscription.DoesNotExist:
|
||||
logger.error(f"Newsletter subscription {subscription_id} not found")
|
||||
return f"Newsletter subscription {subscription_id} not found"
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send newsletter confirmation: {exc}")
|
||||
raise self.retry(exc=exc, countdown=60)
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_event_reminders():
|
||||
"""Send reminders for events starting about 24 hours from now within a 30-minute window."""
|
||||
try:
|
||||
reminder_target = timezone.now() + timedelta(hours=24)
|
||||
window = timedelta(minutes=30)
|
||||
start_range = reminder_target - window
|
||||
end_range = reminder_target + window
|
||||
|
||||
events = Event.objects.filter(
|
||||
start_time__range=(start_range, end_range),
|
||||
status='published',
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
total_sent = 0
|
||||
|
||||
for event in events:
|
||||
# Get confirmed registrations
|
||||
registrations = Registration.objects.filter(
|
||||
event=event,
|
||||
status='confirmed',
|
||||
is_deleted=False
|
||||
).select_related('user')
|
||||
|
||||
for registration in registrations:
|
||||
try:
|
||||
# Send email reminder
|
||||
send_event_reminder(event, registration.user)
|
||||
|
||||
# Send push notification reminder
|
||||
push_service.send_event_reminder_notification(event, registration.user)
|
||||
|
||||
total_sent += 1
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send reminder to {registration.user.email}: {str(e)}")
|
||||
|
||||
logger.info(f"Event reminders sent to {total_sent} users")
|
||||
return f"Event reminders sent to {total_sent} users"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send event reminders: {exc}")
|
||||
raise exc
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_weekly_newsletter():
|
||||
"""Send the weekly newsletter as the system user with recent announcements and upcoming events."""
|
||||
try:
|
||||
# Get active newsletter subscribers
|
||||
subscribers = NewsletterSubscription.objects.filter(
|
||||
is_active=True,
|
||||
confirmed_at__isnull=False,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
if not subscribers.exists():
|
||||
logger.info("No active newsletter subscribers found")
|
||||
return "No active newsletter subscribers found"
|
||||
|
||||
# Get recent announcements (last 7 days)
|
||||
week_ago = timezone.now() - timedelta(days=7)
|
||||
recent_announcements = Announcement.objects.filter(
|
||||
is_published=True,
|
||||
publish_date__gte=week_ago,
|
||||
announcement_type__in=['general', 'academic', 'newsletter'],
|
||||
is_deleted=False
|
||||
).order_by('-publish_date')[:5]
|
||||
|
||||
# Get upcoming events (next 14 days)
|
||||
two_weeks_ahead = timezone.now() + timedelta(days=14)
|
||||
upcoming_events = Event.objects.filter(
|
||||
start_time__range=(timezone.now(), two_weeks_ahead),
|
||||
status='published',
|
||||
is_deleted=False
|
||||
).order_by('start_time')[:5]
|
||||
|
||||
newsletter_content = f"""
|
||||
# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}
|
||||
|
||||
## Recent Announcements
|
||||
"""
|
||||
|
||||
for announcement in recent_announcements:
|
||||
newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n"
|
||||
|
||||
newsletter_content += "\n## Upcoming Events\n"
|
||||
|
||||
for event in upcoming_events:
|
||||
newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
|
||||
|
||||
if not recent_announcements.exists() and not upcoming_events.exists():
|
||||
newsletter_content += "\nNo recent announcements or upcoming events this week."
|
||||
|
||||
newsletter = Announcement.objects.create(
|
||||
title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}",
|
||||
content=newsletter_content,
|
||||
announcement_type='newsletter',
|
||||
priority='normal',
|
||||
author_id=SYSTEM_USER_ID,
|
||||
is_published=True,
|
||||
publish_date=timezone.now(),
|
||||
send_email=True,
|
||||
target_audience='subscribers'
|
||||
)
|
||||
|
||||
# Send to subscribers
|
||||
subscriber_emails = list(subscribers.values_list('email', flat=True))
|
||||
success = send_announcement_email(newsletter, subscriber_emails)
|
||||
|
||||
if success:
|
||||
newsletter.email_sent = True
|
||||
newsletter.save()
|
||||
logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers")
|
||||
return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers"
|
||||
else:
|
||||
raise Exception("Failed to send weekly newsletter")
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send weekly newsletter: {exc}")
|
||||
raise exc
|
||||
|
||||
|
||||
@shared_task
|
||||
def cleanup_expired_tokens():
|
||||
"""Clean up expired newsletter confirmation tokens"""
|
||||
try:
|
||||
# Remove unconfirmed subscriptions older than 7 days
|
||||
week_ago = timezone.now() - timedelta(days=7)
|
||||
expired_subscriptions = NewsletterSubscription.objects.filter(
|
||||
confirmed_at__isnull=True,
|
||||
created_at__lt=week_ago
|
||||
)
|
||||
|
||||
count = expired_subscriptions.count()
|
||||
expired_subscriptions.delete()
|
||||
|
||||
logger.info(f"Cleaned up {count} expired newsletter subscriptions")
|
||||
return f"Cleaned up {count} expired newsletter subscriptions"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to cleanup expired tokens: {exc}")
|
||||
raise exc
|
||||
|
||||
|
||||
@shared_task
|
||||
def send_bulk_announcement(announcement_id, recipient_emails):
|
||||
"""Send announcement to a specific list of recipients"""
|
||||
try:
|
||||
announcement = Announcement.objects.get(id=announcement_id)
|
||||
|
||||
# Split recipients into batches to avoid overwhelming the email server
|
||||
batch_size = 50
|
||||
total_sent = 0
|
||||
|
||||
for i in range(0, len(recipient_emails), batch_size):
|
||||
batch = recipient_emails[i:i + batch_size]
|
||||
success = send_announcement_email(announcement, batch)
|
||||
|
||||
if success:
|
||||
total_sent += len(batch)
|
||||
logger.info(f"Sent announcement to batch of {len(batch)} recipients")
|
||||
|
||||
# Small delay between batches
|
||||
import time
|
||||
time.sleep(1)
|
||||
|
||||
logger.info(f"Bulk announcement sent to {total_sent} recipients")
|
||||
return f"Bulk announcement sent to {total_sent} recipients"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to send bulk announcement: {exc}")
|
||||
raise exc
|
||||
|
||||
|
||||
@shared_task
|
||||
def process_scheduled_announcements():
|
||||
"""Process announcements scheduled for publication"""
|
||||
try:
|
||||
now = timezone.now()
|
||||
|
||||
# Get announcements scheduled for publication
|
||||
scheduled_announcements = Announcement.objects.filter(
|
||||
is_published=True,
|
||||
publish_date__lte=now,
|
||||
email_sent=False,
|
||||
send_email=True,
|
||||
is_deleted=False
|
||||
)
|
||||
|
||||
processed_count = 0
|
||||
|
||||
for announcement in scheduled_announcements:
|
||||
# Send notifications
|
||||
send_announcement_notifications.delay(announcement.id)
|
||||
processed_count += 1
|
||||
|
||||
logger.info(f"Processed {processed_count} scheduled announcements")
|
||||
return f"Processed {processed_count} scheduled announcements"
|
||||
|
||||
except Exception as exc:
|
||||
logger.error(f"Failed to process scheduled announcements: {exc}")
|
||||
raise exc
|
||||
140
backend/communications/utils.py
Normal file
140
backend/communications/utils.py
Normal file
@@ -0,0 +1,140 @@
|
||||
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 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