initial commit
Some checks failed
Backend CI/CD / test (push) Has been cancelled
Backend CI/CD / deploy (push) Has been cancelled

This commit is contained in:
2026-05-19 20:53:08 +03:30
commit 88b793ed9f
169 changed files with 16763 additions and 0 deletions

View File

247
apps/events/api/schemas.py Normal file
View 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 apps.blog.api.schemas import AuthorSchema
from apps.events.models import Event, Registration
from apps.gallery.models import Gallery
from apps.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

370
apps/events/api/views.py Normal file
View File

@@ -0,0 +1,370 @@
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 apps.events.api.schemas import (
EventAdminDetailSchema,
EventBriefSchema,
EventCreateSchema,
EventListSchema,
EventSchema,
EventUpdateSchema,
MyEventRegistrationOut,
PaginatedRegistrationSchema,
RegisterationDetailSchema,
RegistrationCreateSchema,
RegistrationSchema,
RegistrationStatusOut,
RegistrationStatusUpdateSchema,
)
from core.authentication import jwt_auth
from apps.events.models import Event, Registration
from apps.payments.models import DiscountCode
from core.api.schemas import ErrorSchema, MessageSchema
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