From b7b21a6cc66e311270b9e44c935977713badd436 Mon Sep 17 00:00:00 2001 From: Amirhossein Khalili Date: Thu, 21 May 2026 10:28:04 +0330 Subject: [PATCH] feat(backend): migrate auth and notifications off email --- .env.example | 93 +++ .env.sample | 60 -- .gitignore | 3 +- README.md | 3 +- apps/communications/api/schemas.py | 94 +-- apps/communications/api/views.py | 338 ++++------ apps/communications/tasks.py | 322 +++------ apps/events/api/views.py | 78 +++ apps/events/tasks.py | 626 +++++------------- apps/notifications/__init__.py | 1 + apps/notifications/api/__init__.py | 1 + apps/notifications/api/schemas.py | 52 ++ apps/notifications/api/views.py | 156 +++++ apps/notifications/apps.py | 7 + apps/notifications/services.py | 276 ++++++++ apps/notifications/tasks.py | 14 + apps/payments/api/views.py | 62 ++ apps/users/api/schemas.py | 180 +++-- apps/users/api/views.py | 497 ++++++++------ apps/users/email_identity.py | 35 + apps/users/management/__init__.py | 1 + apps/users/management/commands/__init__.py | 1 + .../commands/audit_legacy_email_identities.py | 42 ++ ...d_user_mobile_alter_user_email_and_more.py | 55 ++ apps/users/models.py | 76 ++- apps/users/services/__init__.py | 1 + apps/users/services/auth.py | 309 +++++++++ apps/users/services/google_oauth.py | 559 ++++++++++++++++ apps/users/signals.py | 24 +- apps/users/tasks.py | 165 +++-- config/api.py | 3 +- config/services/celery.py | 11 +- config/settings/base.py | 24 +- config/urls.py | 2 + core/authentication.py | 3 +- 35 files changed, 2784 insertions(+), 1390 deletions(-) create mode 100644 .env.example delete mode 100644 .env.sample create mode 100644 apps/notifications/__init__.py create mode 100644 apps/notifications/api/__init__.py create mode 100644 apps/notifications/api/schemas.py create mode 100644 apps/notifications/api/views.py create mode 100644 apps/notifications/apps.py create mode 100644 apps/notifications/services.py create mode 100644 apps/notifications/tasks.py create mode 100644 apps/users/email_identity.py create mode 100644 apps/users/management/__init__.py create mode 100644 apps/users/management/commands/__init__.py create mode 100644 apps/users/management/commands/audit_legacy_email_identities.py create mode 100644 apps/users/migrations/0007_user_is_mobile_verified_user_mobile_alter_user_email_and_more.py create mode 100644 apps/users/services/__init__.py create mode 100644 apps/users/services/auth.py create mode 100644 apps/users/services/google_oauth.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..30fd6bf --- /dev/null +++ b/.env.example @@ -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= diff --git a/.env.sample b/.env.sample deleted file mode 100644 index cff6806..0000000 --- a/.env.sample +++ /dev/null @@ -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= - diff --git a/.gitignore b/.gitignore index 1601d03..3ed61a5 100644 --- a/.gitignore +++ b/.gitignore @@ -100,6 +100,7 @@ celerybeat-schedule.* # Environments .env +.env.prod .venv env/ venv/ @@ -136,4 +137,4 @@ GitHub.sublime-settings !.vscode/tasks.json !.vscode/launch.json !.vscode/extensions.json -.history \ No newline at end of file +.history diff --git a/README.md b/README.md index 073b6c8..72d4580 100644 --- a/README.md +++ b/README.md @@ -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//api`. - Shared auth helpers live in `core/authentication.py`. - Shared base models, admin helpers, choices, and template tags live under `core/`. - diff --git a/apps/communications/api/schemas.py b/apps/communications/api/schemas.py index a5134e9..16cf389 100644 --- a/apps/communications/api/schemas.py +++ b/apps/communications/api/schemas.py @@ -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 diff --git a/apps/communications/api/views.py b/apps/communications/api/views.py index edc64b6..be82022 100644 --- a/apps/communications/api/views.py +++ b/apps/communications/api/views.py @@ -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: - return {"error": "Announcement not found"}, 404 - - user = request.auth - if not (user.is_staff or user.is_committee): - return {"error": "Announcement not found"}, 404 - + if not announcement.is_published and not _is_staff_user(getattr(request, "auth", None)): + 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 = 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() - - # 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.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 - } - ) - - 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 +def subscribe_newsletter(request): + return { + "message": "خبرنامه ایمیلی حذف شده است. اطلاع‌رسانی‌ها از این پس درون‌سایتی و در موارد مهم از طریق پیامک انجام می‌شود." + } + @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 - ) - + users = User.objects.filter(is_active=True) + if payload.target_audience == "committee": + users = users.filter(is_staff=True) + total_sent = push_service.send_to_multiple_users(users, payload.title, payload.body, payload.data) return {"message": f"Push notification sent to {total_sent} devices"} -# 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] diff --git a/apps/communications/tasks.py b/apps/communications/tasks.py index c9ca18c..c96898d 100644 --- a/apps/communications/tasks.py +++ b/apps/communications/tasks.py @@ -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) - - 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") + notify_user( + registration.user_id, + { + "type": "event_reminder", + "title": f"یادآوری رویداد: {event.title}", + "message": "رویداد شما در ۲۴ ساعت آینده برگزار می‌شود.", + "level": "info", + "action_url": f"/events/{event.slug}", + "entity_type": "event", + "entity_id": event.id, + }, + ) + total_sent += 1 + logger.info("Event reminders sent to %s users", total_sent) return f"Event reminders sent to {total_sent} users" - except Exception as exc: - logger.error(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 diff --git a/apps/events/api/views.py b/apps/events/api/views.py index 4e0fe18..4985441 100644 --- a/apps/events/api/views.py +++ b/apps/events/api/views.py @@ -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) diff --git a/apps/events/tasks.py b/apps/events/tasks.py index 1e69c8e..9c2c779 100644 --- a/apps/events/tasks.py +++ b/apps/events/tasks.py @@ -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}") - raise self.retry(exc=exc, countdown=60) + 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 diff --git a/apps/notifications/__init__.py b/apps/notifications/__init__.py new file mode 100644 index 0000000..b349564 --- /dev/null +++ b/apps/notifications/__init__.py @@ -0,0 +1 @@ +"""Redis-backed in-app notifications.""" diff --git a/apps/notifications/api/__init__.py b/apps/notifications/api/__init__.py new file mode 100644 index 0000000..ec9d372 --- /dev/null +++ b/apps/notifications/api/__init__.py @@ -0,0 +1 @@ +"""Notifications API package.""" diff --git a/apps/notifications/api/schemas.py b/apps/notifications/api/schemas.py new file mode 100644 index 0000000..282a63b --- /dev/null +++ b/apps/notifications/api/schemas.py @@ -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 diff --git a/apps/notifications/api/views.py b/apps/notifications/api/views.py new file mode 100644 index 0000000..99230b7 --- /dev/null +++ b/apps/notifications/api/views.py @@ -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 diff --git a/apps/notifications/apps.py b/apps/notifications/apps.py new file mode 100644 index 0000000..95dbb8b --- /dev/null +++ b/apps/notifications/apps.py @@ -0,0 +1,7 @@ +from django.apps import AppConfig + + +class NotificationsConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.notifications" + label = "notifications" diff --git a/apps/notifications/services.py b/apps/notifications/services.py new file mode 100644 index 0000000..aba2ca3 --- /dev/null +++ b/apps/notifications/services.py @@ -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) diff --git a/apps/notifications/tasks.py b/apps/notifications/tasks.py new file mode 100644 index 0000000..06ee7a8 --- /dev/null +++ b/apps/notifications/tasks.py @@ -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 diff --git a/apps/payments/api/views.py b/apps/payments/api/views.py index 7861c98..1106983 100644 --- a/apps/payments/api/views.py +++ b/apps/payments/api/views.py @@ -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) diff --git a/apps/users/api/schemas.py b/apps/users/api/schemas.py index be95327..135315b 100644 --- a/apps/users/api/schemas.py +++ b/apps/users/api/schemas.py @@ -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 diff --git a/apps/users/api/views.py b/apps/users/api/views.py index d253bab..3a3f32f 100644 --- a/apps/users/api/views.py +++ b/apps/users/api/views.py @@ -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( - username=data.username, - email=data.email, - password=data.password, - student_id=data.student_id, - first_name=data.first_name or "", - last_name=data.last_name or "", - year_of_study=data.year_of_study, - major=major_obj, - university=university_obj, + user = register_user( + RegistrationPayload( + username=data.username, + mobile=data.mobile, + password=data.password, + 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=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) - - if not user: - return 401, {"error": "ایمیل یا رمز عبور نادرست است."} - - if not user.is_email_verified: - return 401, {"error": "برای ورود، ابتدا ایمیل خود را تأیید کنید."} - - if not user.is_active: - return 401, {"error": "حساب کاربری شما غیرفعال است."} - - access_token = create_jwt_token(user) - refresh_token = create_refresh_token(user) - - return 200, { - "access_token": access_token, - "refresh_token": refresh_token, - "token_type": "bearer" - } + try: + return 200, login_with_password(data.identifier, data.password) + except AuthServiceError as exc: + return _error_response(exc) + + +@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) + + +@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 + @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""" - try: - user = get_object_or_404(User, email_verification_token=token) - - if user.is_email_verified: - return 400, {"error": "ایمیل قبلاً تأیید شده است."} - - user.is_email_verified = True - user.save(update_fields=['is_email_verified']) - - 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/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=email) - - 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']) - - # 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": "ایمیل تأیید برای شما ارسال شد."} - - except User.DoesNotExist: - return 400, {"error": "کاربر یافت نشد."} + 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 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) + + 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)) + + +@auth_router.get("/oauth/google/flow", response={200: GoogleFlowResponseSchema, 400: ErrorSchema}) +def google_oauth_flow(request, flow: str): + try: + return 200, get_google_flow(flow) + except GoogleOAuthFlowError as exc: + return _error_response(exc) + + +@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) + + +@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": "لینک بازیابی ایمیلی غیرفعال شده است. لطفاً از بازیابی رمز عبور با موبایل استفاده کنید." + } + @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�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�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) diff --git a/apps/users/email_identity.py b/apps/users/email_identity.py new file mode 100644 index 0000000..fe44e31 --- /dev/null +++ b/apps/users/email_identity.py @@ -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)) diff --git a/apps/users/management/__init__.py b/apps/users/management/__init__.py new file mode 100644 index 0000000..0d116a7 --- /dev/null +++ b/apps/users/management/__init__.py @@ -0,0 +1 @@ +"""User management commands.""" diff --git a/apps/users/management/commands/__init__.py b/apps/users/management/commands/__init__.py new file mode 100644 index 0000000..a162e46 --- /dev/null +++ b/apps/users/management/commands/__init__.py @@ -0,0 +1 @@ +"""User management commands package.""" diff --git a/apps/users/management/commands/audit_legacy_email_identities.py b/apps/users/management/commands/audit_legacy_email_identities.py new file mode 100644 index 0000000..b22fbf0 --- /dev/null +++ b/apps/users/management/commands/audit_legacy_email_identities.py @@ -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.")) diff --git a/apps/users/migrations/0007_user_is_mobile_verified_user_mobile_alter_user_email_and_more.py b/apps/users/migrations/0007_user_is_mobile_verified_user_mobile_alter_user_email_and_more.py new file mode 100644 index 0000000..d5546b1 --- /dev/null +++ b/apps/users/migrations/0007_user_is_mobile_verified_user_mobile_alter_user_email_and_more.py @@ -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')], + }, + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index b0676f1..fd6eb53 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -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) diff --git a/apps/users/services/__init__.py b/apps/users/services/__init__.py new file mode 100644 index 0000000..348fab4 --- /dev/null +++ b/apps/users/services/__init__.py @@ -0,0 +1 @@ +"""User authentication and OAuth services.""" diff --git a/apps/users/services/auth.py b/apps/users/services/auth.py new file mode 100644 index 0000000..06658eb --- /dev/null +++ b/apps/users/services/auth.py @@ -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()), + } diff --git a/apps/users/services/google_oauth.py b/apps/users/services/google_oauth.py new file mode 100644 index 0000000..bbed62c --- /dev/null +++ b/apps/users/services/google_oauth.py @@ -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) diff --git a/apps/users/signals.py b/apps/users/signals.py index fa23f36..a2f13bb 100644 --- a/apps/users/signals.py +++ b/apps/users/signals.py @@ -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: - 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) +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"]) diff --git a/apps/users/tasks.py b/apps/users/tasks.py index 9ce2a68..8a8e5d8 100644 --- a/apps/users/tasks.py +++ b/apps/users/tasks.py @@ -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__) +SMS_ENDPOINT = "https://api.sms.ir/v1/send/verify" + +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", +} + + +def _template_id_for_kind(kind: str) -> str: + setting_name = SMS_TEMPLATE_MAP.get(kind, "") + return getattr(settings, setting_name, "") if setting_name else "" + + +def _send_sms(receptor: str, template_id: str | int, variables: list[dict] | None = None): + headers = { + "Content-Type": "application/json", + "Accept": "application/json", + "x-api-key": settings.SMS_APIKEY, + } + payload = { + "mobile": receptor, + "templateId": int(template_id), + "parameters": variables or [], + } + response = requests.post( + SMS_ENDPOINT, + json=payload, + headers=headers, + timeout=10, + ) + 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("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): - try: - user = User.objects.get(id=user_id) - - 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) + 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=3) + +@shared_task(bind=True, max_retries=1) 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) + 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=3) +@shared_task(bind=True, max_retries=1) def send_email_verified_success(self, user_id: int): - """ - ارسال ایمیل «ایمیل شما با موفقیت تأیید شد» پس از تغییر وضعیت تأیید. - """ - try: - user = User.objects.get(pk=user_id) - - subject = "تأیید ایمیل شما با موفقیت انجام شد" - context = { - "user": user, - "home_url": getattr(settings, "FRONTEND_ROOT", "/"), - } - 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, - ) - logger.info(f"verified success email sent to {user.email}") - return f"verified success email sent to {user.email}" - - except Exception as exc: - logger.error(f"Failed to send verified success email: {exc}") - raise self.retry(exc=exc, countdown=60) + logger.info("Legacy verification success email task skipped for user=%s", user_id) + return {"skipped": True} diff --git a/config/api.py b/config/api.py index 374fb53..ce8d259 100644 --- a/config/api.py +++ b/config/api.py @@ -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"]) - diff --git a/config/services/celery.py b/config/services/celery.py index f848576..79df0ac 100644 --- a/config/services/celery.py +++ b/config/services/celery.py @@ -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', diff --git a/config/settings/base.py b/config/settings/base.py index f4ba27c..f0948d6 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -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" diff --git a/config/urls.py b/config/urls.py index 740270d..60ea067 100644 --- a/config/urls.py +++ b/config/urls.py @@ -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")), ] diff --git a/core/authentication.py b/core/authentication.py index 4e52f87..ffd72fb 100644 --- a/core/authentication.py +++ b/core/authentication.py @@ -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() -