feat(backend): migrate auth and notifications off email
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-21 10:28:04 +03:30
parent b4903f7cb1
commit b7b21a6cc6
35 changed files with 2784 additions and 1390 deletions

93
.env.example Normal file
View File

@@ -0,0 +1,93 @@
# Gunicorn
GUNICORN_WORKERS=3
GUNICORN_THREADS=2
GUNICORN_TIMEOUT=120
# Django
DJANGO_SETTINGS_MODULE=config.settings.production
SECRET_KEY=replace-with-a-long-random-secret
DEBUG=False
ALLOWED_HOSTS=east-guilan-ce.ir,api.east-guilan-ce.ir,web
DJANGO_HOST=https://api.example.com
SITE_URL=https://api.example.com
# Database
DB_ENGINE=django.db.backends.postgresql
DB_NAME=cs_association
DB_USER=postgres
DB_PASSWORD=change-me
DB_HOST=db
DB_PORT=5432
# Redis / Celery
REDIS_PASSWORD=change-me
REDIS_URL=redis://:change-me@redis:6379/0
CELERY_BROKER_URL=redis://:change-me@redis:6379/0
CELERY_RESULT_BACKEND=redis://:change-me@redis:6379/1
# Email
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_SSL=False
EMAIL_USE_TLS=True
EMAIL_HOST_USER=smtp-user
EMAIL_HOST_PASSWORD=smtp-password
DEFAULT_FROM_EMAIL=admin@example.com
# SMS.ir
SMS_APIKEY=replace-with-sms-ir-api-key
SMS_AUTH_OTP_TEMPLATE_ID=100000
SMS_EVENT_CANCELLATION_TEMPLATE_ID=100001
SMS_EVENT_RESCHEDULE_TEMPLATE_ID=100002
SMS_PAYMENT_STATUS_TEMPLATE_ID=100003
# Google OAuth
GOOGLE_OAUTH_CLIENT_ID=replace-with-google-client-id
GOOGLE_OAUTH_CLIENT_SECRET=replace-with-google-client-secret
GOOGLE_OAUTH_REDIRECT_URI=https://api.example.com/api/auth/oauth/google/callback
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL=https://frontend.example.com/auth/google/callback
# JWT
JWT_SECRET_KEY=replace-with-a-second-long-random-secret
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_LIFETIME=3600
JWT_REFRESH_TOKEN_LIFETIME=86400
# Frontend integration
CORS_ALLOWED_ORIGINS=https://frontend.example.com,https://api.example.com
CSRF_TRUSTED_ORIGINS=https://frontend.example.com,https://api.example.com
CORS_ALLOW_CREDENTIALS=True
CSRF_COOKIE_SECURE=True
SESSION_COOKIE_SECURE=True
FRONTEND_ROOT=https://frontend.example.com
FRONTEND_PASSWORD_RESET_PAGE=https://frontend.example.com/reset-password
FRONTEND_CALLBACK_URL=https://frontend.example.com/payments/result
# SSE Notifications
NOTIFICATIONS_ENABLED=True
NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS=300
NOTIFICATION_SSE_HEARTBEAT_SECONDS=20
NOTIFICATION_SSE_RETRY_MS=3000
NOTIFICATION_REDIS_CHANNEL_PREFIX=notif
NOTIFICATION_RETENTION_DAYS=30
NOTIFICATION_DEFAULT_PAGE_SIZE=20
NOTIFICATION_MAX_PAGE_SIZE=100
# Optional web-push settings kept for legacy admin flows
VAPID_PUBLIC_KEY=
VAPID_PRIVATE_KEY=
VAPID_SUBJECT=mailto:admin@example.com
# ZarinPal
ZARINPAL_MERCHANT_ID=merchant-id
ZARINPAL_USE_SANDBOX=False
ZARINPAL_CALLBACK_URL=https://api.example.com/api/payments/callback
# Optional test overrides
TEST_DB_ENGINE=django.db.backends.sqlite3
TEST_DB_NAME=db.test.sqlite3
TEST_DB_USER=
TEST_DB_PASSWORD=
TEST_DB_HOST=
TEST_DB_PORT=

View File

@@ -1,60 +0,0 @@
# Gunicorn
GUNICORN_WORKERS=3
GUNICORN_THREADS=2
GUNICORN_TIMEOUT=120
# Django
DJANGO_SETTINGS_MODULE=config.settings.production
SECRET_KEY=replace-me
DEBUG=False
ALLOWED_HOSTS=api-host.example
DJANGO_HOST=https://api-host.example
# Database
DB_ENGINE=django.db.backends.postgresql
DB_NAME=app
DB_USER=app
DB_PASSWORD=change-me
DB_HOST=db
DB_PORT=5432
# Redis / Celery
REDIS_PASSWORD=change-me
REDIS_URL=redis://:change-me@redis:6379/0
CELERY_BROKER_URL=redis://:change-me@redis:6379/0
CELERY_RESULT_BACKEND=redis://:change-me@redis:6379/1
# Email
EMAIL_BACKEND=django.core.mail.backends.smtp.EmailBackend
EMAIL_HOST=smtp.example.com
EMAIL_PORT=587
EMAIL_USE_TLS=True
EMAIL_HOST_USER=smtp-user
EMAIL_HOST_PASSWORD=smtp-password
DEFAULT_FROM_EMAIL=noreply@example.com
# JWT
JWT_SECRET_KEY=replace-me
JWT_ALGORITHM=HS256
JWT_ACCESS_TOKEN_LIFETIME=3600
JWT_REFRESH_TOKEN_LIFETIME=86400
# Frontend integration
CORS_ALLOWED_ORIGINS=https://frontend-host.example
FRONTEND_ROOT=https://frontend-host.example
FRONTEND_PASSWORD_RESET_PAGE=https://frontend-host.example/reset-password
FRONTEND_CALLBACK_URL=https://frontend-host.example/payments/result
# ZarinPal
ZARINPAL_MERCHANT_ID=merchant-id
ZARINPAL_USE_SANDBOX=False
ZARINPAL_CALLBACK_URL=https://api-host.example/api/payments/callback
# Optional test overrides
TEST_DB_ENGINE=django.db.backends.sqlite3
TEST_DB_NAME=db.test.sqlite3
TEST_DB_USER=
TEST_DB_PASSWORD=
TEST_DB_HOST=
TEST_DB_PORT=

1
.gitignore vendored
View File

@@ -100,6 +100,7 @@ celerybeat-schedule.*
# Environments
.env
.env.prod
.venv
env/
venv/

View File

