feat(backend): migrate auth and notifications off email
This commit is contained in:
93
.env.example
Normal file
93
.env.example
Normal 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=
|
||||
60
.env.sample
60
.env.sample
@@ -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
1
.gitignore
vendored
@@ -100,6 +100,7 @@ celerybeat-schedule.*
|
||||
|
||||
# Environments
|
||||
.env
|
||||
.env.prod
|
||||
.venv
|
||||
env/
|
||||
venv/
|
||||
|
||||
@@ -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/`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
1
apps/notifications/__init__.py
Normal file
1
apps/notifications/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Redis-backed in-app notifications."""
|
||||
1
apps/notifications/api/__init__.py
Normal file
1
apps/notifications/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""Notifications API package."""
|
||||
52
apps/notifications/api/schemas.py
Normal file
52
apps/notifications/api/schemas.py
Normal 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
|
||||
156
apps/notifications/api/views.py
Normal file
156
apps/notifications/api/views.py
Normal 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
|
||||
7
apps/notifications/apps.py
Normal file
7
apps/notifications/apps.py
Normal 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"
|
||||
276
apps/notifications/services.py
Normal file
276
apps/notifications/services.py
Normal 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)
|
||||
14
apps/notifications/tasks.py
Normal file
14
apps/notifications/tasks.py
Normal 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
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
35
apps/users/email_identity.py
Normal file
35
apps/users/email_identity.py
Normal 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))
|
||||
1
apps/users/management/__init__.py
Normal file
1
apps/users/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""User management commands."""
|
||||
1
apps/users/management/commands/__init__.py
Normal file
1
apps/users/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""User management commands package."""
|
||||
@@ -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."))
|
||||
@@ -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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
@@ -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)
|
||||
|
||||
1
apps/users/services/__init__.py
Normal file
1
apps/users/services/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
"""User authentication and OAuth services."""
|
||||
309
apps/users/services/auth.py
Normal file
309
apps/users/services/auth.py
Normal 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()),
|
||||
}
|
||||
559
apps/users/services/google_oauth.py
Normal file
559
apps/users/services/google_oauth.py
Normal 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)
|
||||
@@ -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"])
|
||||
|
||||
@@ -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)
|
||||
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",
|
||||
}
|
||||
|
||||
|
||||
@shared_task(bind=True, max_retries=3)
|
||||
def send_email_verified_success(self, user_id: int):
|
||||
"""
|
||||
ارسال ایمیل «ایمیل شما با موفقیت تأیید شد» پس از تغییر وضعیت تأیید.
|
||||
"""
|
||||
try:
|
||||
user = User.objects.get(pk=user_id)
|
||||
def _template_id_for_kind(kind: str) -> str:
|
||||
setting_name = SMS_TEMPLATE_MAP.get(kind, "")
|
||||
return getattr(settings, setting_name, "") if setting_name else ""
|
||||
|
||||
subject = "تأیید ایمیل شما با موفقیت انجام شد"
|
||||
context = {
|
||||
"user": user,
|
||||
"home_url": getattr(settings, "FRONTEND_ROOT", "/"),
|
||||
|
||||
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,
|
||||
}
|
||||
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,
|
||||
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}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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")),
|
||||
]
|
||||
|
||||
|
||||
@@ -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()
|
||||
|
||||
|
||||
Reference in New Issue
Block a user