init
This commit is contained in:
41
backend/api/authentication.py
Normal file
41
backend/api/authentication.py
Normal file
@@ -0,0 +1,41 @@
|
||||
from django.conf import settings
|
||||
|
||||
from ninja.security import HttpBearer
|
||||
from datetime import datetime, timedelta, UTC
|
||||
import jwt
|
||||
|
||||
from users.models import User
|
||||
|
||||
class JWTAuth(HttpBearer):
|
||||
def authenticate(self, request, token):
|
||||
try:
|
||||
payload = jwt.decode(token, settings.JWT_SECRET_KEY, algorithms=[settings.JWT_ALGORITHM])
|
||||
user_id = payload.get('user_id')
|
||||
if user_id:
|
||||
user = User.objects.get(id=user_id, is_email_verified=True, is_active=True)
|
||||
return user
|
||||
except (jwt.ExpiredSignatureError, jwt.InvalidTokenError, User.DoesNotExist):
|
||||
pass
|
||||
return None
|
||||
|
||||
def create_jwt_token(user):
|
||||
"""Create JWT token for user"""
|
||||
payload = {
|
||||
'user_id': user.id,
|
||||
'email': user.email,
|
||||
'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_ACCESS_TOKEN_LIFETIME),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
def create_refresh_token(user):
|
||||
"""Create refresh token for user"""
|
||||
payload = {
|
||||
'user_id': user.id,
|
||||
'type': 'refresh',
|
||||
'exp': datetime.now(UTC) + timedelta(seconds=settings.JWT_REFRESH_TOKEN_LIFETIME),
|
||||
'iat': datetime.now(UTC),
|
||||
}
|
||||
return jwt.encode(payload, settings.JWT_SECRET_KEY, algorithm=settings.JWT_ALGORITHM)
|
||||
|
||||
jwt_auth = JWTAuth()
|
||||
31
backend/api/schemas/__init__.py
Normal file
31
backend/api/schemas/__init__.py
Normal file
@@ -0,0 +1,31 @@
|
||||
"""Aggregate exports for API schemas and shared response payloads."""
|
||||
|
||||
from typing import Optional
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
from api.schemas.auth import *
|
||||
from api.schemas.blog import *
|
||||
from api.schemas.gallery import *
|
||||
from api.schemas.events import *
|
||||
from api.schemas.communications import *
|
||||
from api.schemas.certificates import *
|
||||
|
||||
|
||||
class MessageSchema(Schema):
|
||||
"""Basic success response containing a message."""
|
||||
message: str
|
||||
|
||||
|
||||
class ErrorSchema(Schema):
|
||||
"""Standard error payload with optional details."""
|
||||
error: str
|
||||
details: Optional[str] = None
|
||||
|
||||
|
||||
def rebuild_comment_schema() -> None:
|
||||
"""Ensure the self-referential CommentSchema is fully initialized."""
|
||||
CommentSchema.model_rebuild()
|
||||
|
||||
|
||||
rebuild_comment_schema()
|
||||
129
backend/api/schemas/auth.py
Normal file
129
backend/api/schemas/auth.py
Normal file
@@ -0,0 +1,129 @@
|
||||
"""Authentication-related API schemas."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional
|
||||
|
||||
from users.models import User
|
||||
|
||||
|
||||
class UserRegistrationSchema(Schema):
|
||||
username: str
|
||||
email: str
|
||||
password: str
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
year_of_study: Optional[int] = None
|
||||
major: Optional[str] = None
|
||||
|
||||
class UserLoginSchema(Schema):
|
||||
email: str
|
||||
password: str
|
||||
|
||||
class UserProfileSchema(ModelSchema):
|
||||
profile_picture: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'student_id',
|
||||
'year_of_study',
|
||||
'major',
|
||||
'university',
|
||||
'bio',
|
||||
'date_joined',
|
||||
'is_email_verified',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'is_deleted',
|
||||
'deleted_at',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_major(obj):
|
||||
return obj.get_major_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_university(obj):
|
||||
return obj.get_university_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
"""
|
||||
Resolves the absolute URL for the profile picture.
|
||||
`context` contains the request object, which is needed for build_absolute_uri.
|
||||
"""
|
||||
request = context['request']
|
||||
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||
return request.build_absolute_uri(obj.profile_picture.url)
|
||||
return None
|
||||
|
||||
|
||||
class UserListSchema(ModelSchema):
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
|
||||
class Meta:
|
||||
model = User
|
||||
fields = [
|
||||
'id',
|
||||
'username',
|
||||
'email',
|
||||
'first_name',
|
||||
'last_name',
|
||||
'is_active',
|
||||
'is_staff',
|
||||
'is_superuser',
|
||||
'date_joined',
|
||||
'major',
|
||||
'university',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_full_name(obj):
|
||||
return obj.get_full_name()
|
||||
|
||||
@staticmethod
|
||||
def resolve_major(obj):
|
||||
return obj.get_major_display()
|
||||
|
||||
@staticmethod
|
||||
def resolve_university(obj):
|
||||
return obj.get_university_display()
|
||||
|
||||
class UserUpdateSchema(Schema):
|
||||
first_name: Optional[str] = None
|
||||
last_name: Optional[str] = None
|
||||
bio: Optional[str] = None
|
||||
year_of_study: Optional[int] = None
|
||||
major: Optional[str] = None
|
||||
university: Optional[str] = None
|
||||
student_id: Optional[str] = None
|
||||
|
||||
class TokenSchema(Schema):
|
||||
access_token: str
|
||||
refresh_token: str
|
||||
token_type: str = "bearer"
|
||||
|
||||
class TokenRefreshIn(Schema):
|
||||
refresh_token: str
|
||||
|
||||
class PasswordResetRequestSchema(Schema):
|
||||
email: str
|
||||
|
||||
class PasswordResetConfirmSchema(Schema):
|
||||
token: str
|
||||
new_password: str
|
||||
|
||||
class UsernameCheckSchema(Schema):
|
||||
exists: bool
|
||||
87
backend/api/schemas/blog.py
Normal file
87
backend/api/schemas/blog.py
Normal file
@@ -0,0 +1,87 @@
|
||||
"""Blog API schemas."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from blog.models import Category, Tag, Comment
|
||||
|
||||
|
||||
class CategorySchema(ModelSchema):
|
||||
class Config:
|
||||
model = Category
|
||||
model_fields = ['id', 'name', 'slug', 'description']
|
||||
|
||||
class TagSchema(ModelSchema):
|
||||
class Config:
|
||||
model = Tag
|
||||
model_fields = ['id', 'name', 'slug']
|
||||
|
||||
class AuthorSchema(Schema):
|
||||
id: int
|
||||
username: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
profile_picture: Optional[str] = None
|
||||
|
||||
@staticmethod
|
||||
def resolve_profile_picture(obj, context):
|
||||
request = context['request']
|
||||
if obj.profile_picture and hasattr(obj.profile_picture, 'url'):
|
||||
return request.build_absolute_uri(obj.profile_picture.url)
|
||||
return None
|
||||
|
||||
class PostListSchema(Schema):
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
excerpt: str
|
||||
author: AuthorSchema
|
||||
featured_image: Optional[str] = None
|
||||
status: str
|
||||
published_at: Optional[datetime] = None
|
||||
category: Optional[CategorySchema] = None
|
||||
tags: List[TagSchema]
|
||||
is_featured: bool
|
||||
created_at: datetime
|
||||
reading_time: int
|
||||
|
||||
class PostDetailSchema(PostListSchema):
|
||||
content: str
|
||||
content_html: str
|
||||
|
||||
class PostCreateSchema(Schema):
|
||||
title: str
|
||||
content: str
|
||||
excerpt: Optional[str] = None
|
||||
category_id: Optional[int] = None
|
||||
tag_ids: Optional[List[int]] = []
|
||||
status: str = "draft"
|
||||
is_featured: bool = False
|
||||
|
||||
class CommentSchema(ModelSchema):
|
||||
author: AuthorSchema
|
||||
replies: List['CommentSchema'] = []
|
||||
post_id: int
|
||||
post_title: str
|
||||
post_slug: str
|
||||
|
||||
class Config:
|
||||
model = Comment
|
||||
model_fields = ['id', 'content', 'created_at', 'is_approved']
|
||||
|
||||
@staticmethod
|
||||
def resolve_post_id(obj):
|
||||
return obj.post_id
|
||||
|
||||
@staticmethod
|
||||
def resolve_post_title(obj):
|
||||
return obj.post.title
|
||||
|
||||
@staticmethod
|
||||
def resolve_post_slug(obj):
|
||||
return obj.post.slug
|
||||
|
||||
class CommentCreateSchema(Schema):
|
||||
content: str
|
||||
parent_id: Optional[int] = None
|
||||
70
backend/api/schemas/certificates.py
Normal file
70
backend/api/schemas/certificates.py
Normal file
@@ -0,0 +1,70 @@
|
||||
"""API payloads for certificate operations."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import List, Optional
|
||||
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class SkillSchema(Schema):
|
||||
id: int
|
||||
name: str
|
||||
description: Optional[str] = None
|
||||
|
||||
|
||||
class CertificateTemplateOut(Schema):
|
||||
id: int
|
||||
event_id: int
|
||||
event_title: str
|
||||
image_url: Optional[str]
|
||||
skill_ids: List[int]
|
||||
skills: List[SkillSchema]
|
||||
|
||||
|
||||
class CertificateGenerationItem(Schema):
|
||||
user_id: int
|
||||
score: int
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
skill_ids: Optional[List[int]] = None
|
||||
issued_at: Optional[datetime] = None
|
||||
expires_at: Optional[datetime] = None
|
||||
|
||||
|
||||
class CertificateGenerationPayload(Schema):
|
||||
entries: List[CertificateGenerationItem]
|
||||
default_title: Optional[str] = None
|
||||
default_description: Optional[str] = None
|
||||
|
||||
|
||||
class UserCertificateOut(Schema):
|
||||
id: int
|
||||
user_id: int
|
||||
user_name: str
|
||||
event_id: int
|
||||
title: str
|
||||
certificate_id: str
|
||||
certificate_code: str
|
||||
score: int
|
||||
score_label: str
|
||||
image_url: Optional[str]
|
||||
|
||||
|
||||
class CertificateGenerationResponse(Schema):
|
||||
certificates: List[UserCertificateOut]
|
||||
|
||||
|
||||
class CertificateVerificationOut(Schema):
|
||||
certificate_id: str
|
||||
certificate_code: str
|
||||
user_id: int
|
||||
user_name: str
|
||||
event_id: int
|
||||
event_title: str
|
||||
title: str
|
||||
score: int
|
||||
score_label: str
|
||||
issued_at: datetime
|
||||
expires_at: Optional[datetime] = None
|
||||
image_url: Optional[str] = None
|
||||
skills: List[str]
|
||||
124
backend/api/schemas/communications.py
Normal file
124
backend/api/schemas/communications.py
Normal file
@@ -0,0 +1,124 @@
|
||||
"""Schemas for communications-related endpoints."""
|
||||
|
||||
from datetime import datetime
|
||||
from typing import Optional, List
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
|
||||
from api.schemas import AuthorSchema
|
||||
from communications.models import (
|
||||
Announcement,
|
||||
NewsletterSubscription,
|
||||
PushNotificationDevice
|
||||
)
|
||||
|
||||
|
||||
class AnnouncementSchema(ModelSchema):
|
||||
author: AuthorSchema
|
||||
content_html: str
|
||||
|
||||
class Config:
|
||||
model = Announcement
|
||||
model_fields = [
|
||||
'id', 'title', 'content', 'announcement_type', 'priority',
|
||||
'is_published', 'publish_date', 'send_email', 'send_push',
|
||||
'target_audience', 'email_sent', 'push_sent', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_content_html(obj):
|
||||
return obj.content_html
|
||||
|
||||
class AnnouncementListSchema(Schema):
|
||||
id: int
|
||||
title: str
|
||||
content: str
|
||||
announcement_type: str
|
||||
priority: str
|
||||
author: AuthorSchema
|
||||
is_published: bool
|
||||
publish_date: Optional[datetime] = None
|
||||
target_audience: str
|
||||
created_at: datetime
|
||||
|
||||
class AnnouncementCreateSchema(Schema):
|
||||
title: str
|
||||
content: str
|
||||
announcement_type: str = "general"
|
||||
priority: str = "normal"
|
||||
target_audience: str = "all"
|
||||
is_published: bool = False
|
||||
publish_date: Optional[datetime] = None
|
||||
send_email: bool = False
|
||||
send_push: bool = False
|
||||
|
||||
class AnnouncementUpdateSchema(Schema):
|
||||
title: Optional[str] = None
|
||||
content: Optional[str] = None
|
||||
announcement_type: Optional[str] = None
|
||||
priority: Optional[str] = None
|
||||
target_audience: Optional[str] = None
|
||||
is_published: Optional[bool] = None
|
||||
publish_date: Optional[datetime] = None
|
||||
send_email: Optional[bool] = None
|
||||
send_push: Optional[bool] = None
|
||||
|
||||
class NewsletterSubscriptionSchema(ModelSchema):
|
||||
user: Optional[AuthorSchema] = None
|
||||
|
||||
class Config:
|
||||
model = NewsletterSubscription
|
||||
model_fields = [
|
||||
'id', 'email', 'is_active', 'subscribed_categories',
|
||||
'confirmed_at', 'created_at'
|
||||
]
|
||||
|
||||
class NewsletterSubscribeSchema(Schema):
|
||||
email: str
|
||||
subscribed_categories: Optional[List[str]] = []
|
||||
|
||||
class NewsletterUnsubscribeSchema(Schema):
|
||||
email: str
|
||||
|
||||
class PushDeviceSchema(ModelSchema):
|
||||
user: AuthorSchema
|
||||
|
||||
class Config:
|
||||
model = PushNotificationDevice
|
||||
model_fields = [
|
||||
'id', 'device_token', 'device_type', 'is_active', 'created_at'
|
||||
]
|
||||
|
||||
class PushDeviceCreateSchema(Schema):
|
||||
device_token: str
|
||||
device_type: str = "web"
|
||||
|
||||
class PushDeviceUpdateSchema(Schema):
|
||||
is_active: bool
|
||||
|
||||
class PushNotificationSchema(Schema):
|
||||
title: str
|
||||
body: str
|
||||
data: Optional[dict] = None
|
||||
target_audience: str = "all"
|
||||
|
||||
class MessageResponseSchema(Schema):
|
||||
"""Simple message payload for API responses."""
|
||||
message: str
|
||||
success: bool = True
|
||||
|
||||
class AnnouncementStatsSchema(Schema):
|
||||
"""Summary statistics for announcements."""
|
||||
total_announcements: int
|
||||
published_announcements: int
|
||||
draft_announcements: int
|
||||
urgent_announcements: int
|
||||
email_sent_count: int
|
||||
push_sent_count: int
|
||||
|
||||
class NewsletterStatsSchema(Schema):
|
||||
"""Summary statistics for newsletter subscriptions."""
|
||||
total_subscriptions: int
|
||||
active_subscriptions: int
|
||||
confirmed_subscriptions: int
|
||||
recent_subscriptions: int
|
||||
247
backend/api/schemas/events.py
Normal file
247
backend/api/schemas/events.py
Normal file
@@ -0,0 +1,247 @@
|
||||
"""Event and gallery API schemas."""
|
||||
|
||||
from uuid import UUID
|
||||
from ninja import ModelSchema, Schema
|
||||
from pydantic import field_validator
|
||||
from typing import Literal, Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
from api.schemas.blog import AuthorSchema
|
||||
from events.models import Event, Registration
|
||||
from gallery.models import Gallery
|
||||
from payments.models import Payment
|
||||
|
||||
|
||||
class EventGallerySchema(ModelSchema):
|
||||
"""Schema representing gallery items associated with an event."""
|
||||
uploaded_by: AuthorSchema
|
||||
file_size_mb: float
|
||||
markdown_url: str
|
||||
absolute_image_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Gallery
|
||||
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||
'width', 'height', 'is_public', 'created_at']
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_image_url(obj, context):
|
||||
request = context['request']
|
||||
if obj.image and hasattr(obj.image, 'url'):
|
||||
return request.build_absolute_uri(obj.image.url)
|
||||
return None
|
||||
|
||||
class EventSchema(ModelSchema):
|
||||
"""Schema providing full event details for API responses."""
|
||||
gallery_images: List[EventGallerySchema]
|
||||
description_html: str
|
||||
registration_count: int
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
|
||||
class Config:
|
||||
model = Event
|
||||
model_fields = [
|
||||
'id', 'title', 'slug', 'description', 'featured_image', 'event_type',
|
||||
'address', 'location', 'online_link', 'start_time', 'end_time',
|
||||
'registration_start_date', 'registration_end_date', 'registration_success_markdown',
|
||||
'capacity', 'price', 'status', 'created_at', 'updated_at'
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_url(obj, context):
|
||||
request = context['request']
|
||||
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
|
||||
@staticmethod
|
||||
def resolve_description_html(obj):
|
||||
return obj.description_html
|
||||
|
||||
|
||||
class EventListSchema(Schema):
|
||||
"""Condensed event representation for list endpoints."""
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
featured_image: Optional[str] = None
|
||||
absolute_featured_image_url: Optional[str] = None
|
||||
event_type: str
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: str
|
||||
registration_count: int
|
||||
created_at: datetime
|
||||
|
||||
@staticmethod
|
||||
def resolve_absolute_featured_image_url(obj, context):
|
||||
request = context['request']
|
||||
if obj.featured_image and hasattr(obj.featured_image, 'url'):
|
||||
return request.build_absolute_uri(obj.featured_image.url)
|
||||
return None
|
||||
|
||||
@staticmethod
|
||||
def resolve_registration_count(obj):
|
||||
return obj.registrations.filter(status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED]).count()
|
||||
|
||||
class EventCreateSchema(Schema):
|
||||
"""Payload for creating events via the API."""
|
||||
title: str
|
||||
description: str
|
||||
event_type: str
|
||||
address: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
online_link: Optional[str] = None
|
||||
start_time: datetime
|
||||
end_time: datetime
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: str = "draft"
|
||||
gallery_image_ids: Optional[List[int]] = []
|
||||
|
||||
class EventUpdateSchema(Schema):
|
||||
"""Payload for updating events via the API."""
|
||||
title: Optional[str] = None
|
||||
description: Optional[str] = None
|
||||
event_type: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
location: Optional[str] = None
|
||||
online_link: Optional[str] = None
|
||||
start_time: Optional[datetime] = None
|
||||
end_time: Optional[datetime] = None
|
||||
registration_start_date: Optional[datetime] = None
|
||||
registration_end_date: Optional[datetime] = None
|
||||
capacity: Optional[int] = None
|
||||
price: Optional[float] = None
|
||||
status: Optional[str] = None
|
||||
gallery_image_ids: Optional[List[int]] = None
|
||||
|
||||
class RegistrationSchema(ModelSchema):
|
||||
"""Schema describing a registration entry with event context."""
|
||||
user: AuthorSchema
|
||||
event: EventListSchema
|
||||
discount_code: str | None = None
|
||||
|
||||
class Config:
|
||||
model = Registration
|
||||
model_fields = [
|
||||
'id',
|
||||
'status',
|
||||
'registered_at',
|
||||
'ticket_id',
|
||||
'discount_amount',
|
||||
'final_price',
|
||||
'created_at',
|
||||
'updated_at',
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def resolve_discount_code(obj):
|
||||
return obj.discount_code.code if obj.discount_code else None
|
||||
|
||||
|
||||
class AdminUserSchema(Schema):
|
||||
id: int
|
||||
username: str
|
||||
first_name: str
|
||||
last_name: str
|
||||
email: str
|
||||
|
||||
|
||||
class PaymentAdminSchema(Schema):
|
||||
id: int
|
||||
authority: Optional[str]
|
||||
ref_id: Optional[str]
|
||||
status: int
|
||||
status_label: str
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
verified_at: Optional[datetime]
|
||||
created_at: datetime
|
||||
discount_code: Optional[str]
|
||||
user: AdminUserSchema
|
||||
|
||||
@field_validator("discount_code", mode="before")
|
||||
def normalize_discount_code(cls, value):
|
||||
if value is None:
|
||||
return None
|
||||
if hasattr(value, "code"):
|
||||
return value.code
|
||||
return str(value)
|
||||
|
||||
|
||||
class RegistrationAdminSchema(Schema):
|
||||
id: int
|
||||
ticket_id: UUID
|
||||
status: str
|
||||
status_label: str
|
||||
registered_at: datetime
|
||||
final_price: Optional[int]
|
||||
discount_amount: Optional[int]
|
||||
user: AdminUserSchema
|
||||
payments: List[PaymentAdminSchema]
|
||||
|
||||
|
||||
class EventAdminDetailSchema(EventSchema):
|
||||
registrations: List[RegistrationAdminSchema] = []
|
||||
|
||||
@staticmethod
|
||||
def resolve_registrations(obj):
|
||||
return obj.registrations.select_related("user").prefetch_related(
|
||||
"payments__discount_code"
|
||||
).order_by("-registered_at")
|
||||
|
||||
class PaginatedRegistrationSchema(Schema):
|
||||
count: int
|
||||
next: Optional[str] = None
|
||||
previous: Optional[str] = None
|
||||
results: List[RegistrationAdminSchema]
|
||||
|
||||
class RegistrationStatusUpdateSchema(Schema):
|
||||
status: str
|
||||
|
||||
class RegisterationDetailSchema(Schema):
|
||||
"""Detailed registration information with associated event metadata."""
|
||||
event_image: Optional[str]
|
||||
event_title: str
|
||||
event_type: str
|
||||
ticket_id: UUID
|
||||
status: str
|
||||
registered_at: datetime
|
||||
success_markdown: Optional[str]
|
||||
|
||||
class EventBriefSchema(Schema):
|
||||
"""Minimal event representation used for nested responses."""
|
||||
id: int
|
||||
title: str
|
||||
slug: str
|
||||
start_date: datetime
|
||||
end_date: Optional[datetime] = None
|
||||
location: Optional[str] = None
|
||||
price: int
|
||||
absolute_image_url: Optional[str] = None
|
||||
|
||||
class MyEventRegistrationOut(Schema):
|
||||
"""Registration information as returned to authenticated users."""
|
||||
id: int
|
||||
created_at: datetime
|
||||
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||
event: EventBriefSchema
|
||||
|
||||
class RegistrationStatusOut(Schema):
|
||||
is_registered: bool
|
||||
|
||||
|
||||
class RegistrationCreateSchema(Schema):
|
||||
discount_code: Optional[str] = None
|
||||
27
backend/api/schemas/gallery.py
Normal file
27
backend/api/schemas/gallery.py
Normal file
@@ -0,0 +1,27 @@
|
||||
"""Schemas for gallery resources."""
|
||||
|
||||
from ninja import Schema, ModelSchema
|
||||
from typing import Optional
|
||||
|
||||
from api.schemas.blog import AuthorSchema
|
||||
from gallery.models import Gallery
|
||||
|
||||
|
||||
class GallerySchema(ModelSchema):
|
||||
"""Serialized representation of a gallery image."""
|
||||
uploaded_by: AuthorSchema
|
||||
file_size_mb: float
|
||||
markdown_url: str
|
||||
|
||||
class Config:
|
||||
model = Gallery
|
||||
model_fields = ['id', 'title', 'description', 'image', 'alt_text',
|
||||
'width', 'height', 'is_public', 'created_at']
|
||||
|
||||
|
||||
class GalleryCreateSchema(Schema):
|
||||
"""Payload for creating a gallery entry."""
|
||||
title: str
|
||||
description: Optional[str] = None
|
||||
alt_text: Optional[str] = None
|
||||
is_public: bool = True
|
||||
35
backend/api/schemas/payments.py
Normal file
35
backend/api/schemas/payments.py
Normal file
@@ -0,0 +1,35 @@
|
||||
from ninja import Schema
|
||||
|
||||
|
||||
class CreatePaymentIn(Schema):
|
||||
event_id: int
|
||||
description: str
|
||||
discount_code: str | None = None
|
||||
mobile: str | None = None
|
||||
email: str | None = None
|
||||
|
||||
|
||||
class CreatePaymentOut(Schema):
|
||||
start_pay_url: str | None = None
|
||||
authority: str | None = None
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
|
||||
class PaymentDetailOut(Schema):
|
||||
ref_id: str | None = None
|
||||
authority: str | None = None
|
||||
base_amount: int
|
||||
discount_amount: int
|
||||
amount: int
|
||||
status: str
|
||||
verified_at: str | None = None
|
||||
event: dict
|
||||
|
||||
class CouponVerifyIn(Schema):
|
||||
event_id: int
|
||||
code: str
|
||||
|
||||
class CouponVerifyOut(Schema):
|
||||
discount_amount: int
|
||||
final_price: int
|
||||
16
backend/api/urls.py
Normal file
16
backend/api/urls.py
Normal file
@@ -0,0 +1,16 @@
|
||||
from ninja import Router
|
||||
|
||||
from api.views import *
|
||||
from api.views import certificates_router
|
||||
|
||||
router = Router()
|
||||
|
||||
router.add_router("auth/", auth_router, tags=["Authentication"])
|
||||
router.add_router("blog/", blog_router, tags=["Blog"])
|
||||
router.add_router("gallery/", gallery_router, tags=["Gallery"])
|
||||
router.add_router("events/", events_router, tags=["Events"])
|
||||
router.add_router("communications/", communications_router, tags=["Communications"])
|
||||
router.add_router("payments/", payments_router, tags=["Payments"])
|
||||
router.add_router("certificates/", certificates_router, tags=["Certificates"])
|
||||
router.add_router("meta/", meta_router, tags=["Meta"])
|
||||
router.add_router("/", health_router, tags=["Health"])
|
||||
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