diff --git a/apps/events/api/schemas.py b/apps/events/api/schemas.py index bf6ee63..3cd15b5 100644 --- a/apps/events/api/schemas.py +++ b/apps/events/api/schemas.py @@ -138,6 +138,7 @@ class EventListSchema(Schema): class EventCreateSchema(Schema): """Payload for creating events via the API.""" title: str + slug: Optional[str] = None description: str event_type: str address: Optional[str] = None @@ -150,11 +151,13 @@ class EventCreateSchema(Schema): capacity: Optional[int] = None price: Optional[float] = None status: str = "draft" + registration_success_markdown: Optional[str] = None gallery_image_ids: Optional[List[int]] = [] class EventUpdateSchema(Schema): """Payload for updating events via the API.""" title: Optional[str] = None + slug: Optional[str] = None description: Optional[str] = None event_type: Optional[str] = None address: Optional[str] = None @@ -167,6 +170,7 @@ class EventUpdateSchema(Schema): capacity: Optional[int] = None price: Optional[float] = None status: Optional[str] = None + registration_success_markdown: Optional[str] = None gallery_image_ids: Optional[List[int]] = None class RegistrationSchema(ModelSchema): @@ -199,12 +203,56 @@ class AdminUserSchema(Schema): first_name: str last_name: str email: str + mobile: Optional[str] = None + profile_picture: Optional[str] = None + profile_picture_thumbnail_url: Optional[str] = None + profile_picture_preview_url: Optional[str] = None + university: Optional[str] = None + major: Optional[str] = None + student_id: Optional[str] = None + year_of_study: Optional[int] = None + + @staticmethod + def resolve_profile_picture(obj, context): + image = getattr(obj, "profile_picture", None) + if not getattr(image, "name", None): + return None + request = context["request"] + return request.build_absolute_uri(image.url) if hasattr(image, "url") else None + + @staticmethod + def resolve_profile_picture_thumbnail_url(obj, context): + image = getattr(obj, "profile_picture", None) + if not getattr(image, "name", None): + return None + request = context["request"] + url = derivative_url(image, THUMBNAIL_VARIANT) + return request.build_absolute_uri(url) if url else None + + @staticmethod + def resolve_profile_picture_preview_url(obj, context): + image = getattr(obj, "profile_picture", None) + if not getattr(image, "name", None): + return None + request = context["request"] + url = derivative_url(image, PREVIEW_VARIANT) + return request.build_absolute_uri(url) if url else None + + @staticmethod + def resolve_university(obj): + return obj.get_university_display() + + @staticmethod + def resolve_major(obj): + return obj.get_major_display() class PaymentAdminSchema(Schema): id: int authority: Optional[str] ref_id: Optional[str] + card_pan: Optional[str] + card_hash: Optional[str] status: int status_label: str base_amount: int @@ -241,7 +289,7 @@ class EventAdminDetailSchema(EventSchema): @staticmethod def resolve_registrations(obj): - return obj.registrations.select_related("user").prefetch_related( + return obj.registrations.select_related("user", "user__university", "user__major").prefetch_related( "payments__discount_code" ).order_by("-registered_at") diff --git a/apps/events/api/views.py b/apps/events/api/views.py index 4985441..67f343f 100644 --- a/apps/events/api/views.py +++ b/apps/events/api/views.py @@ -1,18 +1,20 @@ from django.conf import settings +from django.core.files.base import ContentFile 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 import File, Router, Query, UploadedFile from ninja.errors import HttpError from typing import List, Optional -from uuid import UUID +from uuid import UUID, uuid4 from apps.events.api.schemas import ( EventAdminDetailSchema, EventBriefSchema, EventCreateSchema, + EventGallerySchema, EventListSchema, EventSchema, EventUpdateSchema, @@ -26,6 +28,8 @@ from apps.events.api.schemas import ( ) from core.authentication import jwt_auth from apps.events.models import Event, Registration +from apps.gallery.models import Gallery +from apps.gallery.tasks import process_uploaded_image from apps.notifications.services import notify_user from apps.payments.models import DiscountCode from apps.users.tasks import send_critical_sms @@ -34,6 +38,28 @@ from core.api.schemas import ErrorSchema, MessageSchema events_router = Router() +def _is_staff_user(user) -> bool: + return bool(user and (user.is_staff or user.is_superuser)) + + +def _staff_forbidden(): + return 403, {"error": "اجازه دسترسی ندارید."} + + +def _save_uploaded_image(instance, field_name: str, file: UploadedFile, folder: str): + if not file.content_type or not file.content_type.startswith("image/"): + return False, {"error": "فایل باید تصویر باشد."} + if file.size > 10 * 1024 * 1024: + return False, {"error": "حجم فایل باید کمتر از ۱۰ مگابایت باشد."} + extension = file.name.rsplit(".", 1)[-1] if "." in file.name else "jpg" + getattr(instance, field_name).save( + f"{folder}/{uuid4().hex}.{extension}", + ContentFile(file.read()), + save=True, + ) + return True, instance + + def _frontend_event_url(event: Event) -> str: root = getattr(settings, "FRONTEND_ROOT", "/") or "/" if not root.endswith("/"): @@ -130,20 +156,32 @@ def get_event_by_slug(request, slug: str): ) return event -@events_router.post("/", response=EventSchema) +@events_router.post("/", response={201: EventSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) 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 not _is_staff_user(request.auth): + return _staff_forbidden() + data = payload.dict(exclude={'gallery_image_ids'}) + gallery_image_ids = payload.gallery_image_ids or [] + if data.get("slug"): + data["slug"] = slugify(data["slug"]) + event = Event(**data) + try: + event.full_clean() + event.save() + except Exception as exc: + return 400, {"error": str(exc)} if gallery_image_ids: event.gallery_images.set(gallery_image_ids) - return event + return 201, event -@events_router.put("/{int:event_id}", response=EventSchema) +@events_router.put("/{int:event_id}", response={200: EventSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) def update_event(request, event_id: int, payload: EventUpdateSchema): """Update an existing event""" + if not _is_staff_user(request.auth): + return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) previous_state = { "status": event.status, @@ -158,12 +196,18 @@ def update_event(request, event_id: int, payload: EventUpdateSchema): gallery_image_ids = update_data.pop('gallery_image_ids', None) for attr, value in update_data.items(): + if attr == "slug" and value: + value = slugify(value) setattr(event, attr, value) - if 'title' in update_data: + if 'title' in update_data and not update_data.get("slug"): event.slug = slugify(event.title) - event.save() + try: + event.full_clean() + event.save() + except Exception as exc: + return 400, {"error": str(exc)} if gallery_image_ids is not None: event.gallery_images.set(gallery_image_ids) @@ -196,14 +240,94 @@ def update_event(request, event_id: int, payload: EventUpdateSchema): sms_kind="event_reschedule", ) - return event + return 200, event -@events_router.delete("/{int:event_id}", response=MessageSchema) +@events_router.delete("/{int:event_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) def delete_event(request, event_id: int): """Soft delete an event""" + if not _is_staff_user(request.auth): + return _staff_forbidden() event = get_object_or_404(Event, id=event_id, is_deleted=False) event.delete() - return {"message": "Event deleted successfully"} + return 200, {"message": "Event deleted successfully"} + + +@events_router.post("/{int:event_id}/featured-image", response={200: EventSchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) +def upload_event_featured_image(request, event_id: int, file: UploadedFile = File(...)): + """Upload or replace the poster/featured image for an event.""" + if not _is_staff_user(request.auth): + return _staff_forbidden() + event = get_object_or_404(Event, id=event_id, is_deleted=False) + ok, result = _save_uploaded_image(event, "featured_image", file, "events/featured") + if not ok: + return 400, result + return 200, event + + +@events_router.delete("/{int:event_id}/featured-image", response={200: EventSchema, 403: ErrorSchema}, auth=jwt_auth) +def delete_event_featured_image(request, event_id: int): + """Remove the poster/featured image for an event.""" + if not _is_staff_user(request.auth): + return _staff_forbidden() + event = get_object_or_404(Event, id=event_id, is_deleted=False) + if event.featured_image: + event.featured_image.delete(save=False) + event.featured_image = None + event.save(update_fields=["featured_image", "updated_at"]) + return 200, event + + +@events_router.get("/{int:event_id}/gallery", response={200: List[EventGallerySchema], 403: ErrorSchema}, auth=jwt_auth) +def list_event_gallery(request, event_id: int): + if not _is_staff_user(request.auth): + return _staff_forbidden() + event = get_object_or_404(Event, id=event_id, is_deleted=False) + return 200, event.gallery_images.filter(is_deleted=False).select_related("uploaded_by") + + +@events_router.post("/{int:event_id}/gallery", response={201: EventGallerySchema, 403: ErrorSchema, 400: ErrorSchema}, auth=jwt_auth) +def upload_event_gallery_image( + request, + event_id: int, + file: UploadedFile = File(...), + title: str | None = None, + alt_text: str | None = None, +): + if not _is_staff_user(request.auth): + return _staff_forbidden() + event = get_object_or_404(Event, id=event_id, is_deleted=False) + if not file.content_type or not file.content_type.startswith("image/"): + return 400, {"error": "فایل باید تصویر باشد."} + if file.size > 10 * 1024 * 1024: + return 400, {"error": "حجم فایل باید کمتر از ۱۰ مگابایت باشد."} + try: + gallery_item = Gallery.objects.create( + title=title or file.name, + description="", + uploaded_by=request.auth, + alt_text=alt_text or title or file.name, + is_public=True, + ) + gallery_item._defer_image_processing = True + extension = file.name.rsplit(".", 1)[-1] if "." in file.name else "jpg" + gallery_item.image.save(f"gallery/{uuid4().hex}.{extension}", ContentFile(file.read())) + event.gallery_images.add(gallery_item) + process_uploaded_image.delay(gallery_item.id) + except Exception as exc: + return 400, {"error": str(exc)} + return 201, gallery_item + + +@events_router.delete("/{int:event_id}/gallery/{int:image_id}", response={200: MessageSchema, 403: ErrorSchema}, auth=jwt_auth) +def delete_event_gallery_image(request, event_id: int, image_id: int): + if not _is_staff_user(request.auth): + return _staff_forbidden() + event = get_object_or_404(Event, id=event_id, is_deleted=False) + image = get_object_or_404(Gallery, id=image_id, is_deleted=False) + event.gallery_images.remove(image) + if not image.event_galleries.exclude(id=event.id).exists(): + image.delete() + return 200, {"message": "Gallery image removed"} # Registration endpoints @events_router.get("/{int:event_id}/registrations", response=List[RegistrationSchema]) @@ -235,7 +359,7 @@ def list_event_registrations_admin( event = get_object_or_404(Event, id=event_id, is_deleted=False) qs = ( event.registrations.filter(is_deleted=False) - .select_related("user") + .select_related("user", "user__university", "user__major") .prefetch_related("payments__discount_code") .order_by("-registered_at") ) @@ -259,6 +383,7 @@ def list_event_registrations_admin( if search: qs = qs.filter( Q(user__username__icontains=search) + | Q(user__mobile__icontains=search) | Q(user__email__icontains=search) | Q(user__first_name__icontains=search) | Q(user__last_name__icontains=search) diff --git a/apps/events/tests/integration/test_events.py b/apps/events/tests/integration/test_events.py index 6a33ed3..f4007a2 100644 --- a/apps/events/tests/integration/test_events.py +++ b/apps/events/tests/integration/test_events.py @@ -37,6 +37,7 @@ class EventsAPIIntegrationTests(TestCase): cls.user = User.objects.create_user( username="event_user", email="event.user@example.com", + mobile="09198000001", password=cls.password, ) cls.user.is_email_verified = True @@ -45,6 +46,7 @@ class EventsAPIIntegrationTests(TestCase): cls.staff = User.objects.create_user( username="event_staff", email="event.staff@example.com", + mobile="09198000002", password=cls.password, is_staff=True, ) @@ -151,19 +153,21 @@ class EventsAPIIntegrationTests(TestCase): "/api/events/", data=json.dumps(payload), content_type="application/json", + **self._auth_headers(self.staff_token), ) - self.assertEqual(created.status_code, 200) + self.assertEqual(created.status_code, 201) event_id = created.json()["id"] updated = self.client.put( f"/api/events/{event_id}", data=json.dumps({"title": "Updated Event"}), content_type="application/json", + **self._auth_headers(self.staff_token), ) self.assertEqual(updated.status_code, 200) self.assertEqual(updated.json()["title"], "Updated Event") - deleted = self.client.delete(f"/api/events/{event_id}") + deleted = self.client.delete(f"/api/events/{event_id}", **self._auth_headers(self.staff_token)) self.assertEqual(deleted.status_code, 200) def test_admin_detail_and_registration_list_requires_staff(self): @@ -230,9 +234,10 @@ class EventsAPIIntegrationTests(TestCase): "/api/events/", data=json.dumps(payload), content_type="application/json", + **self._auth_headers(self.staff_token), ) body = response.json() - self.assertEqual(response.status_code, 200) + self.assertEqual(response.status_code, 201) self.assertTrue(body["gallery_images"]) updated = self.client.put( @@ -244,6 +249,7 @@ class EventsAPIIntegrationTests(TestCase): } ), content_type="application/json", + **self._auth_headers(self.staff_token), ) self.assertEqual(updated.status_code, 200) self.assertEqual(updated.json()["slug"], "gallery-event-updated") @@ -370,7 +376,8 @@ class EventsAPIIntegrationTests(TestCase): self.assertEqual(response.status_code, 400) def _create_event_user(self, username, email): - user = User.objects.create_user(username=username, email=email, password=self.password) + suffix = str(abs(hash(username)) % 1_000_000).zfill(6) + user = User.objects.create_user(username=username, email=email, mobile=f"09190{suffix}", password=self.password) user.is_email_verified = True user.save(update_fields=["is_email_verified"]) user.major = self.user.major @@ -468,6 +475,7 @@ class EventSchemasIntegrationTests(TestCase): self.user = User.objects.create_user( username="schema_user", email="schema.user@example.com", + mobile="09198000003", password=self.password, ) self.user.is_email_verified = True