init
This commit is contained in:
9
backend/api/views/__init__.py
Normal file
9
backend/api/views/__init__.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from api.views.auth import auth_router
|
||||
from api.views.blog import blog_router
|
||||
from api.views.gallery import gallery_router
|
||||
from api.views.events import events_router
|
||||
from api.views.certificates import certificates_router
|
||||
from api.views.communications import communications_router
|
||||
from api.views.payments import payments_router
|
||||
from api.views.meta import meta_router
|
||||
from api.views.health import health_router
|
||||
397
backend/api/views/auth.py
Normal file
397
backend/api/views/auth.py
Normal file
@@ -0,0 +1,397 @@
|
||||
from typing import List
|
||||
|
||||
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.storage import default_storage
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
import uuid
|
||||
import jwt
|
||||
from ninja import Query, Router
|
||||
|
||||
from users.models import User, Major, University
|
||||
from users.tasks import send_verification_email, send_password_reset_email
|
||||
from api.authentication import create_jwt_token, create_refresh_token, jwt_auth
|
||||
from api.schemas import (
|
||||
UserRegistrationSchema, UserLoginSchema, UserProfileSchema,
|
||||
UserUpdateSchema, TokenSchema, TokenRefreshIn, MessageSchema, ErrorSchema,
|
||||
PasswordResetRequestSchema, PasswordResetConfirmSchema, UsernameCheckSchema,
|
||||
UserListSchema
|
||||
)
|
||||
|
||||
auth_router = Router()
|
||||
|
||||
def _get_major_from_code(code: str | None):
|
||||
if not code:
|
||||
return None
|
||||
return Major.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
def _get_university_from_code(code: str | None):
|
||||
if not code:
|
||||
return None
|
||||
return University.objects.filter(code=code, is_deleted=False).first()
|
||||
|
||||
|
||||
@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,
|
||||
)
|
||||
|
||||
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("/login", response={200: TokenSchema, 401: 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"
|
||||
}
|
||||
|
||||
@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,
|
||||
settings.JWT_SECRET_KEY,
|
||||
algorithms=[settings.JWT_ALGORITHM],
|
||||
)
|
||||
if payload.get("type") != "refresh":
|
||||
return 401, {"error": "نوع توکن نامعتبر است."}
|
||||
|
||||
user_id = payload.get("user_id")
|
||||
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": "حساب کاربری شما غیرفعال است."}
|
||||
|
||||
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,
|
||||
"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"""
|
||||
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": "کاربر یافت نشد."}
|
||||
|
||||
@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)
|
||||
|
||||
if "major" in payload:
|
||||
code = payload.pop("major")
|
||||
if code:
|
||||
major_obj = _get_major_from_code(code)
|
||||
if not major_obj:
|
||||
return 400, {"error": "UcO_ O<>OrU?UOU? O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
|
||||
payload["major"] = major_obj
|
||||
else:
|
||||
payload["major"] = None
|
||||
|
||||
if "university" in payload:
|
||||
code = payload.pop("university")
|
||||
if code:
|
||||
uni_obj = _get_university_from_code(code)
|
||||
if not uni_obj:
|
||||
return 400, {"error": "UcO U.U^OO<>O_ O<>U^UcU+ O<>O<EFBFBD>UOUOO_."}
|
||||
payload["university"] = uni_obj
|
||||
else:
|
||||
payload["university"] = None
|
||||
|
||||
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:
|
||||
return 400, {"error": "فایلی ارسال نشده است."}
|
||||
|
||||
file = request.FILES['file']
|
||||
|
||||
# Validate file type
|
||||
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
|
||||
|
||||
# Delete old profile picture if exists
|
||||
if user.profile_picture:
|
||||
default_storage.delete(user.profile_picture.name)
|
||||
|
||||
# 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:
|
||||
default_storage.delete(user.profile_picture.name)
|
||||
user.profile_picture = None
|
||||
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)
|
||||
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": "اجازه دسترسی ندارید."}
|
||||
|
||||
try:
|
||||
user = User.deleted_objects.get(id=user_id)
|
||||
user.restore()
|
||||
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)
|
||||
def list_users(
|
||||
request,
|
||||
search: str | None = Query(None),
|
||||
role: str | None = Query(None, description="staff or superuser"),
|
||||
student_id: str | None = Query(None),
|
||||
university: str | None = Query(None),
|
||||
major: str | None = Query(None),
|
||||
is_active: str | None = Query(None, description="true or false"),
|
||||
limit: int = Query(50, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
user = request.auth
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
queryset = User.objects.order_by("-date_joined")
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(username__icontains=search)
|
||||
| Q(email__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)
|
||||
)
|
||||
|
||||
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}
|
||||
|
||||
|
||||
299
backend/api/views/blog.py
Normal file
299
backend/api/views/blog.py
Normal file
@@ -0,0 +1,299 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Prefetch
|
||||
|
||||
from ninja import Router, Query
|
||||
from typing import List, Optional
|
||||
|
||||
from users.models import User
|
||||
from blog.models import Post, Category, Tag, Comment, Like
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas import (
|
||||
PostListSchema, PostDetailSchema, PostCreateSchema,
|
||||
CategorySchema, TagSchema, CommentSchema, CommentCreateSchema,
|
||||
MessageSchema, ErrorSchema
|
||||
)
|
||||
|
||||
blog_router = Router()
|
||||
|
||||
# Post endpoints
|
||||
@blog_router.get("/posts", response=List[PostListSchema])
|
||||
def list_posts(
|
||||
request,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(10, ge=1, le=50),
|
||||
category: Optional[str] = None,
|
||||
tag: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
featured: Optional[bool] = None,
|
||||
author: Optional[str] = None
|
||||
):
|
||||
"""List published posts with filtering and pagination"""
|
||||
queryset = Post.objects.filter(status=Post.StatusChoices.PUBLISHED).select_related(
|
||||
'author', 'category'
|
||||
).prefetch_related('tags')
|
||||
|
||||
# Apply filters
|
||||
if category:
|
||||
queryset = queryset.filter(category__slug=category)
|
||||
|
||||
if tag:
|
||||
queryset = queryset.filter(tags__slug=tag)
|
||||
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search) |
|
||||
Q(content__icontains=search) |
|
||||
Q(excerpt__icontains=search)
|
||||
)
|
||||
|
||||
if featured is not None:
|
||||
queryset = queryset.filter(is_featured=featured)
|
||||
|
||||
if author:
|
||||
queryset = queryset.filter(author__username=author)
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * limit
|
||||
posts = queryset[offset:offset + limit]
|
||||
|
||||
return posts
|
||||
|
||||
@blog_router.get("/posts/{slug}", response=PostDetailSchema)
|
||||
def get_post(request, slug: str):
|
||||
"""Get single post by slug"""
|
||||
post = get_object_or_404(
|
||||
Post.objects.select_related('author', 'category').prefetch_related('tags'),
|
||||
slug=slug,
|
||||
status=Post.StatusChoices.PUBLISHED
|
||||
)
|
||||
return post
|
||||
|
||||
@blog_router.post("/posts", response={201: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def create_post(request, data: PostCreateSchema):
|
||||
"""Create a new post (committee members only)"""
|
||||
user = request.auth
|
||||
|
||||
if not (user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "Only committee members can create posts"}
|
||||
|
||||
try:
|
||||
post = Post.objects.create(
|
||||
title=data.title,
|
||||
content=data.content,
|
||||
excerpt=data.excerpt,
|
||||
author=user,
|
||||
category_id=data.category_id,
|
||||
status=data.status,
|
||||
is_featured=data.is_featured
|
||||
)
|
||||
|
||||
if data.tag_ids:
|
||||
post.tags.set(data.tag_ids)
|
||||
|
||||
return 201, post
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to create post", "details": str(e)}
|
||||
|
||||
@blog_router.put("/posts/{slug}", response={200: PostDetailSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def update_post(request, slug: str, data: PostCreateSchema):
|
||||
"""Update a post (author or committee only)"""
|
||||
user = request.auth
|
||||
post = get_object_or_404(Post, slug=slug)
|
||||
|
||||
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "You can only edit your own posts"}
|
||||
|
||||
try:
|
||||
for field, value in data.dict(exclude_unset=True).items():
|
||||
if field == 'tag_ids':
|
||||
if value:
|
||||
post.tags.set(value)
|
||||
elif field == 'category_id':
|
||||
post.category_id = value
|
||||
else:
|
||||
setattr(post, field, value)
|
||||
|
||||
post.save()
|
||||
return 200, post
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to update post", "details": str(e)}
|
||||
|
||||
@blog_router.delete("/posts/{slug}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def delete_post(request, slug: str):
|
||||
"""Soft delete a post owned by the requester or committee."""
|
||||
user = request.auth
|
||||
post = get_object_or_404(Post, slug=slug)
|
||||
|
||||
if not (post.author == user or user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "You can only delete your own posts"}
|
||||
|
||||
post.delete()
|
||||
return 200, {"message": "Post deleted successfully"}
|
||||
|
||||
@blog_router.get("/deleted/posts", response=List[PostListSchema], auth=jwt_auth)
|
||||
def list_deleted_posts(request):
|
||||
"""List all soft-deleted posts (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Post.deleted_objects.all().select_related('author', 'category').prefetch_related('tags')
|
||||
|
||||
@blog_router.post("deleted/posts/{post_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_post(request, post_id: int):
|
||||
"""Restore a soft-deleted post (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
post = Post.deleted_objects.get(id=post_id)
|
||||
post.restore()
|
||||
return 200, {"message": f"Post '{post.title}' restored successfully."}
|
||||
except Post.DoesNotExist:
|
||||
return 400, {"error": "Post not found or not soft-deleted."}
|
||||
|
||||
|
||||
|
||||
# Comment endpoints
|
||||
@blog_router.get("/posts/{slug}/comments", response=List[CommentSchema])
|
||||
def list_comments(request, slug: str):
|
||||
"""List approved comments for a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
|
||||
comments = Comment.objects.filter(
|
||||
post=post,
|
||||
is_approved=True,
|
||||
parent=None
|
||||
).select_related('author').prefetch_related(
|
||||
Prefetch(
|
||||
'replies',
|
||||
queryset=Comment.objects.filter(is_approved=True).select_related('author')
|
||||
)
|
||||
)
|
||||
|
||||
return comments
|
||||
|
||||
@blog_router.post("/posts/{slug}/comments", response={201: CommentSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def create_comment(request, slug: str, data: CommentCreateSchema):
|
||||
"""Create a comment on a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
user = request.auth
|
||||
|
||||
try:
|
||||
comment = Comment.objects.create(
|
||||
post=post,
|
||||
author=user,
|
||||
content=data.content,
|
||||
parent_id=data.parent_id
|
||||
)
|
||||
|
||||
return 201, comment
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to create comment", "details": str(e)}
|
||||
|
||||
@blog_router.get("/deleted/comments", response=List[CommentSchema], auth=jwt_auth)
|
||||
def list_deleted_comments(request):
|
||||
"""List all soft-deleted comments (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Comment.deleted_objects.all().select_related('author', 'post')
|
||||
|
||||
@blog_router.post("/deleted/comments/{comment_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_comment(request, comment_id: int):
|
||||
"""Restore a soft-deleted comment (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
comment = Comment.deleted_objects.get(id=comment_id)
|
||||
comment.restore()
|
||||
return 200, {"message": f"Comment by {comment.author.username} restored successfully."}
|
||||
except Comment.DoesNotExist:
|
||||
return 400, {"error": "Comment not found or not soft-deleted."}
|
||||
|
||||
|
||||
|
||||
# Like endpoints
|
||||
@blog_router.post("/posts/{slug}/like", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def toggle_like(request, slug: str):
|
||||
"""Toggle like on a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
user = request.auth
|
||||
|
||||
like, created = Like.objects.get_or_create(post=post, user=user)
|
||||
|
||||
if not created:
|
||||
like.delete()
|
||||
return 200, {"message": "Post unliked"}
|
||||
|
||||
return 200, {"message": "Post liked"}
|
||||
|
||||
@blog_router.get("/posts/{slug}/likes", response={200: MessageSchema})
|
||||
def get_likes_count(request, slug: str):
|
||||
"""Get likes count for a post"""
|
||||
post = get_object_or_404(Post, slug=slug, status=Post.StatusChoices.PUBLISHED)
|
||||
count = post.likes.count()
|
||||
return {"message": f"{count}"}
|
||||
|
||||
|
||||
|
||||
# Category endpoints
|
||||
@blog_router.get("/categories", response=List[CategorySchema])
|
||||
def list_categories(request):
|
||||
"""List all categories"""
|
||||
return Category.objects.all()
|
||||
|
||||
@blog_router.get("/categories/{slug}", response=CategorySchema)
|
||||
def get_category(request, slug: str):
|
||||
"""Get single category by slug"""
|
||||
return get_object_or_404(Category, slug=slug)
|
||||
|
||||
@blog_router.get("/deleted/categories", response=List[CategorySchema], auth=jwt_auth)
|
||||
def list_deleted_categories(request):
|
||||
"""List all soft-deleted categories (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Category.deleted_objects.all()
|
||||
|
||||
@blog_router.post("/deleted/categories/{category_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_category(request, category_id: int):
|
||||
"""Restore a soft-deleted category (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
category = Category.deleted_objects.get(id=category_id)
|
||||
category.restore()
|
||||
return 200, {"message": f"Category '{category.name}' restored successfully."}
|
||||
except Category.DoesNotExist:
|
||||
return 400, {"error": "Category not found or not soft-deleted."}
|
||||
|
||||
|
||||
|
||||
# Tag endpoints
|
||||
@blog_router.get("/tags", response=List[TagSchema])
|
||||
def list_tags(request):
|
||||
"""List all tags"""
|
||||
return Tag.objects.all()
|
||||
|
||||
@blog_router.get("/tags/{slug}", response=TagSchema)
|
||||
def get_tag(request, slug: str):
|
||||
"""Get single tag by slug"""
|
||||
return get_object_or_404(Tag, slug=slug)
|
||||
|
||||
@blog_router.get("/deleted/tags", response=List[TagSchema], auth=jwt_auth)
|
||||
def list_deleted_tags(request):
|
||||
"""List all soft-deleted tags (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Tag.all_objects.all()
|
||||
|
||||
@blog_router.post("/deleted/tags/{tag_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_tag(request, tag_id: int):
|
||||
"""Restore a soft-deleted tag (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
tag = Tag.deleted_objects.get(id=tag_id)
|
||||
tag.restore()
|
||||
return 200, {"message": f"Tag '{tag.name}' restored successfully."}
|
||||
except Tag.DoesNotExist:
|
||||
return 400, {"error": "Tag not found or not soft-deleted."}
|
||||
138
backend/api/views/certificates.py
Normal file
138
backend/api/views/certificates.py
Normal file
@@ -0,0 +1,138 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.exceptions import ValidationError
|
||||
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas.certificates import (
|
||||
CertificateTemplateOut,
|
||||
CertificateGenerationPayload,
|
||||
CertificateGenerationResponse,
|
||||
CertificateVerificationOut,
|
||||
SkillSchema,
|
||||
UserCertificateOut,
|
||||
)
|
||||
from certificates.models import CertificateTemplate, UserCertificate
|
||||
|
||||
|
||||
certificates_router = Router(tags=["Certificates"])
|
||||
|
||||
|
||||
def _ensure_staff(user):
|
||||
if not user or not user.is_staff:
|
||||
raise HttpError(403, "Only staff users can access certificate management.")
|
||||
|
||||
|
||||
@certificates_router.get(
|
||||
"templates/{int:event_id}",
|
||||
response=CertificateTemplateOut,
|
||||
auth=jwt_auth,
|
||||
)
|
||||
def get_template(request, event_id: int):
|
||||
_ensure_staff(request.auth)
|
||||
template = get_object_or_404(
|
||||
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
|
||||
event_id=event_id,
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
skills = [
|
||||
SkillSchema(
|
||||
id=skill.id,
|
||||
name=skill.name,
|
||||
description=skill.description,
|
||||
)
|
||||
for skill in template.skills.all()
|
||||
]
|
||||
|
||||
image_url = None
|
||||
if template.image and hasattr(template.image, 'url'):
|
||||
image_url = request.build_absolute_uri(template.image.url)
|
||||
|
||||
return CertificateTemplateOut(
|
||||
id=template.id,
|
||||
event_id=template.event_id,
|
||||
event_title=template.event.title,
|
||||
image_url=image_url,
|
||||
skill_ids=list(template.skills.values_list('id', flat=True)),
|
||||
skills=skills,
|
||||
)
|
||||
|
||||
|
||||
@certificates_router.post(
|
||||
"templates/{int:event_id}/generate",
|
||||
response=CertificateGenerationResponse,
|
||||
auth=jwt_auth,
|
||||
)
|
||||
def generate_certificates(request, event_id: int, payload: CertificateGenerationPayload):
|
||||
_ensure_staff(request.auth)
|
||||
template = get_object_or_404(
|
||||
CertificateTemplate.objects.select_related('event').prefetch_related('skills'),
|
||||
event_id=event_id,
|
||||
is_deleted=False,
|
||||
)
|
||||
|
||||
try:
|
||||
entries = [entry.model_dump() for entry in payload.entries]
|
||||
certificates = template.generate_certificates(
|
||||
entries,
|
||||
default_title=payload.default_title,
|
||||
default_description=payload.default_description,
|
||||
)
|
||||
except ValidationError as exc:
|
||||
raise HttpError(400, str(exc))
|
||||
|
||||
result = []
|
||||
for certificate in certificates:
|
||||
image_url = None
|
||||
if certificate.image and hasattr(certificate.image, 'url'):
|
||||
image_url = request.build_absolute_uri(certificate.image.url)
|
||||
|
||||
result.append(
|
||||
UserCertificateOut(
|
||||
id=certificate.id,
|
||||
user_id=certificate.user_id,
|
||||
user_name=certificate.user.get_full_name() or certificate.user.email,
|
||||
event_id=certificate.event_id,
|
||||
title=certificate.title,
|
||||
certificate_id=str(certificate.certificate_id),
|
||||
certificate_code=certificate.code,
|
||||
score=certificate.score,
|
||||
score_label=certificate.score_label,
|
||||
image_url=image_url,
|
||||
)
|
||||
)
|
||||
|
||||
return CertificateGenerationResponse(certificates=result)
|
||||
|
||||
|
||||
@certificates_router.get(
|
||||
"verify/{str:certificate_code}",
|
||||
response=CertificateVerificationOut,
|
||||
)
|
||||
def verify_certificate(request, certificate_code):
|
||||
certificate = get_object_or_404(
|
||||
UserCertificate.objects.select_related('event', 'user').prefetch_related('skills'),
|
||||
code=certificate_code,
|
||||
is_deleted=False,
|
||||
)
|
||||
image_url = None
|
||||
if certificate.image and hasattr(certificate.image, 'url'):
|
||||
image_url = request.build_absolute_uri(certificate.image.url)
|
||||
|
||||
return CertificateVerificationOut(
|
||||
certificate_id=str(certificate.certificate_id),
|
||||
certificate_code=certificate.code,
|
||||
user_id=certificate.user_id,
|
||||
user_name=certificate.user.get_full_name() or certificate.user.email,
|
||||
event_id=certificate.event_id,
|
||||
event_title=certificate.event.title,
|
||||
title=certificate.title,
|
||||
score=certificate.score,
|
||||
score_label=certificate.score_label,
|
||||
issued_at=certificate.issued_at,
|
||||
expires_at=certificate.expires_at,
|
||||
image_url=image_url,
|
||||
skills=[skill.name for skill in certificate.skills.all()],
|
||||
)
|
||||
329
backend/api/views/communications.py
Normal file
329
backend/api/views/communications.py
Normal file
@@ -0,0 +1,329 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.contrib.auth import get_user_model
|
||||
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 communications.models import (
|
||||
Announcement, NewsletterSubscription, PushNotificationDevice,
|
||||
AnnouncementType, AnnouncementPriority
|
||||
)
|
||||
from communications.utils import (
|
||||
send_announcement_email, send_newsletter_confirmation,
|
||||
get_announcement_recipients
|
||||
)
|
||||
from communications.push_notifications import push_service
|
||||
from api.schemas import (
|
||||
AnnouncementSchema, AnnouncementListSchema, AnnouncementCreateSchema, AnnouncementUpdateSchema,
|
||||
NewsletterSubscriptionSchema, NewsletterSubscribeSchema, NewsletterUnsubscribeSchema,
|
||||
PushDeviceSchema, PushDeviceCreateSchema, PushDeviceUpdateSchema,
|
||||
PushNotificationSchema, MessageResponseSchema,
|
||||
AnnouncementStatsSchema, NewsletterStatsSchema
|
||||
)
|
||||
from api.authentication import jwt_auth
|
||||
|
||||
User = get_user_model()
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
communications_router = Router()
|
||||
|
||||
# Announcement endpoints
|
||||
@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)
|
||||
|
||||
if published_only:
|
||||
queryset = queryset.filter(is_published=True, publish_date__lte=timezone.now())
|
||||
|
||||
return queryset.order_by('-created_at')
|
||||
|
||||
@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
|
||||
)
|
||||
|
||||
# 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
|
||||
|
||||
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):
|
||||
return {"error": "Permission denied"}, 403
|
||||
|
||||
announcement = Announcement.objects.create(
|
||||
author=user,
|
||||
**payload.dict()
|
||||
)
|
||||
|
||||
# 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()
|
||||
|
||||
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)"""
|
||||
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):
|
||||
return {"error": "Permission denied"}, 403
|
||||
|
||||
# Update fields
|
||||
for field, value in payload.dict(exclude_unset=True).items():
|
||||
setattr(announcement, field, value)
|
||||
|
||||
announcement.save()
|
||||
|
||||
# Send notifications if newly published
|
||||
if (announcement.is_published and announcement.publish_date <= timezone.now() and
|
||||
not announcement.email_sent and announcement.send_email):
|
||||
recipients = get_announcement_recipients(announcement)
|
||||
if recipients:
|
||||
send_announcement_email(announcement, recipients)
|
||||
announcement.email_sent = True
|
||||
announcement.save()
|
||||
|
||||
if (announcement.is_published and announcement.publish_date <= timezone.now() and
|
||||
not announcement.push_sent and announcement.send_push):
|
||||
push_service.send_announcement_notification(announcement)
|
||||
announcement.push_sent = True
|
||||
announcement.save()
|
||||
|
||||
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):
|
||||
return {"error": "Permission denied"}, 403
|
||||
|
||||
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):
|
||||
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))
|
||||
)
|
||||
|
||||
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
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@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
|
||||
|
||||
@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}
|
||||
)
|
||||
|
||||
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()
|
||||
return {"message": "Device unregistered successfully"}
|
||||
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')
|
||||
|
||||
@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.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):
|
||||
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
|
||||
)
|
||||
|
||||
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]
|
||||
371
backend/api/views/events.py
Normal file
371
backend/api/views/events.py
Normal file
@@ -0,0 +1,371 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.db.models import Q, Case, When, IntegerField
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from ninja import Router, Query
|
||||
from ninja.errors import HttpError
|
||||
from typing import List, Optional
|
||||
from uuid import UUID
|
||||
|
||||
from api.authentication import jwt_auth
|
||||
from events.models import Event, Registration
|
||||
from payments.models import DiscountCode
|
||||
from api.schemas import (
|
||||
EventSchema,
|
||||
EventCreateSchema,
|
||||
EventUpdateSchema,
|
||||
EventListSchema,
|
||||
RegistrationSchema,
|
||||
RegistrationStatusUpdateSchema,
|
||||
RegisterationDetailSchema,
|
||||
MyEventRegistrationOut,
|
||||
RegistrationStatusOut,
|
||||
EventBriefSchema,
|
||||
EventAdminDetailSchema,
|
||||
PaginatedRegistrationSchema,
|
||||
MessageSchema,
|
||||
ErrorSchema,
|
||||
RegistrationCreateSchema,
|
||||
)
|
||||
|
||||
events_router = Router()
|
||||
|
||||
# Event endpoints
|
||||
@events_router.get("/", response=List[EventListSchema])
|
||||
def list_events(
|
||||
request,
|
||||
# status: Optional[str] = None,
|
||||
status: Optional[List[str]] = Query(None),
|
||||
event_type: Optional[str] = None,
|
||||
search: Optional[str] = None,
|
||||
limit: int = 20,
|
||||
offset: int = 0
|
||||
):
|
||||
"""List events with filtering and pagination"""
|
||||
queryset = Event.objects.filter(is_deleted=False).prefetch_related('gallery_images')
|
||||
|
||||
if status:
|
||||
if "," in status:
|
||||
parts = [s.strip() for s in status.split(",") if s.strip()]
|
||||
queryset = queryset.filter(status__in=parts)
|
||||
else:
|
||||
queryset = queryset.filter(status__in=status)
|
||||
if event_type:
|
||||
queryset = queryset.filter(event_type=event_type)
|
||||
if search:
|
||||
queryset = queryset.filter(
|
||||
Q(title__icontains=search) | Q(description__icontains=search)
|
||||
)
|
||||
|
||||
queryset = queryset.annotate(
|
||||
published_first=Case(
|
||||
When(status='published', then=0),
|
||||
default=1,
|
||||
output_field=IntegerField()
|
||||
)
|
||||
).order_by('published_first', '-start_time', '-id')
|
||||
|
||||
events = queryset[offset:offset + limit]
|
||||
return events
|
||||
|
||||
@events_router.get("/{int:event_id}", response=EventSchema)
|
||||
def get_event(request, event_id: int):
|
||||
"""Get event details by ID"""
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related('gallery_images'),
|
||||
id=event_id,
|
||||
is_deleted=False
|
||||
)
|
||||
return event
|
||||
|
||||
@events_router.get("/slug/{str:slug}", response=EventSchema)
|
||||
def get_event_by_slug(request, slug: str):
|
||||
"""Get event details by slug"""
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related('gallery_images'),
|
||||
slug=slug,
|
||||
is_deleted=False
|
||||
)
|
||||
return event
|
||||
|
||||
@events_router.post("/", response=EventSchema)
|
||||
def create_event(request, payload: EventCreateSchema):
|
||||
"""Create a new event"""
|
||||
gallery_image_ids = payload.dict().pop('gallery_image_ids', [])
|
||||
event = Event.objects.create(**payload.dict(exclude={'gallery_image_ids'}))
|
||||
|
||||
if gallery_image_ids:
|
||||
event.gallery_images.set(gallery_image_ids)
|
||||
|
||||
return event
|
||||
|
||||
@events_router.put("/{int:event_id}", response=EventSchema)
|
||||
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)
|
||||
|
||||
update_data = payload.dict(exclude_unset=True)
|
||||
gallery_image_ids = update_data.pop('gallery_image_ids', None)
|
||||
|
||||
for attr, value in update_data.items():
|
||||
setattr(event, attr, value)
|
||||
|
||||
if 'title' in update_data:
|
||||
event.slug = slugify(event.title)
|
||||
|
||||
event.save()
|
||||
|
||||
if gallery_image_ids is not None:
|
||||
event.gallery_images.set(gallery_image_ids)
|
||||
|
||||
return event
|
||||
|
||||
@events_router.delete("/{int:event_id}", response=MessageSchema)
|
||||
def delete_event(request, event_id: int):
|
||||
"""Soft delete an event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
event.delete()
|
||||
return {"message": "Event deleted successfully"}
|
||||
|
||||
# Registration endpoints
|
||||
@events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema])
|
||||
def list_event_registrations(request, event_id: int, limit: int = 20, offset: int = 0):
|
||||
"""List registrations for a specific event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
queryset = event.registrations.filter(is_deleted=False).select_related('user')
|
||||
|
||||
registrations = queryset[offset:offset + limit]
|
||||
return registrations
|
||||
|
||||
|
||||
@events_router.get("/{int:event_id}/admin-registrations", response={200: PaginatedRegistrationSchema, 403: ErrorSchema}, auth=jwt_auth)
|
||||
def list_event_registrations_admin(
|
||||
request,
|
||||
event_id: int,
|
||||
status: Optional[List[str]] = Query(None),
|
||||
university: Optional[str] = Query(None),
|
||||
major: Optional[str] = Query(None),
|
||||
search: Optional[str] = Query(None),
|
||||
limit: int = Query(20, ge=1, le=200),
|
||||
offset: int = Query(0, ge=0),
|
||||
):
|
||||
"""List registrations with filters for admin dashboard"""
|
||||
user = request.auth
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
qs = (
|
||||
event.registrations.filter(is_deleted=False)
|
||||
.select_related("user")
|
||||
.prefetch_related("payments__discount_code")
|
||||
.order_by("-registered_at")
|
||||
)
|
||||
|
||||
status_values = status or request.GET.getlist('status')
|
||||
if status_values:
|
||||
qs = qs.filter(status__in=status_values)
|
||||
|
||||
if university:
|
||||
qs = qs.filter(
|
||||
Q(user__university__code__icontains=university)
|
||||
| Q(user__university__name__icontains=university)
|
||||
)
|
||||
|
||||
if major:
|
||||
qs = qs.filter(
|
||||
Q(user__major__code__icontains=major)
|
||||
| Q(user__major__name__icontains=major)
|
||||
)
|
||||
|
||||
if search:
|
||||
qs = qs.filter(
|
||||
Q(user__username__icontains=search)
|
||||
| Q(user__email__icontains=search)
|
||||
| Q(user__first_name__icontains=search)
|
||||
| Q(user__last_name__icontains=search)
|
||||
)
|
||||
|
||||
total = qs.count()
|
||||
results = qs[offset : offset + limit]
|
||||
|
||||
return PaginatedRegistrationSchema(count=total, next=None, previous=None, results=list(results))
|
||||
|
||||
@events_router.post(
|
||||
"/{int:event_id}/register",
|
||||
response=RegistrationSchema,
|
||||
auth=jwt_auth,
|
||||
)
|
||||
def register_for_event(
|
||||
request,
|
||||
event_id: int,
|
||||
payload: RegistrationCreateSchema | None = None,
|
||||
):
|
||||
"""Register current user for an event"""
|
||||
event = get_object_or_404(Event, id=event_id, is_deleted=False)
|
||||
user = request.auth
|
||||
|
||||
if Registration.objects.filter(event=event, user=user, status=Registration.StatusChoices.CONFIRMED).exists():
|
||||
raise HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید.")
|
||||
|
||||
if event.registration_end_date and event.registration_end_date < timezone.now():
|
||||
raise HttpError(400, "مهلت ثبتنام به پایان رسیدهاست")
|
||||
|
||||
if event.registration_start_date and event.registration_start_date > timezone.now():
|
||||
raise HttpError(400, "زمان ثبتنام هنوز آغاز نشده است")
|
||||
|
||||
if not event.has_available_slots:
|
||||
raise HttpError(400, "ظرفیت شرکتکنندگان تکمیل است")
|
||||
|
||||
# Create or get existing registration
|
||||
discount_code = None
|
||||
if payload and payload.discount_code:
|
||||
discount_code = payload.discount_code
|
||||
elif request.GET.get("discount_code"):
|
||||
discount_code = request.GET.get("discount_code")
|
||||
|
||||
registration, created = Registration.objects.get_or_create(
|
||||
event=event,
|
||||
user=user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
defaults={"final_price": event.price},
|
||||
)
|
||||
|
||||
if registration.status == Registration.StatusChoices.CONFIRMED:
|
||||
return HttpError(400, "شما قبلا در این ایونت ثبتنام کردهاید")
|
||||
|
||||
if registration.status == Registration.StatusChoices.CANCELLED:
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=user,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=event.price,
|
||||
)
|
||||
elif not created and registration.final_price is None:
|
||||
registration.final_price = event.price
|
||||
registration.save(update_fields=["final_price"])
|
||||
|
||||
applied_code = None
|
||||
discount_amount = 0
|
||||
final_price = event.price
|
||||
fields_to_update = []
|
||||
|
||||
if discount_code:
|
||||
applied_code = DiscountCode.objects.filter(
|
||||
code=discount_code,
|
||||
applicable_events=event,
|
||||
is_active=True,
|
||||
).first()
|
||||
if not applied_code:
|
||||
raise HttpError(400, "UcO_ O<>OrU?UOU? U.O1O<31>O\"O<EFBFBD> U+UOO3O<33>")
|
||||
final_price, discount_amount = applied_code.calculate_discount(event, user)
|
||||
registration.discount_code = applied_code
|
||||
registration.discount_amount = discount_amount
|
||||
fields_to_update.extend(["discount_code", "discount_amount"])
|
||||
|
||||
if registration.final_price != final_price:
|
||||
registration.final_price = final_price
|
||||
fields_to_update.append("final_price")
|
||||
|
||||
if not event.price or final_price == 0:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
fields_to_update.append("status")
|
||||
|
||||
if fields_to_update:
|
||||
registration.save(update_fields=list(set(fields_to_update)))
|
||||
|
||||
return registration
|
||||
|
||||
@events_router.put("/registrations/{int:registration_id}", response=RegistrationSchema, auth=jwt_auth)
|
||||
def update_registration_status(request, registration_id: int, payload: RegistrationStatusUpdateSchema):
|
||||
"""Update registration status"""
|
||||
user = request.auth
|
||||
|
||||
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||
registration.status = payload.dict(exclude_unset=True).get('status')
|
||||
registration.full_clean()
|
||||
registration.save()
|
||||
|
||||
return registration
|
||||
|
||||
@events_router.delete("/registrations/{int:registration_id}", response=MessageSchema, auth=jwt_auth)
|
||||
def cancel_registration(request, registration_id: int):
|
||||
"""Cancel a registration"""
|
||||
user = request.auth
|
||||
|
||||
registration = get_object_or_404(Registration, id=registration_id, user=user, is_deleted=False)
|
||||
registration.delete()
|
||||
return {"message": "ثبتنام شما لغو شد :("}
|
||||
|
||||
@events_router.get("/registerations/verify/{UUID:ticket_id}", response=RegisterationDetailSchema, auth=jwt_auth)
|
||||
def verify_my_registration(request, ticket_id: UUID):
|
||||
try:
|
||||
reg = Registration.objects.select_related("event").get(ticket_id=ticket_id, user=request.auth)
|
||||
return {
|
||||
"event_image": request.build_absolute_uri(reg.event.featured_image.url) if reg.event.featured_image else None,
|
||||
"event_title": reg.event.title,
|
||||
"event_type": reg.event.get_event_type_display(),
|
||||
"ticket_id": reg.ticket_id,
|
||||
"status": reg.status,
|
||||
"registered_at": reg.registered_at,
|
||||
"success_markdown": reg.event.registration_success_markdown,
|
||||
}
|
||||
except Registration.DoesNotExist:
|
||||
raise HttpError(404, "registration not found")
|
||||
|
||||
|
||||
|
||||
@events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth)
|
||||
def my_registrations(request):
|
||||
qs = (
|
||||
Registration.objects
|
||||
.filter(user=request.auth)
|
||||
.select_related("event")
|
||||
.order_by("-created_at")
|
||||
)
|
||||
out: List[MyEventRegistrationOut] = []
|
||||
for r in qs:
|
||||
out.append(
|
||||
MyEventRegistrationOut(
|
||||
id=r.id,
|
||||
created_at=r.created_at,
|
||||
status=r.status,
|
||||
event=EventBriefSchema(
|
||||
id=r.event.id,
|
||||
title=r.event.title,
|
||||
slug=r.event.slug,
|
||||
start_date=r.event.start_time,
|
||||
end_date=r.event.end_time,
|
||||
location=r.event.location,
|
||||
price=r.event.price,
|
||||
absolute_image_url=request.build_absolute_uri(r.event.featured_image.url) if r.event.featured_image else None,
|
||||
),
|
||||
)
|
||||
)
|
||||
return out
|
||||
|
||||
@events_router.get("/{event_id}/is-registered", response=RegistrationStatusOut, auth=jwt_auth)
|
||||
def is_registered(request, event_id: int):
|
||||
exists = Registration.objects.filter(
|
||||
user=request.auth,
|
||||
event_id=event_id,
|
||||
status=Registration.StatusChoices.CONFIRMED
|
||||
).exists()
|
||||
return {"is_registered": exists}
|
||||
@events_router.get("/{int:event_id}/admin-detail", response=EventAdminDetailSchema, auth=jwt_auth)
|
||||
def event_admin_detail(request, event_id: int):
|
||||
user = request.auth
|
||||
if not (user.is_staff or user.is_superuser):
|
||||
return 403, {"error": "اجازه دسترسی ندارید."}
|
||||
|
||||
event = get_object_or_404(
|
||||
Event.objects.prefetch_related(
|
||||
'gallery_images',
|
||||
'registrations__user',
|
||||
'registrations__payments__discount_code'
|
||||
),
|
||||
id=event_id,
|
||||
is_deleted=False,
|
||||
)
|
||||
return event
|
||||
127
backend/api/views/gallery.py
Normal file
127
backend/api/views/gallery.py
Normal file
@@ -0,0 +1,127 @@
|
||||
from django.shortcuts import get_object_or_404
|
||||
from django.core.files.base import ContentFile
|
||||
|
||||
from ninja import Router, Query, File, UploadedFile
|
||||
from typing import List
|
||||
import uuid
|
||||
|
||||
from gallery.models import Gallery
|
||||
from gallery.tasks import process_uploaded_image
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas import GallerySchema, GalleryCreateSchema, MessageSchema, ErrorSchema
|
||||
|
||||
gallery_router = Router()
|
||||
|
||||
@gallery_router.get("/images", response=List[GallerySchema])
|
||||
def list_gallery_images(
|
||||
request,
|
||||
page: int = Query(1, ge=1),
|
||||
limit: int = Query(20, ge=1, le=50),
|
||||
public_only: bool = Query(True)
|
||||
):
|
||||
"""List gallery images"""
|
||||
queryset = Gallery.objects.select_related('uploaded_by')
|
||||
|
||||
if public_only:
|
||||
queryset = queryset.filter(is_public=True)
|
||||
|
||||
# Pagination
|
||||
offset = (page - 1) * limit
|
||||
images = queryset[offset:offset + limit]
|
||||
|
||||
return images
|
||||
|
||||
@gallery_router.get("/images/{image_id}", response=GallerySchema)
|
||||
def get_gallery_image(request, image_id: int):
|
||||
"""Get single gallery image"""
|
||||
image = get_object_or_404(Gallery, id=image_id, is_public=True)
|
||||
return image
|
||||
|
||||
@gallery_router.post("/images", response={201: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def upload_image(request, file: UploadedFile = File(...), data: GalleryCreateSchema = None):
|
||||
"""Upload image to gallery (committee members only)"""
|
||||
user = request.auth
|
||||
|
||||
if not (user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "Only committee members can upload images"}
|
||||
|
||||
# Validate file type
|
||||
if not file.content_type.startswith('image/'):
|
||||
return 400, {"error": "File must be an image"}
|
||||
|
||||
# Validate file size (10MB max)
|
||||
if file.size > 10 * 1024 * 1024:
|
||||
return 400, {"error": "File size must be less than 10MB"}
|
||||
|
||||
try:
|
||||
# Create gallery item
|
||||
gallery_item = Gallery.objects.create(
|
||||
title=data.title if data else file.name,
|
||||
description=data.description if data else "",
|
||||
uploaded_by=user,
|
||||
alt_text=data.alt_text if data else "",
|
||||
is_public=data.is_public if data else True
|
||||
)
|
||||
|
||||
# Save image
|
||||
filename = f"gallery/{uuid.uuid4().hex}.{file.name.split('.')[-1]}"
|
||||
gallery_item.image.save(filename, ContentFile(file.read()))
|
||||
|
||||
# Process image asynchronously
|
||||
process_uploaded_image.delay(gallery_item.id)
|
||||
|
||||
return 201, gallery_item
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to upload image", "details": str(e)}
|
||||
|
||||
@gallery_router.put("/images/{image_id}", response={200: GallerySchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def update_image(request, image_id: int, data: GalleryCreateSchema):
|
||||
"""Update gallery image metadata"""
|
||||
user = request.auth
|
||||
image = get_object_or_404(Gallery, id=image_id)
|
||||
|
||||
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "You can only edit your own images"}
|
||||
|
||||
try:
|
||||
for field, value in data.dict(exclude_unset=True).items():
|
||||
setattr(image, field, value)
|
||||
|
||||
image.save()
|
||||
return 200, image
|
||||
|
||||
except Exception as e:
|
||||
return 400, {"error": "Failed to update image", "details": str(e)}
|
||||
|
||||
@gallery_router.delete("/images/{image_id}", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def delete_image(request, image_id: int):
|
||||
"""Soft delete a gallery image owned by the requester or committee."""
|
||||
user = request.auth
|
||||
image = get_object_or_404(Gallery, id=image_id)
|
||||
|
||||
if not (image.uploaded_by == user or user.is_superuser or user.is_staff):
|
||||
return 400, {"error": "You can only delete your own images"}
|
||||
|
||||
image.delete()
|
||||
return 200, {"message": "Image deleted successfully"}
|
||||
|
||||
|
||||
@gallery_router.get("/deleted/images", response=List[GallerySchema], auth=jwt_auth)
|
||||
def list_deleted_gallery_images(request):
|
||||
"""List all soft-deleted gallery images (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
return Gallery.deleted_objects.all().select_related('uploaded_by')
|
||||
|
||||
@gallery_router.post("/deleted/images/{image_id}/restore", response={200: MessageSchema, 400: ErrorSchema}, auth=jwt_auth)
|
||||
def restore_gallery_image(request, image_id: int):
|
||||
"""Restore a soft-deleted gallery image (Admin/Committee only)"""
|
||||
if not (request.auth.is_staff or request.auth.is_superuser):
|
||||
return 403, {"error": "Permission denied"}
|
||||
try:
|
||||
image = Gallery.deleted_objects.get(id=image_id)
|
||||
image.restore()
|
||||
return 200, {"message": f"Gallery image '{image.title}' restored successfully."}
|
||||
except Gallery.DoesNotExist:
|
||||
return 400, {"error": "Gallery image not found or not soft-deleted."}
|
||||
15
backend/api/views/health.py
Normal file
15
backend/api/views/health.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from ninja import Router
|
||||
|
||||
from django.db import connection
|
||||
from django.utils import timezone
|
||||
|
||||
health_router = Router()
|
||||
|
||||
@health_router.get("/health")
|
||||
def health(request):
|
||||
try:
|
||||
with connection.cursor() as c:
|
||||
c.execute("SELECT 1;")
|
||||
return {"status": "ok", "time": timezone.now().isoformat()}
|
||||
except Exception as e:
|
||||
return {"status": "error", "error": str(e)}, 500
|
||||
15
backend/api/views/meta.py
Normal file
15
backend/api/views/meta.py
Normal file
@@ -0,0 +1,15 @@
|
||||
from ninja import Router
|
||||
|
||||
from users.models import Major, University
|
||||
|
||||
meta_router = Router(tags=['meta'])
|
||||
|
||||
@meta_router.get("/majors")
|
||||
def list_majors(request):
|
||||
majors = Major.objects.filter(is_deleted=False, is_active=True).order_by("name")
|
||||
return [{"id": m.id, "code": m.code, "label": m.name} for m in majors]
|
||||
|
||||
@meta_router.get("/universities")
|
||||
def list_universities(request):
|
||||
universities = University.objects.filter(is_deleted=False, is_active=True).order_by("name")
|
||||
return [{"id": u.id, "code": u.code, "label": u.name} for u in universities]
|
||||
240
backend/api/views/payments.py
Normal file
240
backend/api/views/payments.py
Normal file
@@ -0,0 +1,240 @@
|
||||
from django.conf import settings
|
||||
from django.shortcuts import redirect, get_object_or_404
|
||||
from django.utils import timezone
|
||||
|
||||
from ninja import Router
|
||||
from ninja.errors import HttpError
|
||||
import requests
|
||||
|
||||
from payments.models import Payment, DiscountCode
|
||||
from events.models import Event, Registration
|
||||
from api.authentication import jwt_auth
|
||||
from api.schemas.payments import CouponVerifyIn, CouponVerifyOut, CreatePaymentIn, CreatePaymentOut, PaymentDetailOut
|
||||
|
||||
payments_router = Router(tags=["Payments"])
|
||||
|
||||
|
||||
@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)
|
||||
|
||||
if Payment.objects.filter(status=Payment.OrderStatusChoices.PAID, user=request.auth, event=event).exists():
|
||||
raise HttpError(400, "You have already registered in this event")
|
||||
|
||||
registration = (
|
||||
Registration.objects.filter(event=event, user=request.auth, is_deleted=False)
|
||||
.order_by("-registered_at")
|
||||
.first()
|
||||
)
|
||||
if not registration or registration.status == Registration.StatusChoices.CANCELLED:
|
||||
registration = Registration.objects.create(
|
||||
event=event,
|
||||
user=request.auth,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
final_price=event.price,
|
||||
)
|
||||
elif registration.final_price is None:
|
||||
registration.final_price = event.price
|
||||
registration.save(update_fields=["final_price"])
|
||||
|
||||
discount_code = None
|
||||
discount_amount = 0
|
||||
final_amount = event.price
|
||||
|
||||
if payload.discount_code:
|
||||
discount_code = DiscountCode.objects.filter(code=payload.discount_code, applicable_events=event, is_active=True).first()
|
||||
|
||||
if discount_code:
|
||||
final_amount, discount_amount = discount_code.calculate_discount(event, request.auth)
|
||||
|
||||
registration_updates = []
|
||||
if discount_code and registration.discount_code_id != discount_code.id:
|
||||
registration.discount_code = discount_code
|
||||
registration_updates.append("discount_code")
|
||||
if registration.discount_amount != discount_amount:
|
||||
registration.discount_amount = discount_amount
|
||||
registration_updates.append("discount_amount")
|
||||
if registration.final_price != final_amount:
|
||||
registration.final_price = final_amount
|
||||
registration_updates.append("final_price")
|
||||
|
||||
if final_amount == 0:
|
||||
if registration.status != Registration.StatusChoices.CONFIRMED:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
registration_updates.append("status")
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
else:
|
||||
registration.save(update_fields=["status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": None,
|
||||
"authority": None,
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount if discount_amount else 0,
|
||||
"amount": 0,
|
||||
}
|
||||
|
||||
if registration_updates:
|
||||
registration.save(update_fields=list(set(registration_updates)))
|
||||
|
||||
pay = Payment.objects.create(
|
||||
user=request.auth,
|
||||
event=event,
|
||||
base_amount=event.price,
|
||||
discount_code=discount_code,
|
||||
discount_amount=discount_amount,
|
||||
amount=final_amount,
|
||||
status=Payment.OrderStatusChoices.INIT,
|
||||
registration=registration,
|
||||
)
|
||||
|
||||
callback_url = getattr(settings, "ZARINPAL_CALLBACK_URL", "http://localhost:8000/api/payments/callback")
|
||||
body = {
|
||||
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||
"amount": final_amount,
|
||||
"callback_url": callback_url,
|
||||
"description": payload.description,
|
||||
"metadata": {
|
||||
k: v for k, v in {
|
||||
"mobile": payload.mobile,
|
||||
"email": payload.email,
|
||||
"event_id": event.id,
|
||||
"user_id": request.auth.id,
|
||||
"payment_id": pay.id,
|
||||
"discount_code": discount_code.code if discount_code else None,
|
||||
}.items() if v
|
||||
}
|
||||
}
|
||||
|
||||
try:
|
||||
response = requests.post(
|
||||
settings.ZARINPAL_REQUEST_URL,
|
||||
json=body,
|
||||
headers={"accept":"application/json","content-type":"application/json"},
|
||||
timeout=15
|
||||
)
|
||||
jd = response.json()
|
||||
except Exception as e:
|
||||
pay.delete()
|
||||
raise HttpError(502, f"Gateway request failed: {e}")
|
||||
|
||||
code = (jd.get("data") or {}).get("code")
|
||||
if code != 100:
|
||||
pay.delete()
|
||||
raise HttpError(502, f"Zarinpal error: {jd.get('errors') or jd}")
|
||||
|
||||
authority = jd["data"]["authority"]
|
||||
pay.authority = authority
|
||||
pay.status = Payment.OrderStatusChoices.PENDING
|
||||
pay.save(update_fields=["authority","status"])
|
||||
|
||||
return {
|
||||
"start_pay_url": f"{settings.ZARINPAL_STARTPAY}{authority}",
|
||||
"authority": authority,
|
||||
"base_amount": event.price,
|
||||
"discount_amount": discount_amount if discount_amount else 0,
|
||||
"amount": final_amount,
|
||||
}
|
||||
|
||||
@payments_router.get("callback")
|
||||
def callback(request, Authority: str | None = None, Status: str | None = None):
|
||||
if not Authority:
|
||||
raise HttpError(400, "Missing Authority")
|
||||
|
||||
pay = Payment.objects.filter(authority=Authority).select_related("event","user","discount_code").first()
|
||||
if not pay:
|
||||
raise HttpError(404, "Payment not found")
|
||||
|
||||
if Status != "OK":
|
||||
pay.status = Payment.OrderStatusChoices.CANCELED
|
||||
pay.save(update_fields=["status"])
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
verify_body = {
|
||||
"merchant_id": settings.ZARINPAL_MERCHANT_ID,
|
||||
"amount": pay.amount,
|
||||
"authority": Authority,
|
||||
}
|
||||
|
||||
try:
|
||||
vresp = requests.post(
|
||||
settings.ZARINPAL_VERIFY_URL,
|
||||
json=verify_body,
|
||||
headers={"accept":"application/json","content-type":"application/json"},
|
||||
timeout=15
|
||||
)
|
||||
vjd = vresp.json()
|
||||
except Exception:
|
||||
pay.status = Payment.OrderStatusChoices.FAILED
|
||||
pay.save(update_fields=["status"])
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
vcode = (vjd.get("data") or {}).get("code")
|
||||
if vcode in (100, 101):
|
||||
data = vjd.get("data") or {}
|
||||
pay.status = Payment.OrderStatusChoices.PAID
|
||||
pay.ref_id = data.get("ref_id")
|
||||
pay.card_pan = data.get("card_pan")
|
||||
pay.card_hash = data.get("card_hash")
|
||||
pay.verified_at = timezone.now()
|
||||
pay.save(update_fields=["status", "ref_id", "card_pan", "card_hash", "verified_at"])
|
||||
|
||||
registration = pay.registration or Registration.objects.filter(
|
||||
user=pay.user,
|
||||
event=pay.event,
|
||||
status=Registration.StatusChoices.PENDING,
|
||||
).first()
|
||||
if registration:
|
||||
registration.status = Registration.StatusChoices.CONFIRMED
|
||||
updates = ["status"]
|
||||
if registration.final_price is None:
|
||||
registration.final_price = pay.amount
|
||||
updates.append("final_price")
|
||||
registration.save(update_fields=updates)
|
||||
|
||||
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"])
|
||||
return redirect(f"{settings.FRONTEND_CALLBACK_URL}?status=failed&event_id={pay.event_id}")
|
||||
|
||||
@payments_router.get("by-ref/{ref_id}", response=PaymentDetailOut)
|
||||
def payment_by_ref(request, ref_id: str):
|
||||
pay = get_object_or_404(Payment.objects.select_related("event"), ref_id=ref_id)
|
||||
ev = pay.event
|
||||
return {
|
||||
"ref_id": pay.ref_id,
|
||||
"authority": pay.authority,
|
||||
"base_amount": pay.base_amount,
|
||||
"discount_amount": pay.discount_amount or 0,
|
||||
"amount": pay.amount,
|
||||
"status": pay.get_status_display(),
|
||||
"verified_at": pay.verified_at.isoformat() if pay.verified_at else None,
|
||||
"event": {
|
||||
"id": ev.id,
|
||||
"title": ev.title,
|
||||
"slug": ev.slug,
|
||||
"image_url": request.build_absolute_uri(ev.featured_image.url) if ev.featured_image else None,
|
||||
"success_markdown": ev.registration_success_markdown,
|
||||
},
|
||||
}
|
||||
|
||||
@payments_router.post("/coupon/check", response=CouponVerifyOut, auth=jwt_auth)
|
||||
def check_coupon(request, payload: CouponVerifyIn):
|
||||
event = get_object_or_404(Event, id=payload.event_id)
|
||||
code = payload.code
|
||||
|
||||
if not code:
|
||||
raise HttpError(404, "لطفا کد تخفیف را وارد کنید")
|
||||
|
||||
try:
|
||||
c = DiscountCode.objects.get(code=code, applicable_events=event, is_active=True)
|
||||
final_price, disc = c.calculate_discount(event, request.auth)
|
||||
return {
|
||||
"discount_amount": disc,
|
||||
"final_price": final_price,
|
||||
}
|
||||
|
||||
except DiscountCode.DoesNotExist:
|
||||
raise HttpError(404, "کد تخفیف معتبر نیست")
|
||||
Reference in New Issue
Block a user