init
Some checks failed
CI/CD / Backend & Frontend Checks (push) Has been cancelled
CI/CD / Deploy to Production (push) Has been cancelled

This commit is contained in:
2026-05-18 11:34:07 +03:30
commit 7a8ddeabed
279 changed files with 37390 additions and 0 deletions

View 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
View 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
View 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."}

View 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()],
)

View 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
View 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

View 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."}

View 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
View 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]

View 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, "کد تخفیف معتبر نیست")