@@ -28,7 +28,7 @@ guilan-ace-backend/
python -m venv .venv
.venv\\Scripts\\activate
pip install -r requirements.txt
cp .env.sample .env
cp .env.example .env
python manage.py migrate
python manage.py runserver
```
@@ -42,4 +42,3 @@ python manage.py test --settings=config.settings.test
- Domain routers live under `apps/<domain>/api`.
- Shared auth helpers live in `core/authentication.py`.
- Shared base models, admin helpers, choices, and template tags live under `core/`.

View File

@@ -1,34 +1,58 @@
"""Schemas for communications-related endpoints."""
from datetime import datetime
from typing import Optional, List
from typing import Optional
from ninja import Schema, ModelSchema
from ninja import ModelSchema, Schema
from apps.blog.api.schemas import AuthorSchema
from apps.communications.models import (
Announcement,
NewsletterSubscription,
PushNotificationDevice
)
from apps.communications.models import Announcement, PushNotificationDevice
class AnnouncementSchema(ModelSchema):
author: AuthorSchema
content_html: str
deliver_in_app: bool
deliver_sms: bool
in_app_sent: bool
sms_sent: bool
class Config:
model = Announcement
model_fields = [
'id', 'title', 'content', 'announcement_type', 'priority',
'is_published', 'publish_date', 'send_email', 'send_push',
'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at'
"id",
"title",
"content",
"announcement_type",
"priority",
"is_published",
"publish_date",
"target_audience",
"created_at",
"updated_at",
]
@staticmethod
def resolve_content_html(obj):
return obj.content_html
@staticmethod
def resolve_deliver_in_app(obj):
return obj.send_email
@staticmethod
def resolve_deliver_sms(obj):
return obj.send_push
@staticmethod
def resolve_in_app_sent(obj):
return obj.email_sent
@staticmethod
def resolve_sms_sent(obj):
return obj.push_sent
class AnnouncementListSchema(Schema):
id: int
title: str
@@ -39,8 +63,11 @@ class AnnouncementListSchema(Schema):
is_published: bool
publish_date: Optional[datetime] = None
target_audience: str
deliver_in_app: bool
deliver_sms: bool
created_at: datetime
class AnnouncementCreateSchema(Schema):
title: str
content: str
@@ -49,8 +76,9 @@ class AnnouncementCreateSchema(Schema):
target_audience: str = "all"
is_published: bool = False
publish_date: Optional[datetime] = None
send_email: bool = False
send_push: bool = False
deliver_in_app: bool = True
deliver_sms: bool = False
class AnnouncementUpdateSchema(Schema):
title: Optional[str] = None
@@ -60,65 +88,43 @@ class AnnouncementUpdateSchema(Schema):
target_audience: Optional[str] = None
is_published: Optional[bool] = None
publish_date: Optional[datetime] = None
send_email: Optional[bool] = None
send_push: Optional[bool] = None
deliver_in_app: Optional[bool] = None
deliver_sms: Optional[bool] = None
class NewsletterSubscriptionSchema(ModelSchema):
user: Optional[AuthorSchema] = None
class Config:
model = NewsletterSubscription
model_fields = [
'id', 'email', 'is_active', 'subscribed_categories',
'confirmed_at', 'created_at'
]
class NewsletterSubscribeSchema(Schema):
email: str
subscribed_categories: Optional[List[str]] = []
class NewsletterUnsubscribeSchema(Schema):
email: str
class PushDeviceSchema(ModelSchema):
user: AuthorSchema
class Config:
model = PushNotificationDevice
model_fields = [
'id', 'device_token', 'device_type', 'is_active', 'created_at'
]
model_fields = ["id", "device_token", "device_type", "is_active", "created_at"]
class PushDeviceCreateSchema(Schema):
device_token: str
device_type: str = "web"
class PushDeviceUpdateSchema(Schema):
is_active: bool
class PushNotificationSchema(Schema):
title: str
body: str
data: Optional[dict] = None
target_audience: str = "all"
class MessageResponseSchema(Schema):
"""Simple message payload for API responses."""
message: str
success: bool = True
class AnnouncementStatsSchema(Schema):
"""Summary statistics for announcements."""
total_announcements: int
published_announcements: int
draft_announcements: int
urgent_announcements: int
email_sent_count: int
push_sent_count: int
class NewsletterStatsSchema(Schema):
"""Summary statistics for newsletter subscriptions."""
total_subscriptions: int
active_subscriptions: int
confirmed_subscriptions: int
recent_subscriptions: int
in_app_sent_count: int
sms_sent_count: int

View File

@@ -1,275 +1,191 @@
from django.shortcuts import get_object_or_404
from typing import List
from django.contrib.auth import get_user_model
from django.db.models import Count, Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.db.models import Q, Count
from ninja import Router
from ninja.pagination import paginate
from typing import List
import logging
from apps.communications.models import (
Announcement, NewsletterSubscription, PushNotificationDevice,
AnnouncementType, AnnouncementPriority
)
from apps.communications.utils import (
send_announcement_email, send_newsletter_confirmation,
get_announcement_recipients
)
from apps.communications.push_notifications import push_service
from apps.communications.api.schemas import (
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
PushNotificationSchema, MessageResponseSchema,
AnnouncementStatsSchema, NewsletterStatsSchema
AnnouncementCreateSchema,
AnnouncementListSchema,
AnnouncementSchema,
AnnouncementStatsSchema,
MessageResponseSchema,
PushDeviceCreateSchema,
PushDeviceSchema,
PushDeviceUpdateSchema,
PushNotificationSchema,
)
from apps.communications.models import Announcement, AnnouncementPriority, AnnouncementType, PushNotificationDevice
from apps.communications.push_notifications import push_service
from apps.communications.tasks import send_announcement_notifications
from core.authentication import jwt_auth
User = get_user_model()
logger = logging.getLogger(__name__)
communications_router = Router()
# Announcement endpoints
def _is_staff_user(user) -> bool:
return bool(user and (user.is_staff or user.is_superuser))
@communications_router.get("/announcements/", response=List[AnnouncementListSchema])
@paginate
def list_announcements(request, published_only: bool = True):
"""List announcements"""
queryset = Announcement.objects.select_related('author').filter(is_deleted=False)
queryset = Announcement.objects.select_related("author").filter(is_deleted=False)
if published_only:
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now())
return queryset.order_by('-created_at')
items = []
for announcement in queryset.order_by("-created_at"):
items.append(
{
"id": announcement.id,
"title": announcement.title,
"content": announcement.content,
"announcement_type": announcement.announcement_type,
"priority": announcement.priority,
"author": announcement.author,
"is_published": announcement.is_published,
"publish_date": announcement.publish_date,
"target_audience": announcement.target_audience,
"deliver_in_app": announcement.send_email,
"deliver_sms": announcement.send_push,
"created_at": announcement.created_at,
}
)
return items
@communications_router.get("/announcements/{announcement_id}/", response=AnnouncementSchema)
def get_announcement(request, announcement_id: int):
"""Get single announcement"""
announcement = get_object_or_404(
Announcement.objects.select_related('author').filter(is_deleted=False),
id=announcement_id
Announcement.objects.select_related("author").filter(is_deleted=False),
id=announcement_id,
)
# Check if published or user has permission
if not announcement.is_published:
# Only allow access to unpublished announcements for staff/committee
if not hasattr(request, 'auth') or not request.auth:
if not announcement.is_published and not _is_staff_user(getattr(request, "auth", None)):
return {"error": "Announcement not found"}, 404
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Announcement not found"}, 404
return announcement
@communications_router.post("/announcements/", response=AnnouncementSchema, auth=jwt_auth)
def create_announcement(request, payload: AnnouncementCreateSchema):
"""Create new announcement (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
announcement = Announcement.objects.create(
author=user,
**payload.dict()
title=payload.title,
content=payload.content,
announcement_type=payload.announcement_type,
priority=payload.priority,
target_audience=payload.target_audience,
is_published=payload.is_published,
publish_date=payload.publish_date,
send_email=payload.deliver_in_app,
send_push=payload.deliver_sms,
)
# Send notifications if requested and published
if announcement.is_published and announcement.publish_date <= timezone.now():
if announcement.send_email:
recipients = get_announcement_recipients(announcement)
if recipients:
send_announcement_email(announcement, recipients)
announcement.email_sent = True
if announcement.send_push:
push_service.send_announcement_notification(announcement)
announcement.push_sent = True
announcement.save()
if announcement.is_published and (announcement.publish_date is None or announcement.publish_date <= timezone.now()):
send_announcement_notifications.delay(announcement.id)
return announcement
@communications_router.put("/announcements/{announcement_id}/", response=AnnouncementSchema, auth=jwt_auth)
def update_announcement(request, announcement_id: int, payload: AnnouncementUpdateSchema):
"""Update announcement (author/committee/staff only)"""
def update_announcement(request, announcement_id: int, payload: AnnouncementCreateSchema):
user = request.auth
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
# Check permissions
if not (user.is_staff or user.is_committee or announcement.author == user):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
# Update fields
for field, value in payload.dict(exclude_unset=True).items():
setattr(announcement, field, value)
announcement.save()
# Send notifications if newly published
if (announcement.is_published and announcement.publish_date <= timezone.now() and
not announcement.email_sent and announcement.send_email):
recipients = get_announcement_recipients(announcement)
if recipients:
send_announcement_email(announcement, recipients)
announcement.email_sent = True
announcement.save()
if (announcement.is_published and announcement.publish_date <= timezone.now() and
not announcement.push_sent and announcement.send_push):
push_service.send_announcement_notification(announcement)
announcement.push_sent = True
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
announcement.title = payload.title
announcement.content = payload.content
announcement.announcement_type = payload.announcement_type
announcement.priority = payload.priority
announcement.target_audience = payload.target_audience
announcement.is_published = payload.is_published
announcement.publish_date = payload.publish_date
announcement.send_email = payload.deliver_in_app
announcement.send_push = payload.deliver_sms
announcement.email_sent = False
announcement.push_sent = False
announcement.save()
if announcement.is_published and (announcement.publish_date is None or announcement.publish_date <= timezone.now()):
send_announcement_notifications.delay(announcement.id)
return announcement
@communications_router.delete("/announcements/{announcement_id}/", response=MessageResponseSchema, auth=jwt_auth)
def delete_announcement(request, announcement_id: int):
"""Delete announcement (author/committee/staff only)"""
user = request.auth
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
# Check permissions
if not (user.is_staff or user.is_committee or announcement.author == user):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
announcement = get_object_or_404(Announcement, id=announcement_id, is_deleted=False)
announcement.soft_delete()
return {"message": "Announcement deleted successfully"}
@communications_router.get("/announcements/stats/", response=AnnouncementStatsSchema, auth=jwt_auth)
def get_announcement_stats(request):
"""Get announcement statistics (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
stats = Announcement.objects.filter(is_deleted=False).aggregate(
total_announcements=Count('id'),
published_announcements=Count('id', filter=Q(is_published=True)),
draft_announcements=Count('id', filter=Q(is_published=False)),
urgent_announcements=Count('id', filter=Q(priority='urgent')),
email_sent_count=Count('id', filter=Q(email_sent=True)),
push_sent_count=Count('id', filter=Q(push_sent=True))
total_announcements=Count("id"),
published_announcements=Count("id", filter=Q(is_published=True)),
draft_announcements=Count("id", filter=Q(is_published=False)),
urgent_announcements=Count("id", filter=Q(priority="urgent")),
in_app_sent_count=Count("id", filter=Q(email_sent=True)),
sms_sent_count=Count("id", filter=Q(push_sent=True)),
)
return stats
# Newsletter endpoints
@communications_router.post("/newsletter/subscribe/", response=MessageResponseSchema)
def subscribe_newsletter(request, payload: NewsletterSubscribeSchema):
"""Subscribe to newsletter"""
try:
subscription, created = NewsletterSubscription.objects.get_or_create(
email=payload.email,
defaults={
'subscribed_categories': payload.subscribed_categories,
'is_active': True
def subscribe_newsletter(request):
return {
"message": "خبرنامه ایمیلی حذف شده است. اطلاع‌رسانی‌ها از این پس درون‌سایتی و در موارد مهم از طریق پیامک انجام می‌شود."
}
)
if not created and not subscription.is_active:
subscription.is_active = True
subscription.subscribed_categories = payload.subscribed_categories
subscription.save()
# Send confirmation email
send_newsletter_confirmation(subscription)
message = (
"عضویت در خبرنامه با موفقیت انجام شد! لطفاً برای تأیید، ایمیل خود را بررسی کنید."
if created
else "اشتراک خبرنامه به‌روزرسانی شد!"
)
return {"message": message}
except Exception as e:
logger.error(f"Newsletter subscription failed: {str(e)}")
return {"message": "Subscription failed", "success": False}, 400
@communications_router.post("/newsletter/unsubscribe/", response=MessageResponseSchema)
def unsubscribe_newsletter(request, payload: NewsletterUnsubscribeSchema):
"""Unsubscribe from newsletter"""
try:
subscription = NewsletterSubscription.objects.get(email=payload.email)
subscription.is_active = False
subscription.save()
return {"message": "Successfully unsubscribed from newsletter"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Email not found in subscription list"}, 404
def unsubscribe_newsletter(request):
return {"message": "خبرنامه ایمیلی دیگر فعال نیست."}
@communications_router.get("/newsletter/confirm/{token}/", response=MessageResponseSchema)
def confirm_newsletter_subscription(request, token: str):
"""Confirm newsletter subscription"""
try:
subscription = NewsletterSubscription.objects.get(confirmation_token=token)
subscription.confirmed_at = timezone.now()
subscription.is_active = True
subscription.save()
return {"message": "Newsletter subscription confirmed successfully!"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Invalid confirmation token"}, 400
return {"message": "تایید خبرنامه ایمیلی دیگر لازم نیست."}
@communications_router.get("/newsletter/unsubscribe/{token}/", response=MessageResponseSchema)
def unsubscribe_newsletter_token(request, token: str):
"""Unsubscribe using token from email"""
try:
subscription = NewsletterSubscription.objects.get(unsubscribe_token=token)
subscription.is_active = False
subscription.save()
return {"message": "Successfully unsubscribed from newsletter"}
except NewsletterSubscription.DoesNotExist:
return {"message": "Invalid unsubscribe token"}, 400
return {"message": "خبرنامه ایمیلی دیگر فعال نیست."}
@communications_router.get("/newsletter/subscriptions/", response=List[NewsletterSubscriptionSchema], auth=jwt_auth)
@paginate
def list_newsletter_subscriptions(request):
"""List newsletter subscriptions (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
return NewsletterSubscription.objects.select_related('user').filter(is_deleted=False).order_by('-created_at')
@communications_router.get("/newsletter/stats/", response=NewsletterStatsSchema, auth=jwt_auth)
def get_newsletter_stats(request):
"""Get newsletter statistics (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
return {"error": "Permission denied"}, 403
stats = NewsletterSubscription.objects.filter(is_deleted=False).aggregate(
total_subscriptions=Count('id'),
active_subscriptions=Count('id', filter=Q(is_active=True)),
confirmed_subscriptions=Count('id', filter=Q(confirmed_at__isnull=False)),
recent_subscriptions=Count('id', filter=Q(created_at__gte=timezone.now() - timezone.timedelta(days=30)))
)
return stats
# Push notification endpoints
@communications_router.post("/push-devices/", response=PushDeviceSchema, auth=jwt_auth)
def register_push_device(request, payload: PushDeviceCreateSchema):
"""Register push notification device"""
user = request.auth
device, created = PushNotificationDevice.objects.get_or_create(
user=user,
device_token=payload.device_token,
defaults={'device_type': payload.device_type, 'is_active': True}
defaults={"device_type": payload.device_type, "is_active": True},
)
if not created:
device.is_active = True
device.device_type = payload.device_type
device.save()
return device
@communications_router.delete("/push-devices/", response=MessageResponseSchema, auth=jwt_auth)
def unregister_push_device(request, device_token: str):
"""Unregister push notification device"""
user = request.auth
try:
device = PushNotificationDevice.objects.get(user=user, device_token=device_token)
device.delete()
@@ -277,53 +193,37 @@ def unregister_push_device(request, device_token: str):
except PushNotificationDevice.DoesNotExist:
return {"message": "Device not found"}, 404
@communications_router.get("/push-devices/", response=List[PushDeviceSchema], auth=jwt_auth)
def list_user_push_devices(request):
"""List user's push notification devices"""
user = request.auth
return PushNotificationDevice.objects.filter(user=user, is_deleted=False).order_by('-created_at')
return PushNotificationDevice.objects.filter(user=request.auth, is_deleted=False).order_by("-created_at")
@communications_router.put("/push-devices/{device_id}/", response=PushDeviceSchema, auth=jwt_auth)
def update_push_device(request, device_id: int, payload: PushDeviceUpdateSchema):
"""Update push notification device"""
user = request.auth
device = get_object_or_404(PushNotificationDevice, id=device_id, user=user, is_deleted=False)
device = get_object_or_404(PushNotificationDevice, id=device_id, user=request.auth, is_deleted=False)
device.is_active = payload.is_active
device.save()
return device
@communications_router.post("/push-notifications/send/", response=MessageResponseSchema, auth=jwt_auth)
def send_push_notification(request, payload: PushNotificationSchema):
"""Send push notification (committee/staff only)"""
user = request.auth
if not (user.is_staff or user.is_committee):
if not _is_staff_user(user):
return {"error": "Permission denied"}, 403
# Get target users
users = []
if payload.target_audience == 'all':
users = User.objects.filter(is_active=True)
elif payload.target_audience == 'members':
users = User.objects.filter(is_member=True, is_active=True)
elif payload.target_audience == 'committee':
users = User.objects.filter(is_committee=True, is_active=True)
# Send notifications
total_sent = push_service.send_to_multiple_users(
users, payload.title, payload.body, payload.data
)
if payload.target_audience == "committee":
users = users.filter(is_staff=True)
total_sent = push_service.send_to_multiple_users(users, payload.title, payload.body, payload.data)
return {"message": f"Push notification sent to {total_sent} devices"}
# Utility endpoints
@communications_router.get("/announcement-types/", response=List[dict])
def get_announcement_types(request):
"""Get available announcement types"""
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementType.choices]
@communications_router.get("/announcement-priorities/", response=List[dict])
def get_announcement_priorities(request):
"""Get available announcement priorities"""
return [{"value": choice[0], "label": choice[1]} for choice in AnnouncementPriority.choices]

View File

@@ -1,278 +1,174 @@
from django.utils import timezone
from django.contrib.auth import get_user_model
import logging
from celery import shared_task
from datetime import timedelta
from celery import shared_task
from django.contrib.auth import get_user_model
from django.utils import timezone
import logging
from apps.communications.models import Announcement
from apps.events.models import Event, Registration
from apps.communications.models import Announcement, NewsletterSubscription
from apps.communications.utils import send_announcement_email, send_event_reminder, get_announcement_recipients
from apps.communications.push_notifications import push_service
from apps.notifications.services import notify_user
from apps.users.tasks import send_critical_sms
User = get_user_model()
logger = logging.getLogger(__name__)
SYSTEM_USER_ID = 1
def _audience_queryset(target_audience: str):
qs = User.objects.filter(is_active=True, is_deleted=False)
if target_audience == "committee":
return qs.filter(is_staff=True)
if target_audience == "members":
return qs
return qs
def _dispatch_announcement(announcement: Announcement) -> tuple[int, int]:
in_app_count = 0
sms_count = 0
users = _audience_queryset(announcement.target_audience)
action_url = "/announcements"
for user in users.iterator():
if announcement.send_email:
notify_user(
user.id,
{
"type": "announcement",
"title": announcement.title,
"message": announcement.content[:500],
"level": "warning" if announcement.priority == "urgent" else "info",
"action_url": action_url,
"entity_type": "announcement",
"entity_id": announcement.id,
},
)
in_app_count += 1
if announcement.send_push and user.mobile and user.is_mobile_verified:
send_critical_sms.delay(user.mobile, "event_reschedule", announcement.title)
sms_count += 1
if announcement.send_email:
announcement.email_sent = True
if announcement.send_push:
announcement.push_sent = True
announcement.save(update_fields=["email_sent", "push_sent"])
return in_app_count, sms_count
@shared_task(bind=True, max_retries=3)
def send_announcement_notifications(self, announcement_id):
"""Send email and push notifications for an announcement"""
try:
announcement = Announcement.objects.get(id=announcement_id)
# Send email notifications
if announcement.send_email and not announcement.email_sent:
recipients = get_announcement_recipients(announcement)
if recipients:
success = send_announcement_email(announcement, recipients)
if success:
announcement.email_sent = True
announcement.save()
logger.info(f"Email notifications sent for announcement {announcement.id}")
# Send push notifications
if announcement.send_push and not announcement.push_sent:
sent_count = push_service.send_announcement_notification(announcement)
if sent_count > 0:
announcement.push_sent = True
announcement.save()
logger.info(f"Push notifications sent to {sent_count} devices for announcement {announcement.id}")
return f"Notifications sent for announcement: {announcement.title}"
in_app_count, sms_count = _dispatch_announcement(announcement)
logger.info(
"Announcement %s dispatched in_app=%s sms=%s",
announcement.id,
in_app_count,
sms_count,
)
return f"Announcement dispatched: {announcement.title}"
except Announcement.DoesNotExist:
logger.error(f"Announcement {announcement_id} not found")
logger.error("Announcement %s not found", announcement_id)
return f"Announcement {announcement_id} not found"
except Exception as exc:
logger.error(f"Failed to send announcement notifications: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_newsletter_confirmation_task(self, subscription_id):
"""Send newsletter confirmation email"""
try:
from .utils import send_newsletter_confirmation
subscription = NewsletterSubscription.objects.get(id=subscription_id)
success = send_newsletter_confirmation(subscription)
if success:
logger.info(f"Newsletter confirmation sent to {subscription.email}")
return f"Newsletter confirmation sent to {subscription.email}"
else:
raise Exception("Failed to send newsletter confirmation")
except NewsletterSubscription.DoesNotExist:
logger.error(f"Newsletter subscription {subscription_id} not found")
return f"Newsletter subscription {subscription_id} not found"
except Exception as exc:
logger.error(f"Failed to send newsletter confirmation: {exc}")
logger.error("Failed to send announcement notifications: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task
def send_event_reminders():
"""Send reminders for events starting about 24 hours from now within a 30-minute window."""
try:
reminder_target = timezone.now() + timedelta(hours=24)
window = timedelta(minutes=30)
start_range = reminder_target - window
end_range = reminder_target + window
events = Event.objects.filter(
start_time__range=(start_range, end_range),
status='published',
is_deleted=False
start_time__range=(reminder_target - window, reminder_target + window),
status="published",
is_deleted=False,
)
total_sent = 0
for event in events:
# Get confirmed registrations
registrations = Registration.objects.filter(
event=event,
status='confirmed',
is_deleted=False
).select_related('user')
status=Registration.StatusChoices.CONFIRMED,
is_deleted=False,
).select_related("user")
for registration in registrations:
try:
# Send email reminder
send_event_reminder(event, registration.user)
# Send push notification reminder
push_service.send_event_reminder_notification(event, registration.user)
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
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")
logger.info("Event reminders sent to %s users", total_sent)
return f"Event reminders sent to {total_sent} users"
except Exception as exc:
logger.error(f"Failed to send event reminders: {exc}")
raise exc
logger.error("Failed to send event reminders: %s", exc)
raise
@shared_task
def send_weekly_newsletter():
"""Send the weekly newsletter as the system user with recent announcements and upcoming events."""
try:
# Get active newsletter subscribers
subscribers = NewsletterSubscription.objects.filter(
is_active=True,
confirmed_at__isnull=False,
is_deleted=False
)
if not subscribers.exists():
logger.info("No active newsletter subscribers found")
return "No active newsletter subscribers found"
# Get recent announcements (last 7 days)
week_ago = timezone.now() - timedelta(days=7)
recent_announcements = Announcement.objects.filter(
is_published=True,
publish_date__gte=week_ago,
announcement_type__in=['general', 'academic', 'newsletter'],
is_deleted=False
).order_by('-publish_date')[:5]
# Get upcoming events (next 14 days)
two_weeks_ahead = timezone.now() + timedelta(days=14)
upcoming_events = Event.objects.filter(
start_time__range=(timezone.now(), two_weeks_ahead),
status='published',
is_deleted=False
).order_by('start_time')[:5]
newsletter_content = f"""
# Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}
## Recent Announcements
"""
for announcement in recent_announcements:
newsletter_content += f"- **{announcement.title}** ({announcement.publish_date.strftime('%B %d')})\n"
newsletter_content += "\n## Upcoming Events\n"
for event in upcoming_events:
newsletter_content += f"- **{event.title}** - {event.start_time.strftime('%B %d, %Y at %I:%M %p')}\n"
if not recent_announcements.exists() and not upcoming_events.exists():
newsletter_content += "\nNo recent announcements or upcoming events this week."
newsletter = Announcement.objects.create(
title=f"Weekly Newsletter - {timezone.now().strftime('%B %d, %Y')}",
content=newsletter_content,
announcement_type='newsletter',
priority='normal',
author_id=SYSTEM_USER_ID,
is_published=True,
publish_date=timezone.now(),
send_email=True,
target_audience='subscribers'
)
# Send to subscribers
subscriber_emails = list(subscribers.values_list('email', flat=True))
success = send_announcement_email(newsletter, subscriber_emails)
if success:
newsletter.email_sent = True
newsletter.save()
logger.info(f"Weekly newsletter sent to {len(subscriber_emails)} subscribers")
return f"Weekly newsletter sent to {len(subscriber_emails)} subscribers"
else:
raise Exception("Failed to send weekly newsletter")
except Exception as exc:
logger.error(f"Failed to send weekly newsletter: {exc}")
raise exc
logger.info("Weekly newsletter task skipped because newsletter delivery has been removed.")
return "Newsletter delivery removed"
@shared_task
def cleanup_expired_tokens():
"""Clean up expired newsletter confirmation tokens"""
try:
# Remove unconfirmed subscriptions older than 7 days
week_ago = timezone.now() - timedelta(days=7)
expired_subscriptions = NewsletterSubscription.objects.filter(
confirmed_at__isnull=True,
created_at__lt=week_ago
)
count = expired_subscriptions.count()
expired_subscriptions.delete()
logger.info(f"Cleaned up {count} expired newsletter subscriptions")
return f"Cleaned up {count} expired newsletter subscriptions"
except Exception as exc:
logger.error(f"Failed to cleanup expired tokens: {exc}")
raise exc
logger.info("Newsletter cleanup skipped because newsletter delivery has been removed.")
return "Newsletter delivery removed"
@shared_task
def send_bulk_announcement(announcement_id, recipient_emails):
"""Send announcement to a specific list of recipients"""
try:
announcement = Announcement.objects.get(id=announcement_id)
# Split recipients into batches to avoid overwhelming the email server
batch_size = 50
total_sent = 0
for i in range(0, len(recipient_emails), batch_size):
batch = recipient_emails[i:i + batch_size]
success = send_announcement_email(announcement, batch)
if success:
total_sent += len(batch)
logger.info(f"Sent announcement to batch of {len(batch)} recipients")
# Small delay between batches
import time
time.sleep(1)
logger.info(f"Bulk announcement sent to {total_sent} recipients")
return f"Bulk announcement sent to {total_sent} recipients"
users = User.objects.filter(email__in=recipient_emails, is_active=True)
total = 0
for user in users.iterator():
notify_user(
user.id,
{
"type": "announcement",
"title": announcement.title,
"message": announcement.content[:500],
"level": "warning" if announcement.priority == "urgent" else "info",
"entity_type": "announcement",
"entity_id": announcement.id,
},
)
total += 1
return f"Bulk announcement sent to {total} users"
except Exception as exc:
logger.error(f"Failed to send bulk announcement: {exc}")
raise exc
logger.error("Failed to send bulk announcement: %s", exc)
raise
@shared_task
def process_scheduled_announcements():
"""Process announcements scheduled for publication"""
try:
now = timezone.now()
# Get announcements scheduled for publication
scheduled_announcements = Announcement.objects.filter(
is_published=True,
publish_date__lte=now,
email_sent=False,
send_email=True,
is_deleted=False
is_deleted=False,
)
processed_count = 0
for announcement in scheduled_announcements:
# Send notifications
send_announcement_notifications.delay(announcement.id)
processed_count += 1
logger.info(f"Processed {processed_count} scheduled announcements")
logger.info("Processed %s scheduled announcements", processed_count)
return f"Processed {processed_count} scheduled announcements"
except Exception as exc:
logger.error(f"Failed to process scheduled announcements: {exc}")
raise exc
logger.error("Failed to process scheduled announcements: %s", exc)
raise

View File

@@ -1,3 +1,4 @@
from django.conf import settings
from django.shortcuts import get_object_or_404
from django.db.models import Q, Case, When, IntegerField
from django.utils.text import slugify
@@ -25,11 +26,52 @@ from apps.events.api.schemas import (
)
from core.authentication import jwt_auth
from apps.events.models import Event, Registration
from apps.notifications.services import notify_user
from apps.payments.models import DiscountCode
from apps.users.tasks import send_critical_sms
from core.api.schemas import ErrorSchema, MessageSchema
events_router = Router()
def _frontend_event_url(event: Event) -> str:
root = getattr(settings, "FRONTEND_ROOT", "/") or "/"
if not root.endswith("/"):
root = f"{root}/"
return f"{root}events/{event.slug or event.id}"
def _notify_event_update(
event: Event,
*,
notification_type: str,
title: str,
message: str,
level: str,
sms_kind: str | None = None,
):
recipients = (
Registration.objects.filter(event=event, is_deleted=False)
.exclude(status=Registration.StatusChoices.CANCELLED)
.select_related("user")
)
for registration in recipients:
notify_user(
registration.user_id,
{
"type": notification_type,
"title": title,
"message": message,
"level": level,
"action_url": _frontend_event_url(event),
"entity_type": "event",
"entity_id": event.id,
"meta": {"event_status": event.status},
},
)
if sms_kind and registration.user.mobile and registration.user.is_mobile_verified:
send_critical_sms.delay(registration.user.mobile, sms_kind, event.title)
# Event endpoints
@events_router.get("/", response=List[EventListSchema])
def list_events(
@@ -103,6 +145,14 @@ def create_event(request, payload: EventCreateSchema):
def update_event(request, event_id: int, payload: EventUpdateSchema):
"""Update an existing event"""
event = get_object_or_404(Event, id=event_id, is_deleted=False)
previous_state = {
"status": event.status,
"start_time": event.start_time,
"end_time": event.end_time,
"address": event.address,
"location": event.location,
"online_link": event.online_link,
}
update_data = payload.dict(exclude_unset=True)
gallery_image_ids = update_data.pop('gallery_image_ids', None)
@@ -118,6 +168,34 @@ def update_event(request, event_id: int, payload: EventUpdateSchema):
if gallery_image_ids is not None:
event.gallery_images.set(gallery_image_ids)
schedule_changed = any(
previous_state[field] != getattr(event, field)
for field in ("start_time", "end_time", "address", "location", "online_link")
)
cancelled_now = (
previous_state["status"] != Event.StatusChoices.CANCELLED
and event.status == Event.StatusChoices.CANCELLED
)
if cancelled_now:
_notify_event_update(
event,
notification_type="event_cancelled",
title=f"رویداد {event.title} لغو شد",
message="این رویداد لغو شده است. برای جزئیات بیشتر صفحه رویداد را بررسی کنید.",
level="warning",
sms_kind="event_cancellation",
)
elif schedule_changed:
_notify_event_update(
event,
notification_type="event_rescheduled",
title=f"زمان یا محل {event.title} تغییر کرد",
message="جزئیات زمان‌بندی یا محل برگزاری این رویداد به‌روزرسانی شده است.",
level="info",
sms_kind="event_reschedule",
)
return event
@events_router.delete("/{int:event_id}", response=MessageSchema)

View File

@@ -1,233 +1,127 @@
from django.core.mail import EmailMultiAlternatives
from django.template.loader import render_to_string
from django.utils.html import strip_tags
from django.conf import settings
from django.utils import timezone
from celery import shared_task, group
from celery.exceptions import SoftTimeLimitExceeded
import markdown
import logging
from apps.users.models import User
from apps.events.models import Event, Registration, EventEmailLog
from core.templatetags.jalali import fa_digits, jdate
from celery import group, shared_task
from django.conf import settings
from django.db.models import Q
from apps.events.models import Event, EventEmailLog, Registration
from apps.notifications.services import notify_user
from apps.users.email_identity import normalize_email_identity
from apps.users.models import User
from apps.users.tasks import send_critical_sms
logger = logging.getLogger(__name__)
ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS = 30
ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS = 45
def _build_context(*parts):
values = [str(part) for part in parts if part not in (None, "")]
return "|".join(values) if values else None
def _build_email_context(*parts):
return _build_context(*parts)
def _event_url(event: Event) -> str:
root = getattr(settings, "FRONTEND_ROOT", "/")
slug_or_id = getattr(event, "slug", None) or event.id
return f"{root}events/{slug_or_id}"
def _send_html_email(subject: str, html_body: str, to_email: str):
normalized_email = normalize_email_identity(to_email)
if not normalized_email:
return False
user = User.objects.filter(email=normalized_email).first()
if not user:
return False
notify_user(
user.id,
{
"type": "admin_message",
"title": subject,
"message": html_body[:500],
"level": "info",
},
)
return True
def _event_recipients(event, statuses=None):
qs = Registration.objects.filter(event=event, is_deleted=False).select_related("user", "event")
if statuses:
qs = qs.filter(status__in=statuses)
return qs
@shared_task(bind=True, max_retries=3)
def send_registration_confirmation_email(self, registration_pk: str):
"""Send a registration confirmation email, loading the model lazily to avoid circular imports."""
try:
from .models import Registration
reg = (
Registration.objects
.select_related("event", "user")
.get(pk=registration_pk)
reg = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(
reg.user_id,
{
"type": "event_registration",
"title": f"ثبت‌نام شما در {reg.event.title}",
"message": "ثبت‌نام شما تایید شد.",
"level": "success",
"action_url": f"/events/{reg.event.slug}",
"entity_type": "event",
"entity_id": reg.event_id,
"meta": {"ticket_id": str(reg.ticket_id)},
},
)
user_email = getattr(reg.user, "email", None)
if not user_email:
return
success_md = reg.event.registration_success_markdown or ""
success_html = markdown.markdown(
success_md,
extensions=["extra", "sane_lists", "toc"]
) if success_md else ""
context = {
"user": reg.user,
"event": reg.event,
"registration": reg,
"success_html": success_html,
}
subject = f"تأیید ثبت‌نام شما در {reg.event.title}"
html_body = render_to_string("emails/event_registration_confirmation.html", context)
plain_body = strip_tags(html_body)
message = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user_email],
)
message.attach_alternative(html_body, "text/html")
message.send(fail_silently=False)
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
logger.info("Registration confirmation notification sent to user=%s event=%s", reg.user_id, reg.event_id)
return {"sent": True}
except Exception as exc:
logger.error(f"Failed to send event registration email: {exc}")
logger.error("Failed to send registration confirmation notification: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_registration_cancellation_email(self, registration_pk: str):
try:
from .models import Registration
reg = (
Registration.objects
.select_related("event", "user")
.get(pk=registration_pk)
reg = Registration.objects.select_related("event", "user").get(pk=registration_pk)
notify_user(
reg.user_id,
{
"type": "event_cancellation",
"title": f"لغو ثبت‌نام در {reg.event.title}",
"message": "ثبت‌نام شما در این رویداد لغو شد.",
"level": "warning",
"action_url": f"/events/{reg.event.slug}",
"entity_type": "event",
"entity_id": reg.event_id,
},
)
user_email = getattr(reg.user, "email", None)
if not user_email:
return
context = {
"user": reg.user,
"event": reg.event,
"registration": reg,
}
subject = f"لغو ثبت‌نام شما در {reg.event.title}"
html_body = render_to_string("emails/event_registration_cancellation.html", context)
plain_body = strip_tags(html_body)
message = EmailMultiAlternatives(
subject=subject,
body=plain_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user_email],
)
message.attach_alternative(html_body, "text/html")
message.send(fail_silently=False)
logger.info(f"Event Confirm Registration email sent to {reg.user.email}")
if reg.user.mobile and reg.user.is_mobile_verified:
send_critical_sms.delay(reg.user.mobile, "event_cancellation", reg.event.title)
logger.info("Registration cancellation delivered to user=%s event=%s", reg.user_id, reg.event_id)
return {"sent": True}
except Exception as exc:
logger.error(f"Failed to send event registration email: {exc}")
raise self.retry(exc=exc, countdown=60)
def _event_recipients(event, statuses=None, only_verified=True):
qs = Registration.objects.filter(event=event, is_deleted=False)
if statuses:
qs = qs.filter(status__in=statuses)
if only_verified:
qs = qs.filter(user__is_email_verified=True)
qs = qs.exclude(user__email__isnull=True).exclude(user__email="")
return qs.select_related("user")
def _send_html_email(subject, html_body, to_email):
text_body = strip_tags(html_body)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[to_email],
)
msg.attach_alternative(html_body, "text/html")
msg.send()
def _build_email_context(*parts):
values = [str(part) for part in parts if part not in (None, "")]
return "|".join(values) if values else None
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_kwargs={"max_retries": 3}, soft_time_limit=60)
def send_skyroom_credentials_individual_task(self, reg_id: int):
"""
ارسال نام‌کاربری/رمز برای اسکای‌روم
- username = user.email
- password = registration.ticket_id[:8]
- url = event.online_link (اگر لینک در فیلد online_link ذخیره شده باشد)
"""
r = Registration.objects.get(pk=reg_id)
event = r.event
user = r.user
sky_user = user.email.strip().split('@')[0]
sky_pass = str(r.ticket_id)[:8]
skyroom_url = event.online_link
try:
ctx = {
"user": user,
"event": event,
"skyroom_url": skyroom_url,
"sky_username": sky_user,
"sky_password": sky_pass,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}"
html = render_to_string("emails/skyroom_credentials.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
)
msg.attach_alternative(html, "text/html")
msg.send()
logger.info(f'Skyroom Credentials for Event "{event.title}" sent to {user.email}')
except Exception as exc:
logger.error(f"Failed to send skyroom credentials email: {exc}")
logger.error("Failed to send registration cancellation notification: %s", exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True)
def send_event_reminder_task(self, event_id: int):
"""
یادآوری رویداد (ارسال الان؛ برای ارسال خودکار یک روز قبل، یک beat job بسازید)
"""
event = Event.objects.get(pk=event_id)
regs = (
_event_recipients(event, statuses=["confirmed", "attended"])
.select_related("user", "event")
.distinct()
)
regs = _event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED])
reg_ids = list(regs.values_list("id", flat=True))
job = group(send_event_reminder_to_user.s(event_id, rid) for rid in reg_ids)
res = job.apply_async()
logger.info(
'Queued %s event reminder emails for event "%s" (group_id=%s)',
len(reg_ids),
event.title,
res.id,
)
logger.info('Queued %s event reminders for event "%s" (group_id=%s)', len(reg_ids), event.title, res.id)
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, soft_time_limit=30, time_limit=45)
def send_event_reminder_to_user(self, event_id: int, registration_id: int):
"""
Send reminder email to a single registration; safe to retry without duplicating emails.
"""
user = None
log = None
try:
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
to_email = (user.email or "").strip()
if not to_email:
return {"skipped": True, "status": "no_email"}
context_key = _build_email_context(
"event_reminder",
event.slug or event.id,
event.start_time,
)
registration = Registration.objects.select_related("user", "event").get(pk=registration_id)
event = registration.event
user = registration.user
context_key = _build_context("event_reminder", event.slug or event.id, event.start_time)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user.id,
@@ -236,111 +130,46 @@ def send_event_reminder_to_user(self, event_id: int, registration_id: int):
)
if skip:
return {"skipped": True, "status": log.status}
ctx = {
"user": user,
"event": event,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"یادآوری رویداد: {event.title}"
html = render_to_string("emails/event_reminder.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[to_email],
notify_user(
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,
},
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Event reminder for "%s" sent to %s', event.title, to_email)
return f"Email sent to {to_email}"
except SoftTimeLimitExceeded:
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning(
"Soft time limit exceeded (event_id=%s, registration_id=%s)",
event_id,
registration_id,
)
raise
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error(
"Failed to send event reminder email: %s", exc, exc_info=True
)
raise
@shared_task(bind=True)
def queue_event_announcement(self, event_id: int, subject: str, body_html: str, statuses=None):
"""
تسک مادر: ثبت‌نام‌های هدف را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
# محدوده مخاطبان: اگر statuses داده نشد، همان پیش‌فرض قبلی شما
statuses = statuses or ["confirmed", "attended", "pending"]
regs = (
_event_recipients(event, statuses=statuses)
.select_related("user", "event")
.exclude(user__email__isnull=True)
.exclude(user__email="")
.distinct()
)
statuses = statuses or [Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED, Registration.StatusChoices.PENDING]
regs = _event_recipients(event, statuses=statuses).distinct()
reg_ids = list(regs.values_list("id", flat=True))
# ساخت group از تسک‌های کوچک؛ هر کدام فقط یک ایمیل ارسال می‌کند
job = group(
send_event_announcement_to_user.s(event_id, rid, subject, body_html)
for rid in reg_ids
)
# اگر نتیجه‌ها لازم نیست: CELERY_TASK_IGNORE_RESULT = True
job = group(send_event_announcement_to_user.s(event_id, rid, subject, body_html) for rid in reg_ids)
res = job.apply_async()
logger.info(
'Queued %s event-announcement emails for event "%s" (group_id=%s)',
len(reg_ids), event.title, res.id
)
logger.info('Queued %s event announcements for event "%s" (group_id=%s)', len(reg_ids), event.title, res.id)
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, soft_time_limit=30, time_limit=45)
def send_event_announcement_to_user(self, event_id: int, registration_id: int, subject: str, body_html: str):
"""
تسک کوچک و اتمی: ارسال ایمیل اعلان رویداد برای یک Registration.
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
"""
user = None
log = None
try:
# از Registration می‌گیریم تا یک کوئری کمتر به Event بزنیم
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
context_key = _build_email_context(
"event_announcement3",
event.slug or event.id,
subject,
body_html,
)
registration = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = registration.user
event = registration.event
context_key = _build_context("event_announcement", event.slug or event.id, subject, body_html)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user.id,
@@ -349,99 +178,46 @@ def send_event_announcement_to_user(self, event_id: int, registration_id: int, s
)
if skip:
return {"skipped": True, "status": log.status}
# کانتکست رندر ایمیل: body_html مستقیم داخل تمپلیت شما اینجکت می‌شود
ctx = {
"user": user,
"event": event,
"body_html": body_html,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
html = render_to_string("emails/event_announcement.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
notify_user(
user.id,
{
"type": "event_announcement",
"title": subject,
"message": body_html[:500],
"level": "info",
"action_url": f"/events/{event.slug}",
"entity_type": "event",
"entity_id": event.id,
},
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Event announcement for "%s" sent to %s', event.title, user.email)
return f"Email sent to {user.email}"
except SoftTimeLimitExceeded:
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning("Soft time limit exceeded (event_id=%s, registration_id=%s)", event_id, registration_id)
raise
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error("Failed to send event announcement email: %s", exc, exc_info=True)
raise
def _event_url(event):
root = getattr(settings, "FRONTEND_ROOT", "/")
slug_or_id = getattr(event, "slug", None) or event.id
return f"{root}events/{slug_or_id}"
@shared_task(bind=True)
def queue_invites_to_non_registered_users(self, event_id: int, only_verified=True, only_active=True):
"""
تسک مادر: فقط کاربرها را پیدا می‌کند و برای هر نفر یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
qs = User.objects.all()
if only_verified:
qs = qs.filter(is_email_verified=True)
if only_active:
qs = qs.filter(is_active=True)
# کسانی که برای این ایونت ثبت‌نام نکرده‌اند
qs = qs.exclude(event_registrations__event_id=event_id) \
.exclude(email__isnull=True).exclude(email="") \
.distinct()
if only_verified:
qs = qs.filter(Q(mobile__isnull=False) | Q(email__isnull=False))
qs = qs.exclude(event_registrations__event_id=event_id).distinct()
user_ids = list(qs.values_list("id", flat=True))
# گَروهِ تسک‌های کوچک
job = group(send_invite_to_user.s(event_id, uid) for uid in user_ids)
res = job.apply_async()
return {"event_id": event_id, "queued": len(user_ids), "group_id": res.id}
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, time_limit=60)
def send_invite_to_user(self, event_id: int, user_id: int):
"""
تسک کوچک و اتمی: برای هر کاربر حداکثر یک ایمیل می‌فرستد (با لاگ ایدمپوتنسی).
"""
event = Event.objects.get(pk=event_id)
user = User.objects.get(pk=user_id)
# ساخت محتوا
context = {
"user": user,
"event": event,
"event_url": _event_url(event),
"start_time": fa_digits(jdate(event.start_time))
}
# ایدمپوتنسی: اگر قبلاً این ایمیل رزرو/ارسال شده، Skip
subject = f"دعوت به شرکت در «{event.title}»"
text_body = render_to_string("emails/event_invite_non_registered.txt", context)
html_body = render_to_string("emails/event_invite_non_registered.html", context)
context_key = _build_email_context(
"invite_non_registered",
event.slug or event.id,
html_body,
)
context_key = _build_context("invite_non_registered", event.slug or event.id, user.id)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user_id,
@@ -450,19 +226,21 @@ def send_invite_to_user(self, event_id: int, user_id: int):
)
if skip:
return {"skipped": True, "status": log.status}
try:
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=settings.DEFAULT_FROM_EMAIL,
to=[user.email],
notify_user(
user.id,
{
"type": "event_invitation",
"title": f"دعوت به رویداد {event.title}",
"message": "برای مشاهده جزئیات و ثبت‌نام وارد صفحه رویداد شوید.",
"level": "info",
"action_url": f"/events/{event.slug}",
"entity_type": "event",
"entity_id": event.id,
},
)
msg.attach_alternative(html_body, "text/html")
msg.send()
log.mark_sent()
return f"Email sent to {user.email}"
return {"sent": True}
except Exception as exc:
log.mark_failed(str(exc))
raise
@@ -470,68 +248,31 @@ def send_invite_to_user(self, event_id: int, user_id: int):
@shared_task(bind=True)
def queue_skyroom_credentials(self, event_id: int):
"""
تسک مادر: ثبت‌نام‌های تاییدشده را پیدا می‌کند و برای هر Registration یک تسک کوچک می‌سازد.
"""
event = Event.objects.get(pk=event_id)
# فقط CONFIRMED ها + ایمیل معتبر
regs = (
_event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED])
.select_related("user", "event")
.exclude(user__email__isnull=True)
.exclude(user__email="")
.distinct()
)
regs = _event_recipients(event, statuses=[Registration.StatusChoices.CONFIRMED]).distinct()
reg_ids = list(regs.values_list("id", flat=True))
# ساخت group از تسک‌های کوچک؛ هر کدوم فقط یک ایمیل ارسال می‌کنند
job = group(send_skyroom_credentials_to_user.s(event_id, rid) for rid in reg_ids)
# توصیه: اگر نتیجه‌ها را لازم ندارید، در تنظیمات CELERY_TASK_IGNORE_RESULT=True بگذارید
res = job.apply_async()
logger.info(
'Queued %s Skyroom-credential emails for event "%s" (group_id=%s)',
len(reg_ids), event.title, res.id
)
logger.info('Queued %s Skyroom credential notifications for event "%s" (group_id=%s)', len(reg_ids), event.title, res.id)
return {"event_id": event_id, "queued": len(reg_ids), "group_id": res.id}
@shared_task(
bind=True,
autoretry_for=(Exception,),
retry_backoff=True,
retry_jitter=True,
retry_kwargs={"max_retries": 3},
soft_time_limit=ANNOUNCEMENT_TASK_SOFT_LIMIT_SECONDS,
time_limit=ANNOUNCEMENT_TASK_HARD_LIMIT_SECONDS,
)
@shared_task(bind=True)
def send_skyroom_credentials_individual_task(self, reg_id: int):
registration = Registration.objects.select_related("event", "user").get(pk=reg_id)
return send_skyroom_credentials_to_user.delay(registration.event_id, registration.id)
@shared_task(bind=True, autoretry_for=(Exception,), retry_backoff=True, retry_jitter=True, retry_kwargs={"max_retries": 3}, soft_time_limit=30, time_limit=45)
def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
"""
تسک کوچک و اتمی: ارسال نام‌کاربری/رمز اسکای‌روم برای یک Registration.
با لاگ ایدمپوتنسی تا ارسال تکراری نداشته باشیم.
"""
user = None
log = None
try:
r = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = r.user
event = r.event
# ساخت یوزرنیم/پسورد
sky_username = (user.email or "").strip().split("@")[0]
sky_password = str(r.ticket_id or "")[:8]
skyroom_url = event.online_link
context_key = _build_email_context(
"skyroom_credentials",
event.slug or event.id,
sky_username,
sky_password,
skyroom_url,
)
registration = Registration.objects.select_related("user", "event").get(pk=registration_id)
user = registration.user
event = registration.event
sky_username = (user.email or user.username or user.mobile or "").split("@")[0]
sky_password = str(registration.ticket_id or "")[:8]
context_key = _build_context("skyroom_credentials", event.slug or event.id, sky_username, sky_password, event.online_link)
log, skip = EventEmailLog.claim(
event_id=event_id,
user_id=user.id,
@@ -540,45 +281,26 @@ def send_skyroom_credentials_to_user(self, event_id: int, registration_id: int):
)
if skip:
return {"skipped": True, "status": log.status}
ctx = {
"user": user,
"event": event,
"skyroom_url": skyroom_url,
"sky_username": sky_username,
"sky_password": sky_password,
"event_url": f"{settings.FRONTEND_ROOT}events/{event.slug}",
}
subject = f"اطلاعات دسترسی اسکای‌روم - {event.title}"
html = render_to_string("emails/skyroom_credentials.html", ctx)
text_body = strip_tags(html)
msg = EmailMultiAlternatives(
subject=subject,
body=text_body,
from_email=getattr(settings, "DEFAULT_FROM_EMAIL", None),
to=[user.email],
notify_user(
user.id,
{
"type": "skyroom_credentials",
"title": f"اطلاعات دسترسی رویداد {event.title}",
"message": f"نام کاربری: {sky_username} | رمز عبور: {sky_password}",
"level": "info",
"action_url": _event_url(event),
"entity_type": "event",
"entity_id": event.id,
"meta": {
"online_link": event.online_link,
"username": sky_username,
"password": sky_password,
},
},
)
msg.attach_alternative(html, "text/html")
msg.send()
log.mark_sent()
logger.info('Skyroom credentials for "%s" sent to %s', event.title, user.email)
return f"Email sent to {user.email}"
except SoftTimeLimitExceeded as exc:
# ثبت خطا و اجازه به Celery برای retry خودکار
if log:
log.mark_failed("Soft time limit exceeded")
logger.warning(
"Soft time limit exceeded for event_id=%s, registration_id=%s", event_id, registration_id
)
raise
return {"sent": True}
except Exception as exc:
if log:
log.mark_failed(str(exc))
logger.error("Failed to send skyroom credentials email: %s", exc, exc_info=True)
raise

View File

@@ -0,0 +1 @@
"""Redis-backed in-app notifications."""

View File

@@ -0,0 +1 @@
"""Notifications API package."""

View File

@@ -0,0 +1,52 @@
from datetime import datetime
from typing import Any
from ninja import Schema
class NotificationSchema(Schema):
id: str
type: str
title: str
message: str
level: str
created_at: datetime | str
is_seen: bool
delete_on_seen: bool
action_url: str | None = None
entity_type: str | None = None
entity_id: int | str | None = None
meta: dict[str, Any] = {}
class NotificationListSchema(Schema):
count: int
unread_count: int
notifications: list[NotificationSchema]
class NotificationSeenIn(Schema):
id: str
class NotificationSeenResponseSchema(Schema):
marked_read: bool
notification_id: str | None = None
deleted: bool = False
notification: NotificationSchema | None = None
unread_count: int | None = None
class NotificationDeleteResponseSchema(Schema):
deleted: bool
notification_id: str | None = None
unread_count: int | None = None
class NotificationMarkAllReadResponseSchema(Schema):
marked_read: int
class NotificationStreamTokenResponseSchema(Schema):
token: str
expires_in: int

View File

@@ -0,0 +1,156 @@
import json
from typing import Iterator
from django.conf import settings
from django.core import signing
from django.http import JsonResponse, StreamingHttpResponse
from django.utils import timezone
from django.views import View
from ninja import Router
from apps.notifications.api.schemas import (
NotificationDeleteResponseSchema,
NotificationListSchema,
NotificationMarkAllReadResponseSchema,
NotificationSeenIn,
NotificationSeenResponseSchema,
NotificationStreamTokenResponseSchema,
)
from apps.notifications.services import RedisNotificationStore
from core.api.schemas import ErrorSchema
from core.authentication import jwt_auth
notifications_router = Router()
STREAM_TOKEN_SALT = "notifications.stream"
def _safe_int(value, default: int) -> int:
try:
return max(int(value), 0)
except (TypeError, ValueError):
return default
def _format_sse_event(event: str, data: dict) -> str:
payload = json.dumps(data, ensure_ascii=False, default=str)
return f"event: {event}\ndata: {payload}\n\n"
def _issue_stream_token_for_user(user_id: str) -> str:
return signing.dumps({"user_id": str(user_id), "type": "notification_stream"}, salt=STREAM_TOKEN_SALT)
def _validate_stream_token(token: str | None) -> str:
if not token:
raise signing.BadSignature("Missing stream token")
payload = signing.loads(
token,
salt=STREAM_TOKEN_SALT,
max_age=settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS,
)
if payload.get("type") != "notification_stream":
raise signing.BadSignature("Invalid stream token type")
return str(payload["user_id"])
@notifications_router.get("/", response=NotificationListSchema, auth=jwt_auth)
def list_notifications(request, limit: int | None = None, offset: int = 0, type: str | None = None):
user_id = str(request.auth.id)
safe_limit = min(_safe_int(limit, settings.NOTIFICATION_DEFAULT_PAGE_SIZE), settings.NOTIFICATION_MAX_PAGE_SIZE)
notifications, total_count = RedisNotificationStore.list(
user_id,
limit=safe_limit,
offset=_safe_int(offset, 0),
type_filter=type,
)
return {
"count": total_count,
"unread_count": RedisNotificationStore.unread_count(user_id),
"notifications": notifications,
}
@notifications_router.post("/mark-seen", response={200: NotificationSeenResponseSchema, 404: ErrorSchema}, auth=jwt_auth)
def mark_notification_seen(request, data: NotificationSeenIn):
payload = RedisNotificationStore.mark_seen(str(request.auth.id), data.id)
if payload is None:
return 404, {"error": "Notification not found."}
return 200, {"marked_read": True, **payload}
@notifications_router.delete("/{notif_id}", response={200: NotificationDeleteResponseSchema, 404: ErrorSchema}, auth=jwt_auth)
def delete_notification(request, notif_id: str):
deleted = RedisNotificationStore.delete(str(request.auth.id), notif_id)
if not deleted:
return 404, {"error": "Notification not found."}
return 200, {
"deleted": True,
"notification_id": notif_id,
"unread_count": RedisNotificationStore.unread_count(str(request.auth.id)),
}
@notifications_router.post("/mark-all-read", response=NotificationMarkAllReadResponseSchema, auth=jwt_auth)
def mark_all_notifications_read(request, type: str | None = None):
updated = RedisNotificationStore.mark_all_seen(str(request.auth.id), type_filter=type)
return {"marked_read": updated}
@notifications_router.post("/stream-token", response={200: NotificationStreamTokenResponseSchema, 503: ErrorSchema}, auth=jwt_auth)
def get_notification_stream_token(request):
if not settings.NOTIFICATIONS_ENABLED:
return 503, {"error": "Notifications are disabled."}
return {
"token": _issue_stream_token_for_user(str(request.auth.id)),
"expires_in": settings.NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS,
}
class NotificationStreamView(View):
def _build_stream(self, user_id: str) -> Iterator[str]:
pubsub = RedisNotificationStore.get_pubsub()
channel = RedisNotificationStore._channel_key(user_id)
heartbeat_seconds = max(settings.NOTIFICATION_SSE_HEARTBEAT_SECONDS, 1)
initial_notifications, _ = RedisNotificationStore.list(
user_id,
limit=settings.NOTIFICATION_DEFAULT_PAGE_SIZE,
offset=0,
)
unread_count = RedisNotificationStore.unread_count(user_id)
yield f"retry: {settings.NOTIFICATION_SSE_RETRY_MS}\n\n"
yield _format_sse_event("connected", {"notifications": initial_notifications, "unread_count": unread_count})
yield _format_sse_event("unread_count", {"unread_count": unread_count})
pubsub.subscribe(channel)
last_ping_at = timezone.now()
try:
while True:
message = pubsub.get_message(timeout=1.0)
if message and message.get("type") == "message":
try:
payload = json.loads(message["data"])
except json.JSONDecodeError:
payload = {"event": "notification", "data": {}}
yield _format_sse_event(payload.get("event") or "notification", payload.get("data") or {})
if (timezone.now() - last_ping_at).total_seconds() >= heartbeat_seconds:
last_ping_at = timezone.now()
yield _format_sse_event("ping", {"timestamp": last_ping_at.isoformat()})
finally:
try:
pubsub.unsubscribe(channel)
finally:
pubsub.close()
def get(self, request, *args, **kwargs):
if not settings.NOTIFICATIONS_ENABLED:
return JsonResponse({"detail": "Notifications are disabled."}, status=503)
try:
user_id = _validate_stream_token(request.GET.get("token"))
except signing.SignatureExpired:
return JsonResponse({"detail": "Stream token expired."}, status=401)
except signing.BadSignature:
return JsonResponse({"detail": "Invalid stream token."}, status=401)
response = StreamingHttpResponse(self._build_stream(user_id), content_type="text/event-stream")
response["Cache-Control"] = "no-cache"
response["X-Accel-Buffering"] = "no"
return response

View File

@@ -0,0 +1,7 @@
from django.apps import AppConfig
class NotificationsConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "apps.notifications"
label = "notifications"

View File

@@ -0,0 +1,276 @@
from __future__ import annotations
import json
import uuid
from datetime import timedelta
import redis
from django.conf import settings
from django.utils import timezone
from django.utils.dateparse import parse_datetime
redis_client = redis.StrictRedis.from_url(settings.REDIS_URL, decode_responses=True)
def _isoformat_datetime(value) -> str:
if not value:
return timezone.now().isoformat()
if isinstance(value, str):
parsed = parse_datetime(value)
if parsed is not None:
value = parsed
else:
return value
if timezone.is_naive(value):
value = timezone.make_aware(value, timezone.get_current_timezone())
return timezone.localtime(value).isoformat()
class RedisNotificationStore:
USERS_KEY = "notif:users"
@classmethod
def _ids_key(cls, user_id: str) -> str:
return f"notif:{user_id}:ids"
@classmethod
def _data_key(cls, user_id: str) -> str:
return f"notif:{user_id}:data"
@classmethod
def _channel_key(cls, user_id: str) -> str:
prefix = settings.NOTIFICATION_REDIS_CHANNEL_PREFIX.rstrip(":")
return f"{prefix}:{user_id}"
@staticmethod
def _normalize_notification(data: dict | None) -> dict:
payload = dict(data or {})
return {
"id": str(payload.get("id") or uuid.uuid4()),
"type": payload.get("type") or "notification",
"title": payload.get("title") or "",
"message": payload.get("message") or "",
"level": payload.get("level") or "info",
"created_at": _isoformat_datetime(payload.get("created_at")),
"is_seen": bool(payload.get("is_seen", False)),
"delete_on_seen": bool(payload.get("delete_on_seen", False)),
"action_url": payload.get("action_url"),
"entity_type": payload.get("entity_type"),
"entity_id": payload.get("entity_id"),
"meta": payload.get("meta") or {},
}
@classmethod
def _publish_event(cls, user_id: str, event: str, data: dict) -> None:
if not settings.NOTIFICATIONS_ENABLED:
return
redis_client.publish(
cls._channel_key(user_id),
json.dumps({"event": event, "data": data}, ensure_ascii=False, default=str),
)
@classmethod
def unread_count(cls, user_id: str, *, type_filter: str | None = None) -> int:
notifications, _ = cls.list(
user_id,
limit=settings.NOTIFICATION_MAX_PAGE_SIZE,
offset=0,
type_filter=type_filter,
paginate=False,
)
return sum(1 for notification in notifications if not notification.get("is_seen"))
@classmethod
def add(cls, user_id: str, payload: dict) -> dict:
data = cls._normalize_notification(payload)
created_at = parse_datetime(data["created_at"]) or timezone.now()
created_at_ts = created_at.timestamp()
json_str = json.dumps(data, ensure_ascii=False, default=str)
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
pipe = redis_client.pipeline()
pipe.zadd(ids_key, {data["id"]: created_at_ts})
pipe.hset(data_key, data["id"], json_str)
pipe.sadd(cls.USERS_KEY, user_id)
pipe.execute()
unread_count = cls.unread_count(user_id)
cls._publish_event(user_id, "notification", {"notification": data, "unread_count": unread_count})
cls._publish_event(user_id, "unread_count", {"unread_count": unread_count})
return data
@classmethod
def list(
cls,
user_id: str,
*,
limit: int | None = None,
offset: int = 0,
type_filter: str | None = None,
paginate: bool = True,
) -> tuple[list[dict], int]:
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
ids = redis_client.zrevrange(ids_key, 0, -1)
if not ids:
return [], 0
pipe = redis_client.pipeline()
for notif_id in ids:
pipe.hget(data_key, notif_id)
raw_items = pipe.execute()
items: list[dict] = []
cleanup_ids: list[str] = []
for notif_id, raw in zip(ids, raw_items, strict=False):
if not raw:
cleanup_ids.append(notif_id)
continue
try:
data = cls._normalize_notification(json.loads(raw))
except json.JSONDecodeError:
cleanup_ids.append(notif_id)
continue
if type_filter and data.get("type") != type_filter:
continue
items.append(data)
if cleanup_ids:
redis_client.zrem(ids_key, *cleanup_ids)
redis_client.hdel(data_key, *cleanup_ids)
total_count = len(items)
if not paginate:
return items, total_count
safe_offset = max(offset, 0)
safe_limit = max(limit or settings.NOTIFICATION_DEFAULT_PAGE_SIZE, 1)
return items[safe_offset : safe_offset + safe_limit], total_count
@classmethod
def get(cls, user_id: str, notif_id: str) -> dict | None:
raw = redis_client.hget(cls._data_key(user_id), notif_id)
if not raw:
return None
try:
return cls._normalize_notification(json.loads(raw))
except json.JSONDecodeError:
return None
@classmethod
def delete(cls, user_id: str, notif_id: str) -> bool:
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
pipe = redis_client.pipeline()
pipe.zrem(ids_key, notif_id)
pipe.hdel(data_key, notif_id)
result = pipe.execute()
if any(result):
unread_count = cls.unread_count(user_id)
cls._publish_event(
user_id,
"notification_seen",
{"notification_id": notif_id, "deleted": True, "unread_count": unread_count},
)
cls._publish_event(user_id, "unread_count", {"unread_count": unread_count})
return True
return False
@classmethod
def mark_seen(cls, user_id: str, notif_id: str) -> dict | None:
data = cls.get(user_id, notif_id)
if not data:
return None
if data.get("delete_on_seen"):
deleted = cls.delete(user_id, notif_id)
if deleted:
return {
"notification_id": notif_id,
"deleted": True,
"notification": None,
"unread_count": cls.unread_count(user_id),
}
return None
if not data.get("is_seen"):
data["is_seen"] = True
redis_client.hset(
cls._data_key(user_id),
notif_id,
json.dumps(data, ensure_ascii=False, default=str),
)
unread_count = cls.unread_count(user_id)
payload = {
"notification_id": notif_id,
"deleted": False,
"notification": data,
"unread_count": unread_count,
}
cls._publish_event(user_id, "notification_seen", payload)
cls._publish_event(user_id, "unread_count", {"unread_count": unread_count})
return payload
@classmethod
def mark_all_seen(cls, user_id: str, *, type_filter: str | None = None) -> int:
ids = redis_client.zrevrange(cls._ids_key(user_id), 0, -1)
if not ids:
return 0
updated = 0
pipe = redis_client.pipeline()
for notif_id in ids:
raw = redis_client.hget(cls._data_key(user_id), notif_id)
if not raw:
continue
try:
data = cls._normalize_notification(json.loads(raw))
except json.JSONDecodeError:
continue
if type_filter and data.get("type") != type_filter:
continue
if data.get("delete_on_seen"):
pipe.zrem(cls._ids_key(user_id), notif_id)
pipe.hdel(cls._data_key(user_id), notif_id)
else:
data["is_seen"] = True
pipe.hset(
cls._data_key(user_id),
notif_id,
json.dumps(data, ensure_ascii=False, default=str),
)
updated += 1
if updated:
pipe.execute()
unread_count = cls.unread_count(user_id, type_filter=type_filter)
cls._publish_event(user_id, "notification_mark_all_read", {"type": type_filter, "unread_count": unread_count})
cls._publish_event(user_id, "unread_count", {"unread_count": cls.unread_count(user_id)})
return updated
@classmethod
def get_pubsub(cls):
return redis_client.pubsub(ignore_subscribe_messages=True)
@classmethod
def cleanup_expired(cls, retention_days: int | None = None) -> int:
days = retention_days or settings.NOTIFICATION_RETENTION_DAYS
cutoff_ts = (timezone.now() - timedelta(days=days)).timestamp()
removed = 0
for user_id in redis_client.smembers(cls.USERS_KEY):
ids_key = cls._ids_key(user_id)
data_key = cls._data_key(user_id)
old_ids = redis_client.zrangebyscore(ids_key, "-inf", cutoff_ts)
if not old_ids:
continue
pipe = redis_client.pipeline()
for notif_id in old_ids:
pipe.zrem(ids_key, notif_id)
pipe.hdel(data_key, notif_id)
removed += 1
pipe.execute()
if redis_client.zcard(ids_key) == 0:
redis_client.srem(cls.USERS_KEY, user_id)
return removed
def notify_user(user_id: int | str, payload: dict) -> dict:
return RedisNotificationStore.add(str(user_id), payload)

View File

@@ -0,0 +1,14 @@
import logging
from celery import shared_task
from apps.notifications.services import RedisNotificationStore
logger = logging.getLogger(__name__)
@shared_task
def cleanup_notification_retention():
removed = RedisNotificationStore.cleanup_expired()
logger.info("Cleaned up %s expired notifications", removed)
return removed

View File

@@ -8,12 +8,44 @@ import requests
from apps.payments.models import Payment, DiscountCode
from apps.events.models import Event, Registration
from apps.notifications.services import notify_user
from apps.users.tasks import send_critical_sms
from core.authentication import jwt_auth
from apps.payments.api.schemas import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
payments_router = Router(tags=["Payments"])
def _event_action_url(event: Event) -> str:
root = getattr(settings, "FRONTEND_ROOT", "/") or "/"
if not root.endswith("/"):
root = f"{root}/"
return f"{root}events/{event.slug or event.id}"
def _notify_payment_status(payment: Payment, *, title: str, message: str, level: str):
notify_user(
payment.user_id,
{
"type": "payment_status",
"title": title,
"message": message,
"level": level,
"action_url": _event_action_url(payment.event),
"entity_type": "payment",
"entity_id": payment.id,
"meta": {
"event_id": payment.event_id,
"payment_id": payment.id,
"ref_id": payment.ref_id,
"status": payment.status,
},
},
)
if payment.user.mobile and payment.user.is_mobile_verified:
send_critical_sms.delay(payment.user.mobile, "payment_status", payment.event.title)
@payments_router.post("create", response=CreatePaymentOut, auth=jwt_auth)
def create_payment(request, payload: CreatePaymentIn):
event = get_object_or_404(Event, pk=payload.event_id)
@@ -145,10 +177,18 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
if not pay:
raise HttpError(404, "Payment not found")
previous_status = pay.status
if Status != "OK":
pay.status = Payment.OrderStatusChoices.CANCELED
pay.save(update_fields=["status"])
if previous_status != Payment.OrderStatusChoices.CANCELED:
_notify_payment_status(
pay,
title=f"پرداخت {pay.event.title} لغو شد",
message="پرداخت شما کامل نشد و ثبت‌نام نهایی انجام نگرفت.",
level="warning",
)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
verify_body = {
@@ -168,6 +208,13 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
except Exception:
pay.status = Payment.OrderStatusChoices.FAILED
pay.save(update_fields=["status"])
if previous_status != Payment.OrderStatusChoices.FAILED:
_notify_payment_status(
pay,
title=f"پرداخت {pay.event.title} ناموفق بود",
message="خطا در تأیید پرداخت رخ داد. در صورت نیاز دوباره تلاش کنید.",
level="error",
)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
vcode = (vjd.get("data") or {}).get("code")
@@ -193,10 +240,25 @@ def callback(request, Authority: str | None = None, Status: str | None = None):
updates.append("final_price")
registration.save(update_fields=updates)
if previous_status != Payment.OrderStatusChoices.PAID:
_notify_payment_status(
pay,
title=f"پرداخت {pay.event.title} تأیید شد",
message="پرداخت شما با موفقیت ثبت شد و ثبت‌نام رویداد تکمیل شده است.",
level="success",
)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=success&event_id={pay.event_id}&ref_id={pay.ref_id}")
pay.status = Payment.OrderStatusChoices.FAILED
pay.save(update_fields=["status"])
if previous_status != Payment.OrderStatusChoices.FAILED:
_notify_payment_status(
pay,
title=f"پرداخت {pay.event.title} ناموفق بود",
message="تراکنش شما توسط درگاه تأیید نشد. در صورت نیاز دوباره پرداخت را انجام دهید.",
level="error",
)
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)

View File

@@ -1,16 +1,20 @@
"""Authentication-related API schemas."""
from ninja import Schema, ModelSchema
from datetime import datetime
from typing import Optional
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
from ninja import ModelSchema, Schema
from apps.users.models import User
from core.media import PREVIEW_VARIANT, THUMBNAIL_VARIANT, derivative_url
class UserRegistrationSchema(Schema):
username: str
email: str
mobile: str
code: str
password: str
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
university: Optional[str] = None
@@ -18,10 +22,71 @@ class UserRegistrationSchema(Schema):
year_of_study: Optional[int] = None
major: Optional[str] = None
class UserLoginSchema(Schema):
email: str
identifier: str
password: str
class UserOtpLoginSchema(Schema):
mobile: str
code: str
class RegisterOtpVerifySchema(Schema):
mobile: str
code: str
class OtpSendSchema(Schema):
mobile: str
mode: str
class MobileOtpSendSchema(Schema):
mobile: str
class MobileOtpVerifySchema(Schema):
mobile: str
code: str
class GoogleFlowSchema(Schema):
flow: str
class GoogleClaimVerifySchema(Schema):
flow: str
code: str
class GoogleCompleteSchema(Schema):
flow: str
mobile: str
username: Optional[str] = None
student_id: Optional[str] = None
year_of_study: Optional[int] = None
major: Optional[str] = None
university: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
class GoogleFlowResponseSchema(Schema):
status: str
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
avatar_url: Optional[str] = None
resolution: Optional[str] = None
mobile: Optional[str] = None
mobile_hint: Optional[str] = None
detail: Optional[str] = None
access_token: Optional[str] = None
refresh_token: Optional[str] = None
class UserProfileSchema(ModelSchema):
profile_picture: Optional[str] = None
profile_picture_thumbnail_url: Optional[str] = None
@@ -29,27 +94,32 @@ class UserProfileSchema(ModelSchema):
student_id: Optional[str] = None
major: Optional[str] = None
university: Optional[str] = None
mobile: Optional[str] = None
requires_mobile_verification: bool
has_google_link: bool
class Meta:
model = User
fields = [
'id',
'username',
'email',
'first_name',
'last_name',
'student_id',
'year_of_study',
'major',
'university',
'bio',
'date_joined',
'is_email_verified',
'is_active',
'is_staff',
'is_superuser',
'is_deleted',
'deleted_at',
"id",
"username",
"email",
"mobile",
"first_name",
"last_name",
"student_id",
"year_of_study",
"major",
"university",
"bio",
"date_joined",
"is_email_verified",
"is_mobile_verified",
"is_active",
"is_staff",
"is_superuser",
"is_deleted",
"deleted_at",
]
@staticmethod
@@ -60,14 +130,18 @@ class UserProfileSchema(ModelSchema):
def resolve_university(obj):
return obj.get_university_display()
@staticmethod
def resolve_requires_mobile_verification(obj):
return obj.requires_mobile_verification
@staticmethod
def resolve_has_google_link(obj):
return obj.has_google_link
@staticmethod
def resolve_profile_picture(obj, context):
"""
Resolves the absolute URL for the profile picture.
`context` contains the request object, which is needed for build_absolute_uri.
"""
request = context['request']
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
request = context["request"]
if obj.profile_picture and hasattr(obj.profile_picture, "url"):
return request.build_absolute_uri(obj.profile_picture.url)
return None
@@ -87,27 +161,26 @@ class UserProfileSchema(ModelSchema):
class UserListSchema(ModelSchema):
major: Optional[str] = None
university: Optional[str] = None
mobile: Optional[str] = None
class Meta:
model = User
fields = [
'id',
'username',
'email',
'first_name',
'last_name',
'is_active',
'is_staff',
'is_superuser',
'date_joined',
'major',
'university',
"id",
"username",
"email",
"mobile",
"first_name",
"last_name",
"is_active",
"is_staff",
"is_superuser",
"date_joined",
"major",
"university",
"is_mobile_verified",
]
@staticmethod
def resolve_full_name(obj):
return obj.get_full_name()
@staticmethod
def resolve_major(obj):
return obj.get_major_display()
@@ -116,7 +189,9 @@ class UserListSchema(ModelSchema):
def resolve_university(obj):
return obj.get_university_display()
class UserUpdateSchema(Schema):
email: Optional[str] = None
first_name: Optional[str] = None
last_name: Optional[str] = None
bio: Optional[str] = None
@@ -125,20 +200,33 @@ class UserUpdateSchema(Schema):
university: Optional[str] = None
student_id: Optional[str] = None
class TokenSchema(Schema):
access_token: str
refresh_token: str
token_type: str = "bearer"
class TokenRefreshIn(Schema):
refresh_token: str
class PasswordResetRequestSchema(Schema):
email: str
class PasswordResetConfirmSchema(Schema):
token: str
class PasswordResetSchema(Schema):
mobile: str
code: str
new_password: str
class UsernameCheckSchema(Schema):
exists: bool
class MobileLookupSchema(Schema):
exists: bool
has_password: bool
class OtpSendResponseSchema(Schema):
message: str
expires_in_seconds: int
expires_at: datetime

View File

@@ -1,36 +1,80 @@
from typing import List
from __future__ import annotations
import jwt
import uuid
from django.conf import settings
from django.contrib.auth import authenticate
from django.db.models import Q
from django.shortcuts import get_object_or_404
from django.utils import timezone
from django.core.files.base import ContentFile
import uuid
import jwt
from django.db.models import Q
from django.http import HttpResponseRedirect
from django.shortcuts import get_object_or_404
from ninja import Query, Router
from core.media import delete_image_derivatives
from apps.users.models import User, Major, University
from apps.users.tasks import send_verification_email, send_password_reset_email
from apps.users.api.schemas import (
PasswordResetConfirmSchema,
PasswordResetRequestSchema,
GoogleClaimVerifySchema,
GoogleCompleteSchema,
GoogleFlowResponseSchema,
GoogleFlowSchema,
MobileLookupSchema,
MobileOtpSendSchema,
MobileOtpVerifySchema,
OtpSendResponseSchema,
OtpSendSchema,
PasswordResetSchema,
RegisterOtpVerifySchema,
TokenRefreshIn,
TokenSchema,
UserListSchema,
UserLoginSchema,
UserOtpLoginSchema,
UserProfileSchema,
UserRegistrationSchema,
UserUpdateSchema,
UsernameCheckSchema,
)
from apps.users.email_identity import normalize_email_identity
from apps.users.models import Major, University, User
from apps.users.services.auth import (
AuthServiceError,
RegistrationPayload,
generate_and_send_otp,
get_tokens_for_user,
login_with_otp,
login_with_password,
lookup_mobile_registration_state,
register_user,
reset_password_with_otp,
send_authenticated_mobile_otp,
verify_register_otp,
verify_authenticated_mobile_otp,
)
from apps.users.services.google_oauth import (
GoogleOAuthFlowError,
build_authenticated_flow_payload,
build_google_authorization_url,
build_google_callback_redirect_url,
build_pending_google_flow_payload,
complete_google_signup,
consume_google_state,
create_google_flow,
exchange_code_for_google_profile,
find_social_account_for_profile,
get_google_flow,
send_google_claim_otp,
sync_user_from_google_profile,
verify_google_claim,
)
from core.api.schemas import ErrorSchema, MessageSchema
from core.authentication import create_jwt_token, create_refresh_token, jwt_auth
from core.media import delete_image_derivatives
auth_router = Router()
def _error_response(exc: AuthServiceError | GoogleOAuthFlowError):
return exc.status_code, {"error": exc.message}
def _get_major_from_code(code: str | None):
if not code:
return None
@@ -45,84 +89,90 @@ def _get_university_from_code(code: str | None):
@auth_router.post("/register", response={201: MessageSchema, 400: ErrorSchema})
def register(request, data: UserRegistrationSchema):
"""Register a new user"""
try:
if data.student_id and len(str(data.student_id)) < 10:
return 400, {"error": "Student ID must be at least 10 characters long."}
major_obj = None
if data.major:
major_obj = _get_major_from_code(data.major)
if not major_obj:
return 400, {"error": "Selected major is not recognized."}
university_obj = None
if data.university:
university_obj = _get_university_from_code(data.university)
if not university_obj:
return 400, {"error": "Selected university is not recognized."}
if User.objects.filter(username=data.username).exists():
return 400, {"error": "Username is already in use."}
if User.objects.filter(email=data.email).exists():
return 400, {"error": "Email is already registered."}
if (
data.student_id
and university_obj
and User.objects.filter(
university=university_obj, student_id=data.student_id
).exists()
):
return 400, {"error": "This student ID is already registered at that university."}
User.objects.create_user(
user = register_user(
RegistrationPayload(
username=data.username,
email=data.email,
mobile=data.mobile,
password=data.password,
student_id=data.student_id,
code=data.code,
email=data.email,
first_name=data.first_name or "",
last_name=data.last_name or "",
university=data.university,
student_id=data.student_id,
year_of_study=data.year_of_study,
major=major_obj,
university=university_obj,
major=data.major,
)
)
except AuthServiceError as exc:
return _error_response(exc)
return 201, {"message": f"ثبت‌نام با موفقیت انجام شد. خوش آمدید {user.get_full_name() or user.username}."}
return 201, {"message": "Registration successful. Please check your inbox to verify your email."}
except Exception as e:
return 400, {
"error": "Unable to register user.",
"details": str(e),
}
@auth_router.post("/otp/send", response={200: OtpSendResponseSchema, 400: ErrorSchema})
def send_otp(request, data: OtpSendSchema):
try:
payload = generate_and_send_otp(data.mobile, data.mode)
except AuthServiceError as exc:
return _error_response(exc)
return 200, payload
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema})
@auth_router.post("/otp/verify-register", response={200: MessageSchema, 400: ErrorSchema})
def verify_register_otp_view(request, data: RegisterOtpVerifySchema):
try:
verify_register_otp(data.mobile, data.code)
except AuthServiceError as exc:
return _error_response(exc)
return 200, {"message": "کد تایید با موفقیت تایید شد."}
@auth_router.post("/login", response={200: TokenSchema, 401: ErrorSchema, 400: ErrorSchema})
def login(request, data: UserLoginSchema):
"""Login user and return JWT tokens"""
user = authenticate(email=data.email, password=data.password)
try:
return 200, login_with_password(data.identifier, data.password)
except AuthServiceError as exc:
return _error_response(exc)
if not user:
return 401, {"error": "ایمیل یا رمز عبور نادرست است."}
if not user.is_email_verified:
return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."}
@auth_router.post("/login/otp", response={200: TokenSchema, 401: ErrorSchema, 400: ErrorSchema})
def login_otp(request, data: UserOtpLoginSchema):
try:
return 200, login_with_otp(data.mobile, data.code)
except AuthServiceError as exc:
return _error_response(exc)
if not user.is_active:
return 401, {"error": "حساب کاربری شما غیرفعال است."}
access_token = create_jwt_token(user)
refresh_token = create_refresh_token(user)
@auth_router.post("/reset-password", response={200: MessageSchema, 400: ErrorSchema})
def reset_password(request, data: PasswordResetSchema):
try:
reset_password_with_otp(data.mobile, data.code, data.new_password)
except AuthServiceError as exc:
return _error_response(exc)
return 200, {"message": "رمز عبور با موفقیت تغییر کرد."}
@auth_router.post("/mobile/send-otp", response={200: OtpSendResponseSchema, 400: ErrorSchema}, auth=jwt_auth)
def send_mobile_verify_otp(request, data: MobileOtpSendSchema):
try:
payload = send_authenticated_mobile_otp(request.auth, data.mobile)
except AuthServiceError as exc:
return _error_response(exc)
return 200, payload
@auth_router.post("/mobile/verify", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
def verify_mobile(request, data: MobileOtpVerifySchema):
try:
user = verify_authenticated_mobile_otp(request.auth, data.mobile, data.code)
except AuthServiceError as exc:
return _error_response(exc)
return 200, user
return 200, {
"access_token": access_token,
"refresh_token": refresh_token,
"token_type": "bearer"
}
@auth_router.post("/refresh", response={200: TokenSchema, 401: ErrorSchema})
def refresh_tokens(request, data: TokenRefreshIn):
"""Exchange a valid refresh token for a new access (and refresh) token."""
try:
payload = jwt.decode(
data.refresh_token,
@@ -136,77 +186,137 @@ def refresh_tokens(request, data: TokenRefreshIn):
if not user_id:
return 401, {"error": "داده‌های توکن نامعتبر است."}
user = get_object_or_404(User, id=user_id)
if not user.is_email_verified:
return 401, {"error": "برای استفاده، ابتدا ایمیل خود را تأیید کنید."}
if not user.is_active:
return 401, {"error": "حساب کاربری شما غیرفعال است."}
user = get_object_or_404(User, id=user_id, is_active=True)
except jwt.ExpiredSignatureError:
return 401, {"error": "رفرش‌توکن منقضی شده است."}
except jwt.InvalidTokenError:
return 401, {"error": "رفرش‌توکن نامعتبر است."}
access_token = create_jwt_token(user)
refresh_token = create_refresh_token(user)
return {
"access_token": access_token,
"refresh_token": refresh_token,
"access_token": create_jwt_token(user),
"refresh_token": create_refresh_token(user),
"token_type": "bearer",
}
@auth_router.get("/verify-email/{token}", response={200: MessageSchema, 400: ErrorSchema})
def verify_email(request, token: str):
"""Verify user email with token"""
@auth_router.get("/oauth/google/start")
def google_oauth_start(request):
return HttpResponseRedirect(build_google_authorization_url())
@auth_router.get("/oauth/google/callback")
def google_oauth_callback(request):
if request.GET.get("error"):
flow = create_google_flow(
{
"status": "error",
"detail": request.GET.get("error_description") or "Google sign-in was cancelled.",
}
)
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
try:
user = get_object_or_404(User, email_verification_token=token)
consume_google_state(request.GET.get("state"))
profile = exchange_code_for_google_profile(request.GET.get("code"))
social_account = find_social_account_for_profile(profile)
if user.is_email_verified:
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
if social_account and social_account.user.is_mobile_verified:
sync_user_from_google_profile(social_account.user, profile)
flow_payload = build_authenticated_flow_payload(social_account.user)
elif social_account:
sync_user_from_google_profile(social_account.user, profile)
flow_payload = build_pending_google_flow_payload(profile)
flow_payload["resolution"] = "existing_email_claim"
flow_payload["target_user_id"] = social_account.user.id
flow_payload["mobile_hint"] = social_account.user.mobile
else:
flow_payload = build_pending_google_flow_payload(profile)
user.is_email_verified = True
user.save(update_fields=['is_email_verified'])
flow = create_google_flow(flow_payload)
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
except GoogleOAuthFlowError as exc:
flow = create_google_flow({"status": "error", "detail": exc.message})
return HttpResponseRedirect(build_google_callback_redirect_url(flow))
return 200, {"message": "ایمیل شما با موفقیت تأیید شد."}
except User.DoesNotExist:
return 400, {"error": "توکن تأیید نامعتبر است."}
@auth_router.post("/resend-verification", response={200: MessageSchema, 400: ErrorSchema})
def resend_verification(request, email: str):
"""Resend verification email"""
@auth_router.get("/oauth/google/flow", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
def google_oauth_flow(request, flow: str):
try:
user = get_object_or_404(User, email=email)
return 200, get_google_flow(flow)
except GoogleOAuthFlowError as exc:
return _error_response(exc)
if user.is_email_verified:
return 400, {"error": "ایمیل قبلاً تأیید شده است."}
# Generate new token
user.regenerate_verification_token()
user.email_verification_sent_at = timezone.now()
user.save(update_fields=['email_verification_sent_at'])
@auth_router.post("/oauth/google/complete", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
def google_oauth_complete(request, data: GoogleCompleteSchema):
try:
payload = complete_google_signup(
flow=data.flow,
mobile=data.mobile,
username=data.username,
student_id=data.student_id,
year_of_study=data.year_of_study,
major=data.major,
university=data.university,
first_name=data.first_name,
last_name=data.last_name,
)
return 200, payload
except (GoogleOAuthFlowError, AuthServiceError) as exc:
return _error_response(exc)
# Send verification email
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{user.email_verification_token}"
send_verification_email.delay(user.id, verification_url)
return 200, {"message": "ایمیل تأیید برای شما ارسال شد."}
@auth_router.post("/oauth/google/claim/send-otp", response={200: MessageSchema, 400: ErrorSchema})
def google_oauth_claim_send_otp(request, data: GoogleFlowSchema):
try:
return 200, send_google_claim_otp(data.flow)
except (GoogleOAuthFlowError, AuthServiceError) as exc:
return _error_response(exc)
@auth_router.post("/oauth/google/claim/verify", response={200: GoogleFlowResponseSchema, 400: ErrorSchema})
def google_oauth_claim_verify(request, data: GoogleClaimVerifySchema):
try:
return 200, verify_google_claim(data.flow, data.code)
except (GoogleOAuthFlowError, AuthServiceError) as exc:
return _error_response(exc)
@auth_router.get("/verify-email/{token}", response=MessageSchema)
def verify_email_legacy_guidance(request, token: str):
return {
"message": "تایید ایمیل غیرفعال شده است. برای بازیابی حساب از ورود با گوگل یا تایید شماره موبایل استفاده کنید."
}
@auth_router.post("/resend-verification", response=MessageSchema)
def resend_verification_legacy_guidance(request, email: str):
return {
"message": "ارسال ایمیل تایید غیرفعال شده است. برای ادامه، شماره موبایل خود را ثبت یا از ورود با گوگل استفاده کنید."
}
@auth_router.post("/request-password-reset", response=MessageSchema)
def request_password_reset_legacy_guidance(request):
return {
"message": "بازیابی رمز عبور با ایمیل غیرفعال شده است. از بازیابی با کد پیامکی یا ورود با گوگل استفاده کنید."
}
@auth_router.post("/reset-password-confirm", response=MessageSchema)
def reset_password_confirm_legacy_guidance(request):
return {
"message": "لینک بازیابی ایمیلی غیرفعال شده است. لطفاً از بازیابی رمز عبور با موبایل استفاده کنید."
}
except User.DoesNotExist:
return 400, {"error": "کاربر یافت نشد."}
@auth_router.get("/profile", response=UserProfileSchema, auth=jwt_auth)
def get_profile(request):
"""Get current user profile"""
return request.auth
@auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth)
def update_profile(request, data: UserUpdateSchema):
"""Update current user profile"""
user = request.auth
payload = data.dict(exclude_unset=True)
@@ -215,7 +325,7 @@ def update_profile(request, data: UserUpdateSchema):
if code:
major_obj = _get_major_from_code(code)
if not major_obj:
return 400, {"error": "UcO_ O<>OrU?UOU? O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
return 400, {"error": "رشته انتخابی معتبر نیست."}
payload["major"] = major_obj
else:
payload["major"] = None
@@ -225,108 +335,61 @@ def update_profile(request, data: UserUpdateSchema):
if code:
uni_obj = _get_university_from_code(code)
if not uni_obj:
return 400, {"error": "UcO U.U^OO<>O_ O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
return 400, {"error": "دانشگاه انتخابی معتبر نیست."}
payload["university"] = uni_obj
else:
payload["university"] = None
if "email" in payload:
normalized_email = normalize_email_identity(payload["email"])
existing = User.objects.filter(email=normalized_email).exclude(pk=user.pk) if normalized_email else User.objects.none()
if existing.exists():
return 400, {"error": "این ایمیل قبلاً ثبت شده است."}
payload["email"] = normalized_email
payload["is_email_verified"] = False if normalized_email else False
for field, value in payload.items():
setattr(user, field, value)
user.save()
return 200, user
@auth_router.post("/profile/picture", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
def upload_profile_picture(request):
"""Upload profile picture"""
if 'file' not in request.FILES:
if "file" not in request.FILES:
return 400, {"error": "فایلی ارسال نشده است."}
file = request.FILES['file']
# Validate file type
if not file.content_type.startswith('image/'):
file = request.FILES["file"]
if not file.content_type.startswith("image/"):
return 400, {"error": "فایل باید از نوع تصویر باشد."}
# Validate file size (5MB max)
if file.size > 5 * 1024 * 1024:
return 400, {"error": "حجم فایل باید کمتر از ۵ مگابایت باشد."}
user = request.auth
# Save new profile picture
filename = f"profile_pictures/{user.id}_{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
user.profile_picture.save(filename, ContentFile(file.read()))
return 200, {"message": "تصویر پروفایل با موفقیت به‌روزرسانی شد."}
@auth_router.delete("/profile/picture", response={200: MessageSchema}, auth=jwt_auth)
def delete_profile_picture(request):
"""Delete current user's profile picture"""
user = request.auth
if user.profile_picture:
delete_image_derivatives(user.profile_picture, "profile_picture", delete_original=True)
user.profile_picture = None
user.save(update_fields=['profile_picture'])
user.save(update_fields=["profile_picture"])
return 200, {"message": "تصویر پروفایل با موفقیت حذف شد."}
@auth_router.post("/request-password-reset", response={200: MessageSchema, 400: ErrorSchema})
def request_password_reset(request, data: PasswordResetRequestSchema):
"""Request a password reset email"""
try:
user = get_object_or_404(User, email=data.email)
user.set_password_reset_token()
reset_url = f"{settings.FRONTEND_PASSWORD_RESET_PAGE}/{user.password_reset_token}"
send_password_reset_email.delay(user.id, reset_url)
# پیام عمومیِ یکسان برای جلوگیری از افشای وجود/عدم وجود ایمیل
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
except User.DoesNotExist:
return 200, {"message": "اگر حسابی با این ایمیل وجود داشته باشد، لینک بازنشانی رمز عبور ارسال خواهد شد."}
except Exception as e:
return 400, {"error": "درخواست بازنشانی رمز عبور انجام نشد.", "details": str(e)}
@auth_router.post("/reset-password-confirm", response={200: MessageSchema, 400: ErrorSchema})
def reset_password_confirm(request, data: PasswordResetConfirmSchema):
"""Confirm password reset with token and new password"""
try:
user = get_object_or_404(User, password_reset_token=data.token)
if user.password_reset_token_expires_at < timezone.now():
user.password_reset_token = None
user.password_reset_token_expires_at = None
user.save(update_fields=['password_reset_token', 'password_reset_token_expires_at'])
return 400, {"error": "زمان استفاده از لینک تغییر رمز عبور به پایان رسیده است. لطفاً دوباره اقدام کنید."}
user.set_password(data.new_password)
user.password_reset_token = None
user.password_reset_token_expires_at = None
user.save(update_fields=['password', 'password_reset_token', 'password_reset_token_expires_at'])
return 200, {"message": "رمز عبور شما با موفقیت تغییر کرد."}
except User.DoesNotExist:
return 400, {"error": "توکن بازنشانی رمز عبور نامعتبر یا منقضی شده است."}
except Exception as e:
return 400, {"error": "تغییر رمز عبور انجام نشد.", "details": str(e)}
@auth_router.get("/users/deleted", response={200: List[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
@auth_router.get("/users/deleted", response={200: list[UserProfileSchema], 403: ErrorSchema}, auth=jwt_auth)
def list_deleted_users(request):
"""List soft-deleted users via the dedicated manager (Admin/Committee only)."""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "اجازه دسترسی ندارید."}
return User.deleted_objects.all()
@auth_router.post("/users/{user_id}/restore", response={200: MessageSchema, 400: ErrorSchema, 403: ErrorSchema}, auth=jwt_auth)
def restore_user(request, user_id: int):
"""Restore a soft-deleted user (Admin/Committee only)"""
if not (request.auth.is_staff or request.auth.is_superuser):
return 403, {"error": "اجازه دسترسی ندارید."}
@@ -336,10 +399,9 @@ def restore_user(request, user_id: int):
return 200, {"message": f"کاربر {user.username} با موفقیت بازیابی شد."}
except User.DoesNotExist:
return 400, {"error": "کاربر یافت نشد یا حذف نرم نشده است."}
except Exception as e:
return 400, {"error": "بازیابی کاربر انجام نشد.", "details": str(e)}
@auth_router.get("/users", response={200: List[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
@auth_router.get("/users", response={200: list[UserListSchema], 403: ErrorSchema}, auth=jwt_auth)
def list_users(
request,
search: str | None = Query(None),
@@ -356,43 +418,42 @@ def list_users(
return 403, {"error": "اجازه دسترسی ندارید."}
queryset = User.objects.order_by("-date_joined")
if search:
queryset = queryset.filter(
Q(username__icontains=search)
| Q(email__icontains=search)
| Q(mobile__icontains=search)
| Q(first_name__icontains=search)
| Q(last_name__icontains=search)
)
if role == "staff":
queryset = queryset.filter(is_staff=True)
elif role == "superuser":
queryset = queryset.filter(is_superuser=True)
if student_id:
queryset = queryset.filter(student_id__icontains=student_id)
if university:
queryset = queryset.filter(
Q(university__code__icontains=university) | Q(university__name__icontains=university)
)
if major:
queryset = queryset.filter(
Q(major__code__icontains=major) | Q(major__name__icontains=major)
)
queryset = queryset.filter(Q(major__code__icontains=major) | Q(major__name__icontains=major))
if is_active is not None:
if is_active.lower() in ("true", "1"):
queryset = queryset.filter(is_active=True)
elif is_active.lower() in ("false", "0"):
queryset = queryset.filter(is_active=False)
return queryset[offset : offset + limit]
@auth_router.get("/check-username", response=UsernameCheckSchema)
def check_username_availability(request, username: str):
"""Check if a username is available for registration"""
exists = User.objects.filter(username=username).exists()
return {"exists": exists}
return {"exists": User.objects.filter(username=username).exists()}
@auth_router.get("/check-mobile", response={200: MobileLookupSchema, 400: ErrorSchema})
def check_mobile_availability(request, mobile: str):
try:
return 200, lookup_mobile_registration_state(mobile)
except AuthServiceError as exc:
return _error_response(exc)

View File

@@ -0,0 +1,35 @@
from __future__ import annotations
PLACEHOLDER_EMAIL_SUFFIX = "@noemail.local"
def normalize_email_identity(value: str | None) -> str | None:
if value is None:
return None
normalized = value.strip().lower()
return normalized or None
def normalize_mobile_number(value: str | None) -> str | None:
if value is None:
return None
normalized = "".join(ch for ch in value if ch.isdigit())
return normalized or None
def is_valid_mobile_number(value: str | None) -> bool:
normalized = normalize_mobile_number(value)
return bool(normalized and len(normalized) == 11 and normalized.startswith("09"))
def mask_mobile(value: str | None) -> str | None:
if not value:
return None
if len(value) <= 4:
return value
return f"{value[:2]}{'*' * max(len(value) - 6, 1)}{value[-4:]}"
def is_placeholder_email(value: str | None) -> bool:
normalized = normalize_email_identity(value)
return bool(normalized and normalized.endswith(PLACEHOLDER_EMAIL_SUFFIX))

View File

@@ -0,0 +1 @@
"""User management commands."""

View File

@@ -0,0 +1 @@
"""User management commands package."""

View File

@@ -0,0 +1,42 @@
from collections import defaultdict
from django.core.management.base import BaseCommand
from apps.users.email_identity import is_placeholder_email, normalize_email_identity
from apps.users.models import User
class Command(BaseCommand):
help = "Audit legacy email identities for Google auto-link safety."
def handle(self, *args, **options):
duplicate_map: dict[str, list[User]] = defaultdict(list)
placeholder_users: list[User] = []
email_only_users: list[User] = []
for user in User.all_objects.all().order_by("id"):
normalized_email = normalize_email_identity(user.email)
if normalized_email:
duplicate_map[normalized_email].append(user)
if is_placeholder_email(user.email):
placeholder_users.append(user)
if normalized_email and not user.mobile:
email_only_users.append(user)
duplicates = {email: users for email, users in duplicate_map.items() if len(users) > 1}
self.stdout.write(self.style.WARNING(f"Case-insensitive duplicate emails: {len(duplicates)}"))
for email, users in duplicates.items():
user_ids = ", ".join(str(user.id) for user in users)
self.stdout.write(f" {email}: user_ids=[{user_ids}]")
self.stdout.write(self.style.WARNING(f"Placeholder emails: {len(placeholder_users)}"))
for user in placeholder_users[:50]:
self.stdout.write(f" user_id={user.id} email={user.email}")
self.stdout.write(self.style.WARNING(f"Email-only users needing mobile bind: {len(email_only_users)}"))
for user in email_only_users[:100]:
self.stdout.write(f" user_id={user.id} email={user.email}")
if not duplicates and not placeholder_users:
self.stdout.write(self.style.SUCCESS("No blocking legacy email issues were found."))

View File

@@ -0,0 +1,55 @@
# Generated by Django 5.2.5 on 2026-05-20 18:44
import django.db.models.deletion
from django.conf import settings
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('users', '0006_remove_legacy_fields'),
]
operations = [
migrations.AddField(
model_name='user',
name='is_mobile_verified',
field=models.BooleanField(default=False),
),
migrations.AddField(
model_name='user',
name='mobile',
field=models.CharField(blank=True, default=None, max_length=11, null=True, unique=True),
),
migrations.AlterField(
model_name='user',
name='email',
field=models.EmailField(blank=True, default=None, max_length=254, null=True, unique=True),
),
migrations.CreateModel(
name='UserSocialAccount',
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)),
('provider', models.CharField(choices=[('google', 'google')], max_length=32)),
('provider_user_id', models.CharField(max_length=255)),
('email', models.EmailField(blank=True, default=None, max_length=254, null=True)),
('email_verified', models.BooleanField(default=False)),
('avatar_url', models.URLField(blank=True, default='')),
('is_active', models.BooleanField(default=True)),
('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='social_accounts', to=settings.AUTH_USER_MODEL)),
],
options={
'verbose_name': 'User Social Account',
'verbose_name_plural': 'User Social Accounts',
'db_table': 'user_social_accounts',
'ordering': ['-updated_at', '-created_at'],
'indexes': [models.Index(fields=['provider', 'provider_user_id'], name='user_social_provider_uid_idx'), models.Index(fields=['provider', 'email'], name='user_social_provider_email_idx')],
'constraints': [models.UniqueConstraint(fields=('provider', 'provider_user_id'), name='user_social_account_provider_uid_uniq')],
},
),
]

View File

@@ -11,6 +11,7 @@ from core.media import (
safe_process_public_image,
)
from core.models import BaseModel
from apps.users.email_identity import normalize_email_identity, normalize_mobile_number
class University(BaseModel):
@@ -38,7 +39,8 @@ class Major(BaseModel):
class User(AbstractUser, BaseModel):
email = models.EmailField(unique=True)
email = models.EmailField(unique=True, null=True, blank=True, default=None)
mobile = models.CharField(max_length=11, unique=True, null=True, blank=True, default=None)
bio = models.TextField(null=True, blank=True)
profile_picture = models.ImageField(upload_to='profile_pictures/', null=True, blank=True)
@@ -59,14 +61,15 @@ class User(AbstractUser, BaseModel):
related_name='users',
)
is_email_verified = models.BooleanField(default=False)
is_mobile_verified = models.BooleanField(default=False)
email_verification_token = models.UUIDField(default=uuid.uuid4, unique=True)
email_verification_sent_at = models.DateTimeField(null=True, blank=True)
password_reset_token = models.UUIDField(null=True, blank=True, unique=True)
password_reset_token_expires_at = models.DateTimeField(null=True, blank=True)
USERNAME_FIELD = 'email'
REQUIRED_FIELDS = ['username']
USERNAME_FIELD = 'username'
REQUIRED_FIELDS = []
class Meta:
db_table = 'users'
@@ -74,7 +77,8 @@ class User(AbstractUser, BaseModel):
verbose_name_plural = 'Users'
def __str__(self):
return f"{self.get_full_name()} ({self.email})"
identity = self.mobile or self.email or self.username
return f"{self.get_full_name() or self.username} ({identity})"
def get_full_name(self):
return f"{self.first_name} {self.last_name}".strip()
@@ -89,6 +93,17 @@ class User(AbstractUser, BaseModel):
return self.university.name
return None
@property
def requires_mobile_verification(self):
return not self.is_mobile_verified
@property
def has_google_link(self):
return self.social_accounts.filter(
provider=UserSocialAccount.ProviderType.GOOGLE,
is_active=True,
).exists()
def regenerate_verification_token(self):
self.email_verification_token = uuid.uuid4()
self.save(update_fields=['email_verification_token'])
@@ -102,12 +117,8 @@ class User(AbstractUser, BaseModel):
def save(self, *args, **kwargs):
previous_image_name = get_image_previous_name(self, "profile_picture")
current_image_name = self.profile_picture.name if self.profile_picture else None
send_verified_success = False
if self.pk is not None:
prev = type(self).objects.filter(pk=self.pk).values_list('is_email_verified', flat=True).first()
if prev is not None and prev is False and self.is_email_verified is True:
send_verified_success = True
self.email = normalize_email_identity(self.email)
self.mobile = normalize_mobile_number(self.mobile)
super().save(*args, **kwargs)
@@ -122,9 +133,42 @@ class User(AbstractUser, BaseModel):
if previous_image_name != current_image_name and self.profile_picture:
safe_process_public_image(self.profile_picture, "profile_picture")
if send_verified_success:
try:
from apps.users.tasks import send_email_verified_success
send_email_verified_success.delay(self.id)
except Exception:
pass
class UserSocialAccount(BaseModel):
class ProviderType(models.TextChoices):
GOOGLE = "google", "google"
user = models.ForeignKey(
User,
on_delete=models.CASCADE,
related_name="social_accounts",
)
provider = models.CharField(max_length=32, choices=ProviderType.choices)
provider_user_id = models.CharField(max_length=255)
email = models.EmailField(blank=True, null=True, default=None)
email_verified = models.BooleanField(default=False)
avatar_url = models.URLField(blank=True, default="")
is_active = models.BooleanField(default=True)
class Meta:
verbose_name = "User Social Account"
verbose_name_plural = "User Social Accounts"
db_table = "user_social_accounts"
ordering = ["-updated_at", "-created_at"]
constraints = [
models.UniqueConstraint(
fields=("provider", "provider_user_id"),
name="user_social_account_provider_uid_uniq",
)
]
indexes = [
models.Index(fields=["provider", "provider_user_id"], name="user_social_provider_uid_idx"),
models.Index(fields=["provider", "email"], name="user_social_provider_email_idx"),
]
def __str__(self):
return f"{self.provider}:{self.provider_user_id}"
def save(self, *args, **kwargs):
self.email = normalize_email_identity(self.email)
super().save(*args, **kwargs)

View File

@@ -0,0 +1 @@
"""User authentication and OAuth services."""

309
apps/users/services/auth.py Normal file
View File

@@ -0,0 +1,309 @@
from __future__ import annotations
import random
import string
from dataclasses import dataclass
from datetime import timedelta
from django.contrib.auth import password_validation
from django.core.exceptions import ValidationError as DjangoValidationError
from django.db import transaction
from django.utils import timezone
from django_redis import get_redis_connection
from apps.users.email_identity import (
is_valid_mobile_number,
normalize_email_identity,
normalize_mobile_number,
)
from apps.users.models import Major, University, User
from apps.users.tasks import send_critical_sms
from core.authentication import create_jwt_token, create_refresh_token
OTP_EXPIRY_SECONDS = 120
VERIFIED_OTP_EXPIRY_SECONDS = 900
OTP_KEY_PREFIX = "auth_otp"
OTP_VERIFIED_KEY_PREFIX = "auth_otp_verified"
SMS_KIND_REGISTER = "auth_register_otp"
SMS_KIND_LOGIN = "auth_login_otp"
SMS_KIND_RESET_PASSWORD = "auth_reset_password_otp"
SMS_KIND_VERIFY_MOBILE = "auth_verify_mobile_otp"
OTP_MODE_SMS_KIND = {
"register": SMS_KIND_REGISTER,
"login": SMS_KIND_LOGIN,
"reset_password": SMS_KIND_RESET_PASSWORD,
"verify_mobile": SMS_KIND_VERIFY_MOBILE,
"google_claim": SMS_KIND_VERIFY_MOBILE,
}
class AuthServiceError(Exception):
def __init__(self, message: str, *, field: str = "detail", status_code: int = 400):
super().__init__(message)
self.message = message
self.field = field
self.status_code = status_code
def to_response(self) -> dict[str, str]:
return {"error": self.message}
@dataclass
class RegistrationPayload:
username: str
mobile: str
password: str
code: str
email: str | None = None
first_name: str = ""
last_name: str = ""
university: str | None = None
student_id: str | None = None
year_of_study: int | None = None
major: str | None = None
def _otp_key(mode: str, mobile: str) -> str:
return f"{OTP_KEY_PREFIX}:{mode}:{mobile}"
def _verified_otp_key(mode: str, mobile: str) -> str:
return f"{OTP_VERIFIED_KEY_PREFIX}:{mode}:{mobile}"
def _redis():
return get_redis_connection("default")
def _validate_new_password(password: str, *, user: User | None = None, field_name: str = "password") -> None:
try:
password_validation.validate_password(password, user=user)
except DjangoValidationError as exc:
message = exc.messages[0] if len(exc.messages) == 1 else exc.messages[0]
raise AuthServiceError(message, field=field_name)
def _resolve_user_by_identifier(identifier: str) -> User | None:
normalized_mobile = normalize_mobile_number(identifier)
if is_valid_mobile_number(normalized_mobile):
return User.objects.filter(mobile=normalized_mobile).first()
normalized_email = normalize_email_identity(identifier)
if normalized_email:
return User.objects.filter(email=normalized_email).first()
return None
def _get_major_from_code(code: str | None) -> Major | None:
if not code:
return None
return Major.objects.filter(code=code, is_deleted=False).first()
def _get_university_from_code(code: str | None) -> University | None:
if not code:
return None
return University.objects.filter(code=code, is_deleted=False).first()
def _normalize_otp_code(code: str) -> str:
normalized = (
str(code or "")
.strip()
.translate(str.maketrans("۰۱۲۳۴۵۶۷۸۹٠١٢٣٤٥٦٧٨٩", "01234567890123456789"))
)
if len(normalized) != 5 or not normalized.isdigit():
raise AuthServiceError("کد تایید باید شامل ۵ رقم باشد.", field="code")
return normalized
def _issue_otp(mobile: str, mode: str) -> dict[str, str | int]:
verification_code = "".join(random.choices(string.digits, k=5))
redis_conn = _redis()
redis_conn.setex(_otp_key(mode, mobile), OTP_EXPIRY_SECONDS, verification_code)
send_critical_sms.delay(mobile, OTP_MODE_SMS_KIND.get(mode, SMS_KIND_VERIFY_MOBILE), verification_code)
expires_at = timezone.now() + timedelta(seconds=OTP_EXPIRY_SECONDS)
return {
"message": "کد تایید با موفقیت ارسال شد.",
"expires_in_seconds": OTP_EXPIRY_SECONDS,
"expires_at": expires_at.isoformat(),
}
def verify_otp_code(*, mobile: str, code: str, mode: str) -> None:
normalized_code = _normalize_otp_code(code)
redis_conn = _redis()
stored_code = redis_conn.get(_otp_key(mode, mobile))
if not stored_code or stored_code.decode("utf-8") != normalized_code:
raise AuthServiceError("کد تایید نامعتبر است یا منقضی شده است.", field="code")
redis_conn.delete(_otp_key(mode, mobile))
def verify_register_otp(mobile: str, code: str) -> None:
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
verify_otp_code(mobile=normalized_mobile, code=code, mode="register")
_redis().setex(_verified_otp_key("register", normalized_mobile), VERIFIED_OTP_EXPIRY_SECONDS, "1")
def _consume_verified_otp(mobile: str, mode: str) -> bool:
redis_conn = _redis()
key = _verified_otp_key(mode, mobile)
if redis_conn.get(key):
redis_conn.delete(key)
return True
return False
def get_tokens_for_user(user: User) -> dict[str, str]:
return {
"access_token": create_jwt_token(user),
"refresh_token": create_refresh_token(user),
"token_type": "bearer",
}
def generate_and_send_otp(mobile: str, mode: str) -> dict[str, str | int]:
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
user_exists = User.objects.filter(mobile=normalized_mobile).exists()
if mode == "register" and user_exists:
raise AuthServiceError("این شماره قبلاً ثبت شده است.", field="mobile")
if mode in {"login", "reset_password"} and not user_exists:
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
return _issue_otp(normalized_mobile, mode)
@transaction.atomic
def register_user(payload: RegistrationPayload) -> User:
if payload.student_id and len(str(payload.student_id)) < 10:
raise AuthServiceError("شماره دانشجویی باید حداقل ۱۰ رقم باشد.", field="student_id")
if User.objects.filter(username=payload.username).exists():
raise AuthServiceError("نام کاربری قبلاً استفاده شده است.", field="username")
normalized_email = normalize_email_identity(payload.email)
if normalized_email and User.objects.filter(email=normalized_email).exists():
raise AuthServiceError("این ایمیل قبلاً ثبت شده است.", field="email")
normalized_mobile = normalize_mobile_number(payload.mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
if not _consume_verified_otp(normalized_mobile, "register"):
verify_otp_code(mobile=normalized_mobile, code=payload.code, mode="register")
_validate_new_password(payload.password)
major = _get_major_from_code(payload.major)
if payload.major and not major:
raise AuthServiceError("رشته انتخابی معتبر نیست.", field="major")
university = _get_university_from_code(payload.university)
if payload.university and not university:
raise AuthServiceError("دانشگاه انتخابی معتبر نیست.", field="university")
if payload.student_id and university and User.objects.filter(
university=university,
student_id=payload.student_id,
).exists():
raise AuthServiceError(
"این شماره دانشجویی در دانشگاه انتخابی قبلاً ثبت شده است.",
field="student_id",
)
user = User.objects.create_user(
username=payload.username,
email=normalized_email,
mobile=normalized_mobile,
password=payload.password,
first_name=payload.first_name or "",
last_name=payload.last_name or "",
student_id=payload.student_id,
year_of_study=payload.year_of_study,
major=major,
university=university,
is_email_verified=bool(normalized_email),
is_mobile_verified=True,
)
return user
def login_with_password(identifier: str, password: str) -> dict[str, str]:
user = _resolve_user_by_identifier(identifier)
if not user or not user.check_password(password):
raise AuthServiceError("شناسه ورود یا رمز عبور نادرست است.", status_code=401)
if not user.is_active:
raise AuthServiceError("حساب کاربری شما غیرفعال است.", status_code=401)
return get_tokens_for_user(user)
def login_with_otp(mobile: str, code: str) -> dict[str, str]:
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
user = User.objects.filter(mobile=normalized_mobile).first()
if not user:
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
if not user.is_active:
raise AuthServiceError("حساب کاربری شما غیرفعال است.", status_code=401)
verify_otp_code(mobile=normalized_mobile, code=code, mode="login")
if not user.is_mobile_verified:
user.is_mobile_verified = True
user.save(update_fields=["is_mobile_verified"])
return get_tokens_for_user(user)
def reset_password_with_otp(mobile: str, code: str, password: str) -> None:
normalized_mobile = normalize_mobile_number(mobile)
user = User.objects.filter(mobile=normalized_mobile).first()
if not user:
raise AuthServiceError("کاربری با این شماره موبایل یافت نشد.", field="mobile")
verify_otp_code(mobile=normalized_mobile, code=code, mode="reset_password")
_validate_new_password(password, user=user)
if user.check_password(password):
raise AuthServiceError("رمز عبور جدید نباید با رمز قبلی یکسان باشد.", field="password")
user.set_password(password)
user.save(update_fields=["password"])
def send_authenticated_mobile_otp(user: User, mobile: str) -> dict[str, str | int]:
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=user.pk).exists()
if conflict:
raise AuthServiceError("این شماره موبایل قبلاً به حساب دیگری متصل شده است.", field="mobile")
return _issue_otp(normalized_mobile, "verify_mobile")
def verify_authenticated_mobile_otp(user: User, mobile: str, code: str) -> User:
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=user.pk).exists()
if conflict:
raise AuthServiceError("این شماره موبایل قبلاً به حساب دیگری متصل شده است.", field="mobile")
verify_otp_code(mobile=normalized_mobile, code=code, mode="verify_mobile")
user.mobile = normalized_mobile
user.is_mobile_verified = True
user.save(update_fields=["mobile", "is_mobile_verified"])
return user
def lookup_mobile_registration_state(mobile: str) -> dict[str, bool]:
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise AuthServiceError("شماره موبایل معتبر نیست.", field="mobile")
user = User.objects.filter(mobile=normalized_mobile).first()
return {
"exists": bool(user),
"has_password": bool(user and user.has_usable_password()),
}

View File

@@ -0,0 +1,559 @@
from __future__ import annotations
import secrets
from dataclasses import asdict, dataclass
from typing import Any
from urllib.parse import urlencode
from urllib.parse import urlparse
import requests
from django.conf import settings
from django.core.cache import cache
from django.core.files.base import ContentFile
from apps.users.email_identity import (
is_placeholder_email,
is_valid_mobile_number,
mask_mobile,
normalize_email_identity,
normalize_mobile_number,
)
from apps.users.models import Major, University, User, UserSocialAccount
from apps.users.services.auth import (
AuthServiceError,
RegistrationPayload,
generate_and_send_otp,
get_tokens_for_user,
verify_otp_code,
)
GOOGLE_AUTH_URL = "https://accounts.google.com/o/oauth2/v2/auth"
GOOGLE_TOKEN_URL = "https://oauth2.googleapis.com/token"
GOOGLE_USERINFO_URL = "https://openidconnect.googleapis.com/v1/userinfo"
GOOGLE_STATE_TTL_SECONDS = 300
GOOGLE_FLOW_TTL_SECONDS = 900
GOOGLE_STATE_CACHE_PREFIX = "google_oauth_state"
GOOGLE_FLOW_CACHE_PREFIX = "google_oauth_flow"
class GoogleOAuthFlowError(AuthServiceError):
def __init__(
self,
message: str,
*,
code: str = "google_flow_error",
status_code: int = 409,
extra: dict[str, Any] | None = None,
):
super().__init__(message, field="detail", status_code=status_code)
self.code = code
self.extra = extra or {}
@dataclass
class GoogleProfile:
provider_user_id: str
email: str
email_verified: bool
first_name: str
last_name: str
avatar_url: str
def _cache_key(prefix: str, token: str) -> str:
return f"{prefix}:{token}"
def _create_token() -> str:
return secrets.token_urlsafe(32)
def _required_setting(name: str) -> str:
value = getattr(settings, name, "")
if not value:
raise GoogleOAuthFlowError(f"{name} is not configured.", status_code=500)
return value
def _public_flow_payload(flow_payload: dict[str, Any]) -> dict[str, Any]:
status = flow_payload.get("status")
base = {"status": status}
for key in (
"email",
"first_name",
"last_name",
"avatar_url",
"resolution",
"mobile",
"mobile_hint",
"detail",
"access_token",
"refresh_token",
):
if key in flow_payload:
base[key] = flow_payload[key]
return base
def _profile_payload(profile: GoogleProfile) -> dict[str, Any]:
return asdict(profile)
def _profile_from_payload(payload: dict[str, Any]) -> GoogleProfile:
raw = payload.get("google_profile")
if not isinstance(raw, dict):
raise GoogleOAuthFlowError(
"Google flow profile data is missing.",
code="google_flow_invalid_state",
status_code=400,
)
return GoogleProfile(**raw)
def create_google_state() -> str:
state = _create_token()
cache.set(_cache_key(GOOGLE_STATE_CACHE_PREFIX, state), {"valid": True}, GOOGLE_STATE_TTL_SECONDS)
return state
def consume_google_state(state: str) -> None:
key = _cache_key(GOOGLE_STATE_CACHE_PREFIX, state or "")
payload = cache.get(key)
cache.delete(key)
if not payload:
raise GoogleOAuthFlowError(
"Google sign-in state is invalid or expired.",
code="google_flow_invalid_state",
status_code=400,
)
def create_google_flow(payload: dict[str, Any]) -> str:
flow = _create_token()
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
return flow
def get_google_flow_payload(flow: str) -> dict[str, Any]:
payload = cache.get(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow))
if not payload:
raise GoogleOAuthFlowError(
"Google sign-in flow is invalid or expired.",
code="google_flow_invalid_state",
status_code=400,
)
return payload
def get_google_flow(flow: str) -> dict[str, Any]:
return _public_flow_payload(get_google_flow_payload(flow))
def update_google_flow(flow: str, payload: dict[str, Any]) -> None:
cache.set(_cache_key(GOOGLE_FLOW_CACHE_PREFIX, flow), payload, GOOGLE_FLOW_TTL_SECONDS)
def build_google_authorization_url() -> str:
state = create_google_state()
params = {
"client_id": _required_setting("GOOGLE_OAUTH_CLIENT_ID"),
"redirect_uri": _required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
"response_type": "code",
"scope": "openid email profile",
"state": state,
"access_type": "online",
"prompt": "select_account",
}
return f"{GOOGLE_AUTH_URL}?{urlencode(params)}"
def exchange_code_for_google_profile(code: str) -> GoogleProfile:
if not code:
raise GoogleOAuthFlowError("Missing Google authorization code.", status_code=400)
try:
token_response = requests.post(
GOOGLE_TOKEN_URL,
data={
"code": code,
"client_id": _required_setting("GOOGLE_OAUTH_CLIENT_ID"),
"client_secret": _required_setting("GOOGLE_OAUTH_CLIENT_SECRET"),
"redirect_uri": _required_setting("GOOGLE_OAUTH_REDIRECT_URI"),
"grant_type": "authorization_code",
},
timeout=10,
)
token_response.raise_for_status()
token_payload = token_response.json()
except requests.RequestException as exc:
raise GoogleOAuthFlowError("Google token exchange failed.", status_code=400) from exc
access_token = token_payload.get("access_token")
if not access_token:
raise GoogleOAuthFlowError("Google did not return an access token.", status_code=400)
try:
userinfo_response = requests.get(
GOOGLE_USERINFO_URL,
headers={"Authorization": f"Bearer {access_token}"},
timeout=10,
)
userinfo_response.raise_for_status()
userinfo = userinfo_response.json()
except requests.RequestException as exc:
raise GoogleOAuthFlowError("Google user profile lookup failed.", status_code=400) from exc
email = normalize_email_identity(userinfo.get("email"))
if not userinfo.get("sub") or not email or not userinfo.get("email_verified"):
raise GoogleOAuthFlowError(
"Google account must have a verified email address.",
status_code=400,
)
return GoogleProfile(
provider_user_id=userinfo.get("sub"),
email=email,
email_verified=bool(userinfo.get("email_verified")),
first_name=userinfo.get("given_name", "") or "",
last_name=userinfo.get("family_name", "") or "",
avatar_url=userinfo.get("picture", "") or "",
)
def build_google_callback_redirect_url(flow: str) -> str:
callback = _required_setting("GOOGLE_OAUTH_FRONTEND_CALLBACK_URL")
sep = "&" if "?" in callback else "?"
return f"{callback}{sep}flow={flow}"
def find_social_account_for_profile(profile: GoogleProfile) -> UserSocialAccount | None:
return (
UserSocialAccount.objects.select_related("user")
.filter(provider=UserSocialAccount.ProviderType.GOOGLE, provider_user_id=profile.provider_user_id)
.first()
)
def _avatar_file_extension(profile: GoogleProfile) -> str:
path = urlparse(profile.avatar_url or "").path
if "." in path:
suffix = path.rsplit(".", 1)[-1].lower()
if suffix in {"jpg", "jpeg", "png", "webp", "gif"}:
return suffix
return "jpg"
def sync_user_from_google_profile(user: User, profile: GoogleProfile) -> None:
update_fields: list[str] = []
if not user.first_name and profile.first_name:
user.first_name = profile.first_name
update_fields.append("first_name")
if not user.last_name and profile.last_name:
user.last_name = profile.last_name
update_fields.append("last_name")
if not user.email and profile.email:
user.email = profile.email
user.is_email_verified = True
update_fields.extend(["email", "is_email_verified"])
if not user.profile_picture and profile.avatar_url:
try:
avatar_response = requests.get(profile.avatar_url, timeout=10)
avatar_response.raise_for_status()
except requests.RequestException:
avatar_response = None
if avatar_response and avatar_response.content:
filename = f"google-{profile.provider_user_id}.{_avatar_file_extension(profile)}"
user.profile_picture.save(filename, ContentFile(avatar_response.content), save=False)
update_fields.append("profile_picture")
if update_fields:
user.save(update_fields=update_fields)
def build_authenticated_flow_payload(user: User) -> dict[str, Any]:
tokens = get_tokens_for_user(user)
return {
"status": "authenticated",
"access_token": tokens["access_token"],
"refresh_token": tokens["refresh_token"],
}
def _is_google_claim_candidate(user: User | None) -> bool:
if user is None:
return False
if is_placeholder_email(user.email):
return False
normalized_email = normalize_email_identity(user.email)
return bool(normalized_email)
def build_pending_google_flow_payload(profile: GoogleProfile) -> dict[str, Any]:
existing_email_user = None
if not is_placeholder_email(profile.email):
existing_email_user = User.objects.filter(email=profile.email).first()
resolution = "existing_email_claim" if _is_google_claim_candidate(existing_email_user) else "new_account"
mobile_hint = mask_mobile(existing_email_user.mobile) if existing_email_user else None
has_verified_mobile = bool(existing_email_user and existing_email_user.mobile and existing_email_user.is_mobile_verified)
return {
"status": "collect_profile",
"google_profile": _profile_payload(profile),
"email": profile.email,
"first_name": profile.first_name,
"last_name": profile.last_name,
"avatar_url": profile.avatar_url,
"resolution": resolution,
"target_user_id": existing_email_user.id if existing_email_user else None,
"mobile_hint": mobile_hint,
"has_verified_mobile": has_verified_mobile,
}
def _resolve_major(code: str | None) -> Major | None:
if not code:
return None
return Major.objects.filter(code=code, is_deleted=False).first()
def _resolve_university(code: str | None) -> University | None:
if not code:
return None
return University.objects.filter(code=code, is_deleted=False).first()
def complete_google_signup(
*,
flow: str,
mobile: str,
username: str | None = None,
student_id: str | None = None,
year_of_study: int | None = None,
major: str | None = None,
university: str | None = None,
first_name: str | None = None,
last_name: str | None = None,
) -> dict[str, Any]:
flow_payload = get_google_flow_payload(flow)
if flow_payload.get("status") != "collect_profile":
raise GoogleOAuthFlowError(
"Google sign-in flow is in an unexpected state.",
code="google_flow_invalid_state",
status_code=400,
)
normalized_mobile = normalize_mobile_number(mobile)
if not is_valid_mobile_number(normalized_mobile):
raise GoogleOAuthFlowError("شماره موبایل معتبر نیست.", status_code=400)
profile = _profile_from_payload(flow_payload)
resolution = flow_payload.get("resolution", "new_account")
target_user_id = flow_payload.get("target_user_id")
target_user = User.objects.filter(pk=target_user_id).first() if target_user_id else None
if resolution == "existing_email_claim":
if target_user is None:
raise GoogleOAuthFlowError(
"Target account could not be found.",
code="google_flow_invalid_state",
status_code=400,
)
conflict = User.objects.filter(mobile=normalized_mobile).exclude(pk=target_user.pk).first()
if conflict and normalize_email_identity(conflict.email) not in (None, profile.email):
raise GoogleOAuthFlowError(
"این شماره موبایل قبلاً به حساب دیگری متصل شده است.",
code="google_mobile_belongs_to_other_email",
status_code=409,
)
generate_and_send_otp(normalized_mobile, "google_claim")
claim_payload = {
"status": "claim_required",
"google_profile": _profile_payload(profile),
"resolution": resolution,
"target_user_id": target_user.id,
"mobile": normalized_mobile,
"email": profile.email,
"mobile_hint": mask_mobile(normalized_mobile),
"detail": "مالکیت شماره موبایل را تایید کنید تا ورود با گوگل تکمیل شود.",
}
update_google_flow(flow, claim_payload)
return _public_flow_payload(claim_payload)
if not username:
raise GoogleOAuthFlowError("نام کاربری الزامی است.", code="google_missing_username", status_code=400)
if User.objects.filter(username=username).exists():
raise GoogleOAuthFlowError("نام کاربری قبلاً استفاده شده است.", code="google_username_taken", status_code=400)
if student_id and len(str(student_id)) < 10:
raise GoogleOAuthFlowError("شماره دانشجویی باید حداقل ۱۰ رقم باشد.", code="google_invalid_student_id", status_code=400)
major_obj = _resolve_major(major)
if major and not major_obj:
raise GoogleOAuthFlowError("رشته انتخابی معتبر نیست.", code="google_invalid_major", status_code=400)
university_obj = _resolve_university(university)
if university and not university_obj:
raise GoogleOAuthFlowError("دانشگاه انتخابی معتبر نیست.", code="google_invalid_university", status_code=400)
if student_id and university_obj and User.objects.filter(university=university_obj, student_id=student_id).exists():
raise GoogleOAuthFlowError(
"این شماره دانشجویی در دانشگاه انتخابی قبلاً ثبت شده است.",
code="google_student_id_taken",
status_code=400,
)
conflict = User.objects.filter(mobile=normalized_mobile).first()
if conflict:
existing_email = normalize_email_identity(conflict.email)
if existing_email not in (None, profile.email):
raise GoogleOAuthFlowError(
"این شماره موبایل قبلاً به حساب دیگری متصل شده است.",
code="google_mobile_belongs_to_other_email",
status_code=409,
)
generate_and_send_otp(normalized_mobile, "google_claim")
claim_payload = {
"status": "claim_required",
"google_profile": _profile_payload(profile),
"resolution": "new_account",
"mobile": normalized_mobile,
"email": profile.email,
"username": username,
"student_id": student_id,
"year_of_study": year_of_study,
"major": major,
"university": university,
"first_name": first_name or profile.first_name,
"last_name": last_name or profile.last_name,
"detail": "کد تایید موبایل را وارد کنید تا حساب جدید شما ساخته شود.",
}
update_google_flow(flow, claim_payload)
return _public_flow_payload(claim_payload)
def send_google_claim_otp(flow: str) -> dict[str, str]:
flow_payload = get_google_flow_payload(flow)
if flow_payload.get("status") != "claim_required":
raise GoogleOAuthFlowError(
"Google sign-in flow is in an unexpected state.",
code="google_flow_invalid_state",
status_code=400,
)
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str) or not mobile:
raise GoogleOAuthFlowError("Claim mobile number is missing.", status_code=400)
generate_and_send_otp(mobile, "google_claim")
return {"message": "کد تایید مجدداً ارسال شد."}
def _link_google_account(*, user: User, profile: GoogleProfile) -> None:
social = find_social_account_for_profile(profile)
if social and social.user_id != user.id:
raise GoogleOAuthFlowError(
"این حساب گوگل قبلاً به کاربر دیگری متصل شده است.",
code="google_already_linked",
status_code=409,
)
sync_user_from_google_profile(user, profile)
if social:
social.email = profile.email
social.email_verified = profile.email_verified
social.avatar_url = profile.avatar_url
social.is_active = True
social.save(update_fields=["email", "email_verified", "avatar_url", "is_active"])
return
UserSocialAccount.objects.create(
user=user,
provider=UserSocialAccount.ProviderType.GOOGLE,
provider_user_id=profile.provider_user_id,
email=profile.email,
email_verified=profile.email_verified,
avatar_url=profile.avatar_url,
is_active=True,
)
def verify_google_claim(flow: str, code: str) -> dict[str, Any]:
flow_payload = get_google_flow_payload(flow)
if flow_payload.get("status") != "claim_required":
raise GoogleOAuthFlowError(
"Google sign-in flow is in an unexpected state.",
code="google_flow_invalid_state",
status_code=400,
)
mobile = flow_payload.get("mobile")
if not isinstance(mobile, str) or not mobile:
raise GoogleOAuthFlowError("Claim mobile number is missing.", status_code=400)
verify_otp_code(mobile=mobile, code=code, mode="google_claim")
profile = _profile_from_payload(flow_payload)
resolution = flow_payload.get("resolution", "new_account")
if resolution == "existing_email_claim":
target_user_id = flow_payload.get("target_user_id")
user = User.objects.filter(pk=target_user_id).first()
if user is None or normalize_email_identity(user.email) != profile.email:
raise GoogleOAuthFlowError(
"The matching account could not be verified.",
code="google_email_claim_failed",
status_code=409,
)
user.mobile = mobile
user.is_mobile_verified = True
user.is_email_verified = True
user.save(update_fields=["mobile", "is_mobile_verified", "is_email_verified"])
_link_google_account(user=user, profile=profile)
authenticated = build_authenticated_flow_payload(user)
update_google_flow(flow, authenticated)
return _public_flow_payload(authenticated)
normalized_email = normalize_email_identity(profile.email)
if normalized_email and User.objects.filter(email=normalized_email).exists():
raise GoogleOAuthFlowError(
"این ایمیل قبلاً به حساب دیگری متصل شده است.",
code="google_email_taken",
status_code=409,
)
payload = RegistrationPayload(
username=flow_payload.get("username", ""),
mobile=mobile,
password=_create_token(),
code=code,
email=normalized_email,
first_name=flow_payload.get("first_name") or profile.first_name,
last_name=flow_payload.get("last_name") or profile.last_name,
university=flow_payload.get("university"),
student_id=flow_payload.get("student_id"),
year_of_study=flow_payload.get("year_of_study"),
major=flow_payload.get("major"),
)
if not payload.username:
raise GoogleOAuthFlowError("نام کاربری برای ساخت حساب جدید الزامی است.", status_code=400)
major_obj = _resolve_major(payload.major)
university_obj = _resolve_university(payload.university)
user = User.objects.create_user(
username=payload.username,
email=payload.email,
mobile=payload.mobile,
password=None,
first_name=payload.first_name or "",
last_name=payload.last_name or "",
student_id=payload.student_id,
year_of_study=payload.year_of_study,
major=major_obj,
university=university_obj,
is_mobile_verified=True,
is_email_verified=bool(payload.email),
)
user.set_unusable_password()
user.save(update_fields=["password"])
_link_google_account(user=user, profile=profile)
authenticated = build_authenticated_flow_payload(user)
update_google_flow(flow, authenticated)
return _public_flow_payload(authenticated)

View File

@@ -2,26 +2,12 @@ import uuid
from django.db.models.signals import post_save
from django.dispatch import receiver
from django.utils import timezone
from django.conf import settings
from apps.users.models import User
from apps.users.tasks import send_verification_email
@receiver(post_save, sender=User)
def send_verification_email_on_registration(sender, instance, created, **kwargs):
if created:
if not instance.username:
def ensure_username_on_registration(sender, instance, created, **kwargs):
if created and not instance.username:
instance.username = str(uuid.uuid4())[:10]
instance.save(update_fields=['username'])
if not instance.is_email_verified and instance.email:
# Update the email verification sent timestamp
instance.email_verification_sent_at = timezone.now()
instance.save(update_fields=['email_verification_sent_at'])
# Generate verification URL (you'll need to adjust this based on your frontend)
verification_url = f"{settings.FRONTEND_ROOT}verify-email/{instance.email_verification_token}"
# Send verification email asynchronously
send_verification_email.delay(instance.id, verification_url)
instance.save(update_fields=["username"])

View File

@@ -1,99 +1,90 @@
from django.core.mail import send_mail
from django.template.loader import render_to_string
from django.conf import settings
from django.utils.html import strip_tags
from celery import shared_task
import logging
from apps.users.models import User
import requests
from celery import shared_task
from django.conf import settings
logger = logging.getLogger(__name__)
@shared_task(bind=True, max_retries=3)
def send_verification_email(self, user_id, verification_url):
try:
user = User.objects.get(id=user_id)
SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify"
subject = 'تایید ایمیل | انجمن علمی مهندسی کامپیوتر'
html_message = render_to_string('emails/verification_email.html', {
'user': user,
'verification_url': verification_url,
})
plain_message = strip_tags(html_message)
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"Verification email sent to {user.email}")
return f"Verification email sent to {user.email}"
except Exception as exc:
logger.error(f"Failed to send verification email: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_password_reset_email(self, user_id, reset_url):
try:
user = User.objects.get(id=user_id)
subject = 'بازیابی رمز عبور | انجمن علمی مهندسی کامپیوتر'
html_message = render_to_string('emails/password_reset_email.html', {
'user': user,
'reset_url': reset_url,
})
plain_message = strip_tags(html_message)
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"Password reset email sent to {user.email}")
return f"Password reset email sent to {user.email}"
except Exception as exc:
logger.error(f"Failed to send password reset email: {exc}")
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=3)
def send_email_verified_success(self, user_id: int):
"""
ارسال ایمیل «ایمیل شما با موفقیت تأیید شد» پس از تغییر وضعیت تأیید.
"""
try:
user = User.objects.get(pk=user_id)
subject = "تأیید ایمیل شما با موفقیت انجام شد"
context = {
"user": user,
"home_url": getattr(settings, "FRONTEND_ROOT", "/"),
SMS_TEMPLATE_MAP = {
"auth_register_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"auth_login_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"auth_reset_password_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"auth_verify_mobile_otp": "SMS_AUTH_OTP_TEMPLATE_ID",
"event_cancellation": "SMS_EVENT_CANCELLATION_TEMPLATE_ID",
"event_reschedule": "SMS_EVENT_RESCHEDULE_TEMPLATE_ID",
"payment_status": "SMS_PAYMENT_STATUS_TEMPLATE_ID",
}
html_message = render_to_string("emails/verification_success.html", context)
plain_message = strip_tags(html_message)
send_mail(
subject=subject,
message=plain_message,
from_email=settings.DEFAULT_FROM_EMAIL,
recipient_list=[user.email],
html_message=html_message,
fail_silently=False,
def _template_id_for_kind(kind: str) -> str:
setting_name = SMS_TEMPLATE_MAP.get(kind, "")
return getattr(settings, setting_name, "") if setting_name else ""
def _send_sms(receptor: str, template_id: str | int, variables: list[dict] | None = None):
headers = {
"Content-Type": "application/json",
"Accept": "application/json",
"x-api-key": settings.SMS_APIKEY,
}
payload = {
"mobile": receptor,
"templateId": int(template_id),
"parameters": variables or [],
}
response = requests.post(
SMS_ENDPOINT,
json=payload,
headers=headers,
timeout=10,
)
logger.info(f"verified success email sent to {user.email}")
return f"verified success email sent to {user.email}"
response.raise_for_status()
return response
@shared_task(bind=True, max_retries=3)
def send_critical_sms(self, mobile: str, kind: str, code_or_message: str):
try:
template_id = _template_id_for_kind(kind)
if not template_id or not settings.SMS_APIKEY:
logger.info(
"SMS skipped for mobile=%s kind=%s template=%s configured=%s payload=%s",
mobile,
kind,
bool(template_id),
bool(settings.SMS_APIKEY),
code_or_message,
)
return {"mobile": mobile, "kind": kind, "sent": False}
variables = [{"name": "OTP", "value": str(code_or_message)}]
if kind in {"event_cancellation", "event_reschedule", "payment_status"}:
variables = [{"name": "MESSAGE", "value": str(code_or_message)}]
_send_sms(mobile, template_id, variables=variables)
logger.info("SMS sent to %s for kind=%s", mobile, kind)
return {"mobile": mobile, "kind": kind, "sent": True}
except Exception as exc:
logger.error(f"Failed to send verified success email: {exc}")
logger.error("Failed to send SMS to %s for kind=%s: %s", mobile, kind, exc)
raise self.retry(exc=exc, countdown=60)
@shared_task(bind=True, max_retries=1)
def send_verification_email(self, user_id, verification_url):
logger.info("Legacy verification email task skipped for user=%s url=%s", user_id, verification_url)
return {"skipped": True}
@shared_task(bind=True, max_retries=1)
def send_password_reset_email(self, user_id, reset_url):
logger.info("Legacy password reset email task skipped for user=%s url=%s", user_id, reset_url)
return {"skipped": True}
@shared_task(bind=True, max_retries=1)
def send_email_verified_success(self, user_id: int):
logger.info("Legacy verification success email task skipped for user=%s", user_id)
return {"skipped": True}

View File

@@ -5,6 +5,7 @@ from apps.certificates.api.views import certificates_router
from apps.communications.api.views import communications_router
from apps.events.api.views import events_router
from apps.gallery.api.views import gallery_router
from apps.notifications.api.views import notifications_router
from apps.payments.api.views import payments_router
from apps.users.api.meta import meta_router
from apps.users.api.views import auth_router
@@ -15,9 +16,9 @@ router.add_router("auth/", auth_router, tags=["Authentication"])
router.add_router("blog/", blog_router, tags=["Blog"])
router.add_router("gallery/", gallery_router, tags=["Gallery"])
router.add_router("events/", events_router, tags=["Events"])
router.add_router("notifications/", notifications_router, tags=["Notifications"])
router.add_router("communications/", communications_router, tags=["Communications"])
router.add_router("payments/", payments_router, tags=["Payments"])
router.add_router("certificates/", certificates_router, tags=["Certificates"])
router.add_router("meta/", meta_router, tags=["Meta"])
router.add_router("", health_router, tags=["Health"])

View File

@@ -33,15 +33,10 @@ app.conf.beat_schedule = {
'schedule': crontab(minute=0, hour='*/1'),
'description': 'Runs hourly to notify about upcoming events.',
},
'send-weekly-newsletter': {
'task': 'apps.communications.tasks.send_weekly_newsletter',
'schedule': crontab(hour=9, minute=0, day_of_week=1),
'description': 'Runs every Monday at 09:00 UTC.',
},
'cleanup-expired-tokens': {
'task': 'apps.communications.tasks.cleanup_expired_tokens',
'cleanup-notification-retention': {
'task': 'apps.notifications.tasks.cleanup_notification_retention',
'schedule': crontab(hour=2, minute=0),
'description': 'Runs daily at 02:00 UTC.',
'description': 'Runs daily at 02:00 UTC to cleanup notification retention.',
},
'process-scheduled-announcements': {
'task': 'apps.communications.tasks.process_scheduled_announcements',

View File

@@ -36,6 +36,7 @@ THIRD_PARTY_APPS = [
LOCAL_APPS = [
"core",
"apps.users",
"apps.notifications",
"apps.blog",
"apps.gallery",
"apps.events",
@@ -151,11 +152,18 @@ SESSION_COOKIE_SECURE = config('SESSION_COOKIE_SECURE', default=True, cast=bool)
EMAIL_BACKEND = config('EMAIL_BACKEND', default='django.core.mail.backends.console.EmailBackend')
EMAIL_HOST = config('EMAIL_HOST', default='')
EMAIL_PORT = config('EMAIL_PORT', default=587, cast=int)
EMAIL_USE_SSL = config('EMAIL_USE_SSL', default=False, cast=bool)
EMAIL_USE_TLS = config('EMAIL_USE_TLS', default=True, cast=bool)
EMAIL_HOST_USER = config('EMAIL_HOST_USER', default='')
EMAIL_HOST_PASSWORD = config('EMAIL_HOST_PASSWORD', default='')
DEFAULT_FROM_EMAIL = config('DEFAULT_FROM_EMAIL', default='webmaster@localhost')
SMS_APIKEY = config('SMS_APIKEY', default='')
SMS_AUTH_OTP_TEMPLATE_ID = config('SMS_AUTH_OTP_TEMPLATE_ID', default='')
SMS_EVENT_CANCELLATION_TEMPLATE_ID = config('SMS_EVENT_CANCELLATION_TEMPLATE_ID', default='')
SMS_EVENT_RESCHEDULE_TEMPLATE_ID = config('SMS_EVENT_RESCHEDULE_TEMPLATE_ID', default='')
SMS_PAYMENT_STATUS_TEMPLATE_ID = config('SMS_PAYMENT_STATUS_TEMPLATE_ID', default='')
# JWT Configuration
JWT_SECRET_KEY = config('JWT_SECRET_KEY', default=SECRET_KEY)
JWT_ALGORITHM = config('JWT_ALGORITHM', default='HS256')
@@ -174,8 +182,8 @@ CACHES = {
}
# Celery Configuration
CELERY_BROKER_URL = REDIS_URL
CELERY_RESULT_BACKEND = REDIS_URL
CELERY_BROKER_URL = config('CELERY_BROKER_URL', default=REDIS_URL)
CELERY_RESULT_BACKEND = config('CELERY_RESULT_BACKEND', default=REDIS_URL)
# Logging Configuration
@@ -230,6 +238,18 @@ BACKEND_ROOT = config('DJANGO_HOST', default='http://localhost:8000/')
FRONTEND_ROOT = config('FRONTEND_ROOT', default='http://localhost:3000/')
FRONTEND_PASSWORD_RESET_PAGE = config('FRONTEND_PASSWORD_RESET_PAGE', default='http://localhost:3000/api/auth/reset-password-confirm/')
FRONTEND_CALLBACK_URL = config('FRONTEND_CALLBACK_URL', default='http://localhost:3000/payments/result')
GOOGLE_OAUTH_CLIENT_ID = config('GOOGLE_OAUTH_CLIENT_ID', default='')
GOOGLE_OAUTH_CLIENT_SECRET = config('GOOGLE_OAUTH_CLIENT_SECRET', default='')
GOOGLE_OAUTH_REDIRECT_URI = config('GOOGLE_OAUTH_REDIRECT_URI', default='')
GOOGLE_OAUTH_FRONTEND_CALLBACK_URL = config('GOOGLE_OAUTH_FRONTEND_CALLBACK_URL', default='http://localhost:8080/auth/google/callback')
NOTIFICATIONS_ENABLED = config('NOTIFICATIONS_ENABLED', default=True, cast=bool)
NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS = config('NOTIFICATION_STREAM_TOKEN_LIFETIME_SECONDS', default=300, cast=int)
NOTIFICATION_SSE_HEARTBEAT_SECONDS = config('NOTIFICATION_SSE_HEARTBEAT_SECONDS', default=20, cast=int)
NOTIFICATION_SSE_RETRY_MS = config('NOTIFICATION_SSE_RETRY_MS', default=3000, cast=int)
NOTIFICATION_REDIS_CHANNEL_PREFIX = config('NOTIFICATION_REDIS_CHANNEL_PREFIX', default='notif')
NOTIFICATION_RETENTION_DAYS = config('NOTIFICATION_RETENTION_DAYS', default=30, cast=int)
NOTIFICATION_DEFAULT_PAGE_SIZE = config('NOTIFICATION_DEFAULT_PAGE_SIZE', default=20, cast=int)
NOTIFICATION_MAX_PAGE_SIZE = config('NOTIFICATION_MAX_PAGE_SIZE', default=100, cast=int)
if DATABASES["default"]["ENGINE"] == "django.db.backends.postgresql":
DATABASES["default"]["ENGINE"] = "django_prometheus.db.backends.postgresql"

View File

@@ -3,6 +3,7 @@ from django.urls import path, include
from django.conf import settings
from django.conf.urls.static import static
from ninja import NinjaAPI
from apps.notifications.api.views import NotificationStreamView
from config.api import router as api_router
api = NinjaAPI(
@@ -16,6 +17,7 @@ api.add_router("", api_router)
urlpatterns = [
path('admin/', admin.site.urls),
path('api/', api.urls),
path("api/notifications/stream/", NotificationStreamView.as_view()),
path("", include("django_prometheus.urls")),
]

View File

@@ -19,7 +19,6 @@ class JWTAuth(HttpBearer):
if user_id:
user = User.objects.get(
id=user_id,
is_email_verified=True,
is_active=True,
)
return user
@@ -32,6 +31,7 @@ def create_jwt_token(user):
payload = {
"user_id": user.id,
"email": user.email,
"mobile": user.mobile,
"exp": datetime.now(UTC) + timedelta(seconds=settings.JWT_ACCESS_TOKEN_LIFETIME),
"iat": datetime.now(UTC),
}
@@ -49,4 +49,3 @@ def create_refresh_token(user):
jwt_auth = JWTAuth()