feat: add community contribution ranks
This commit is contained in:
@@ -1,10 +1,12 @@
|
||||
from django.contrib import admin
|
||||
from import_export.admin import ImportExportModelAdmin
|
||||
from django.utils import timezone
|
||||
|
||||
from core.admin import BaseModelAdmin, SoftDeleteListFilter
|
||||
from core.templatetags.jalali import jdate
|
||||
from apps.events.models import Event, Registration
|
||||
from apps.events.resources import EventResource, RegistrationResource
|
||||
from apps.events.services import verify_registration_attendance
|
||||
|
||||
|
||||
@admin.register(Event)
|
||||
@@ -107,13 +109,17 @@ class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
readonly_fields = (
|
||||
'ticket_id', 'registered_at', 'confirmation_notification_sent_at',
|
||||
'cancellation_notification_sent_at', 'discount_code', 'discount_amount',
|
||||
'final_price', 'deleted_at',
|
||||
'final_price', 'deleted_at', 'attendance_verified_at',
|
||||
'attendance_verified_by', 'attendance_verification_source',
|
||||
)
|
||||
fieldsets = (
|
||||
('Registration Details', {'fields': (
|
||||
'user', 'event', 'status', 'registered_at', 'ticket_id',
|
||||
'confirmation_notification_sent_at', 'cancellation_notification_sent_at',
|
||||
)}),
|
||||
('Attendance verification', {'fields': (
|
||||
'attendance_verified_at', 'attendance_verified_by', 'attendance_verification_source',
|
||||
)}),
|
||||
('Pricing & Discount', {
|
||||
'fields': ('discount_code', 'discount_amount', 'final_price'),
|
||||
'classes': ('collapse',),
|
||||
@@ -144,8 +150,28 @@ class RegistrationAdmin(BaseModelAdmin, ImportExportModelAdmin):
|
||||
|
||||
@admin.action(description='Mark selected registrations as attended')
|
||||
def mark_attended(self, request, queryset):
|
||||
queryset.update(status=Registration.StatusChoices.ATTENDED)
|
||||
self.message_user(request, f'Marked {queryset.count()} registrations as attended.')
|
||||
count = 0
|
||||
for registration in queryset:
|
||||
verify_registration_attendance(
|
||||
registration,
|
||||
verifier=request.user,
|
||||
source=Registration.AttendanceVerificationSource.STAFF_ADMIN,
|
||||
)
|
||||
count += 1
|
||||
self.message_user(request, f'Marked {count} registrations as attended.')
|
||||
|
||||
def save_model(self, request, obj, form, change):
|
||||
previous_status = None
|
||||
if change and obj.pk:
|
||||
previous_status = Registration.all_objects.filter(pk=obj.pk).values_list("status", flat=True).first()
|
||||
if (
|
||||
obj.status == Registration.StatusChoices.ATTENDED
|
||||
and (previous_status != Registration.StatusChoices.ATTENDED or not obj.attendance_verified_at)
|
||||
):
|
||||
obj.attendance_verified_by = request.user
|
||||
obj.attendance_verified_at = timezone.now()
|
||||
obj.attendance_verification_source = Registration.AttendanceVerificationSource.STAFF_ADMIN
|
||||
super().save_model(request, obj, form, change)
|
||||
|
||||
@admin.action(description='Restore selected registrations')
|
||||
def restore_registrations(self, request, queryset):
|
||||
|
||||
@@ -330,6 +330,14 @@ class MyEventRegistrationOut(Schema):
|
||||
status: Literal["pending", "confirmed", "cancelled", "attended"]
|
||||
event: EventBriefSchema
|
||||
|
||||
|
||||
class PaginatedMyEventRegistrationsSchema(Schema):
|
||||
count: int
|
||||
page: int
|
||||
page_size: int
|
||||
total_pages: int
|
||||
results: List[MyEventRegistrationOut]
|
||||
|
||||
class RegistrationStatusOut(Schema):
|
||||
is_registered: bool
|
||||
|
||||
|
||||
@@ -1,13 +1,14 @@
|
||||
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.db.models import Q, Case, When, IntegerField, F
|
||||
from django.db.models.functions import Lower
|
||||
from django.utils.text import slugify
|
||||
from django.utils import timezone
|
||||
|
||||
from ninja import File, Router, Query, UploadedFile
|
||||
from ninja.errors import HttpError
|
||||
from typing import List, Optional
|
||||
from typing import List, Literal, Optional
|
||||
from uuid import UUID, uuid4
|
||||
|
||||
from apps.events.api.schemas import (
|
||||
@@ -19,6 +20,7 @@ from apps.events.api.schemas import (
|
||||
EventSchema,
|
||||
EventUpdateSchema,
|
||||
MyEventRegistrationOut,
|
||||
PaginatedMyEventRegistrationsSchema,
|
||||
PaginatedRegistrationSchema,
|
||||
RegisterationDetailSchema,
|
||||
RegistrationCreateSchema,
|
||||
@@ -485,7 +487,10 @@ def update_registration_status(request, registration_id: int, payload: Registrat
|
||||
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')
|
||||
requested_status = payload.dict(exclude_unset=True).get('status')
|
||||
if requested_status == Registration.StatusChoices.ATTENDED:
|
||||
raise HttpError(403, "حضور رویداد فقط توسط برگزارکننده تأیید میشود.")
|
||||
registration.status = requested_status
|
||||
registration.full_clean()
|
||||
registration.save()
|
||||
|
||||
@@ -518,6 +523,56 @@ def verify_my_registration(request, ticket_id: UUID):
|
||||
|
||||
|
||||
|
||||
@events_router.get("/my-registrations/paginated", response=PaginatedMyEventRegistrationsSchema, auth=jwt_auth)
|
||||
def my_registrations_paginated(
|
||||
request,
|
||||
status: Literal["all", "pending", "confirmed", "cancelled", "attended"] = Query("confirmed"),
|
||||
sort_by: Literal["start_time", "title"] = Query("start_time"),
|
||||
sort_order: Literal["asc", "desc"] = Query("desc"),
|
||||
page: int = Query(1, ge=1),
|
||||
):
|
||||
page_size = 10
|
||||
queryset = Registration.objects.filter(user=request.auth)
|
||||
if status != "all":
|
||||
queryset = queryset.filter(status=status)
|
||||
|
||||
ordering_expression = Lower("event__title") if sort_by == "title" else F("event__start_time")
|
||||
ordering_expression = ordering_expression.desc() if sort_order == "desc" else ordering_expression.asc()
|
||||
queryset = queryset.select_related("event").order_by(ordering_expression, "id")
|
||||
count = queryset.count()
|
||||
total_pages = max(1, (count + page_size - 1) // page_size)
|
||||
start = (page - 1) * page_size
|
||||
results = [
|
||||
MyEventRegistrationOut(
|
||||
id=registration.id,
|
||||
created_at=registration.created_at,
|
||||
status=registration.status,
|
||||
event=EventBriefSchema(
|
||||
id=registration.event.id,
|
||||
title=registration.event.title,
|
||||
slug=registration.event.slug,
|
||||
start_date=registration.event.start_time,
|
||||
end_date=registration.event.end_time,
|
||||
location=registration.event.location,
|
||||
price=registration.event.price,
|
||||
absolute_image_url=(
|
||||
request.build_absolute_uri(registration.event.featured_image.url)
|
||||
if registration.event.featured_image
|
||||
else None
|
||||
),
|
||||
),
|
||||
)
|
||||
for registration in queryset[start : start + page_size]
|
||||
]
|
||||
return {
|
||||
"count": count,
|
||||
"page": page,
|
||||
"page_size": page_size,
|
||||
"total_pages": total_pages,
|
||||
"results": results,
|
||||
}
|
||||
|
||||
|
||||
@events_router.get("/my-registrations", response=List[MyEventRegistrationOut], auth=jwt_auth)
|
||||
def my_registrations(request):
|
||||
qs = (
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
# Generated by Django 5.2.5 on 2026-09-29 13:02
|
||||
|
||||
import django.db.models.deletion
|
||||
from django.conf import settings
|
||||
from django.db import migrations, models
|
||||
|
||||
|
||||
class Migration(migrations.Migration):
|
||||
|
||||
dependencies = [
|
||||
('events', '0014_remove_registration_cancellation_email_sent_at_and_more'),
|
||||
migrations.swappable_dependency(settings.AUTH_USER_MODEL),
|
||||
]
|
||||
|
||||
operations = [
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='attendance_verification_source',
|
||||
field=models.CharField(blank=True, choices=[('staff_admin', 'Staff admin'), ('staff_api', 'Staff API'), ('system', 'System'), ('historical', 'Historical backfill')], max_length=20),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='attendance_verified_at',
|
||||
field=models.DateTimeField(blank=True, null=True),
|
||||
),
|
||||
migrations.AddField(
|
||||
model_name='registration',
|
||||
name='attendance_verified_by',
|
||||
field=models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.SET_NULL, related_name='verified_event_attendance', to=settings.AUTH_USER_MODEL),
|
||||
),
|
||||
]
|
||||
@@ -126,11 +126,30 @@ class Registration(BaseModel):
|
||||
CANCELLED = 'cancelled', 'Cancelled'
|
||||
ATTENDED = 'attended', 'Attended'
|
||||
|
||||
class AttendanceVerificationSource(models.TextChoices):
|
||||
STAFF_ADMIN = 'staff_admin', 'Staff admin'
|
||||
STAFF_API = 'staff_api', 'Staff API'
|
||||
SYSTEM = 'system', 'System'
|
||||
HISTORICAL = 'historical', 'Historical backfill'
|
||||
|
||||
event = models.ForeignKey(Event, on_delete=models.CASCADE, related_name='registrations')
|
||||
user = models.ForeignKey(settings.AUTH_USER_MODEL, on_delete=models.CASCADE, related_name='event_registrations')
|
||||
registered_at = models.DateTimeField(auto_now_add=True)
|
||||
status = models.CharField(max_length=10, choices=StatusChoices.choices,
|
||||
default=StatusChoices.PENDING)
|
||||
attendance_verified_at = models.DateTimeField(null=True, blank=True)
|
||||
attendance_verified_by = models.ForeignKey(
|
||||
settings.AUTH_USER_MODEL,
|
||||
null=True,
|
||||
blank=True,
|
||||
on_delete=models.SET_NULL,
|
||||
related_name='verified_event_attendance',
|
||||
)
|
||||
attendance_verification_source = models.CharField(
|
||||
max_length=20,
|
||||
choices=AttendanceVerificationSource.choices,
|
||||
blank=True,
|
||||
)
|
||||
ticket_id = models.UUIDField(default=uuid.uuid4, unique=True, editable=False)
|
||||
|
||||
confirmation_notification_sent_at = models.DateTimeField(null=True, blank=True)
|
||||
@@ -193,3 +212,6 @@ class Registration(BaseModel):
|
||||
send_registration_confirmation_notification.delay(str(self.pk))
|
||||
self.confirmation_notification_sent_at = timezone.now()
|
||||
super().save(update_fields=["confirmation_notification_sent_at"])
|
||||
|
||||
from apps.gamification.services import sync_registration_attendance
|
||||
sync_registration_attendance(self)
|
||||
|
||||
26
apps/events/services.py
Normal file
26
apps/events/services.py
Normal file
@@ -0,0 +1,26 @@
|
||||
from django.db import transaction
|
||||
from django.utils import timezone
|
||||
|
||||
from apps.events.models import Registration
|
||||
|
||||
|
||||
@transaction.atomic
|
||||
def verify_registration_attendance(registration, *, verifier=None, source=None):
|
||||
if source is None:
|
||||
source = (
|
||||
Registration.AttendanceVerificationSource.SYSTEM
|
||||
if verifier is None
|
||||
else Registration.AttendanceVerificationSource.STAFF_API
|
||||
)
|
||||
registration.status = Registration.StatusChoices.ATTENDED
|
||||
registration.attendance_verified_at = timezone.now()
|
||||
registration.attendance_verified_by = verifier
|
||||
registration.attendance_verification_source = source
|
||||
registration.save(update_fields=[
|
||||
"status",
|
||||
"attendance_verified_at",
|
||||
"attendance_verified_by",
|
||||
"attendance_verification_source",
|
||||
"updated_at",
|
||||
])
|
||||
return registration
|
||||
@@ -201,12 +201,14 @@ class TestEventsAPIIntegrationTests:
|
||||
response = self.client.post(f'/api/events/{event.id}/register', HTTP_AUTHORIZATION=f'Bearer {self.token}', content_type='application/json')
|
||||
assert response.status_code == 400
|
||||
|
||||
def test_registration_status_update_and_cancel(self):
|
||||
def test_registration_status_update_rejects_self_attendance_and_allows_cancel(self):
|
||||
event = self._create_event(price=0)
|
||||
registration = self._create_registration(event, self.user)
|
||||
original_status = registration.status
|
||||
update = self.client.put(f'/api/events/registrations/{registration.id}', data=json.dumps({'status': Registration.StatusChoices.ATTENDED}), content_type='application/json', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||
assert update.status_code == 200
|
||||
assert update.json()['status'] == Registration.StatusChoices.ATTENDED
|
||||
assert update.status_code == 403
|
||||
registration.refresh_from_db()
|
||||
assert registration.status == original_status
|
||||
cancel = self.client.delete(f'/api/events/registrations/{registration.id}', HTTP_AUTHORIZATION=f'Bearer {self.token}')
|
||||
assert cancel.status_code == 200
|
||||
assert cancel.json()['message'] == 'ثبت\u200cنام شما لغو شد :('
|
||||
@@ -224,6 +226,100 @@ class TestEventsAPIIntegrationTests:
|
||||
assert status_resp.status_code == 200
|
||||
assert status_resp.json()['is_registered']
|
||||
|
||||
def test_my_registrations_pagination_is_scoped_by_status(self):
|
||||
for index in range(12):
|
||||
event = self._create_event(title=f'Confirmed {index}')
|
||||
self._create_registration(event, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
cancelled_event = self._create_event(title='Cancelled entry')
|
||||
self._create_registration(cancelled_event, self.user, status=Registration.StatusChoices.CANCELLED)
|
||||
|
||||
first_page = self.client.get(
|
||||
'/api/events/my-registrations/paginated',
|
||||
{'status': Registration.StatusChoices.CONFIRMED, 'page': 1},
|
||||
**self._auth_headers(self.token),
|
||||
)
|
||||
second_page = self.client.get(
|
||||
'/api/events/my-registrations/paginated',
|
||||
{'status': Registration.StatusChoices.CONFIRMED, 'page': 2},
|
||||
**self._auth_headers(self.token),
|
||||
)
|
||||
cancelled_page = self.client.get(
|
||||
'/api/events/my-registrations/paginated',
|
||||
{'status': Registration.StatusChoices.CANCELLED, 'page': 1},
|
||||
**self._auth_headers(self.token),
|
||||
)
|
||||
|
||||
assert first_page.status_code == 200, first_page.content
|
||||
assert first_page.json()['count'] == 12
|
||||
assert first_page.json()['page_size'] == 10
|
||||
assert first_page.json()['total_pages'] == 2
|
||||
assert len(first_page.json()['results']) == 10
|
||||
assert len(second_page.json()['results']) == 2
|
||||
assert all(item['status'] == Registration.StatusChoices.CONFIRMED for item in first_page.json()['results'])
|
||||
assert cancelled_page.json()['count'] == 1
|
||||
assert cancelled_page.json()['results'][0]['status'] == Registration.StatusChoices.CANCELLED
|
||||
|
||||
all_first_page = self.client.get(
|
||||
'/api/events/my-registrations/paginated',
|
||||
{'status': 'all', 'page': 1},
|
||||
**self._auth_headers(self.token),
|
||||
)
|
||||
all_second_page = self.client.get(
|
||||
'/api/events/my-registrations/paginated',
|
||||
{'status': 'all', 'page': 2},
|
||||
**self._auth_headers(self.token),
|
||||
)
|
||||
assert all_first_page.status_code == 200, all_first_page.content
|
||||
assert all_first_page.json()['count'] == 13
|
||||
assert all_first_page.json()['total_pages'] == 2
|
||||
assert len(all_first_page.json()['results']) == 10
|
||||
assert len(all_second_page.json()['results']) == 3
|
||||
assert {item['status'] for item in all_first_page.json()['results'] + all_second_page.json()['results']} == {
|
||||
Registration.StatusChoices.CONFIRMED,
|
||||
Registration.StatusChoices.CANCELLED,
|
||||
}
|
||||
|
||||
def test_my_registrations_supports_status_filter_and_event_ordering(self):
|
||||
now = timezone.now()
|
||||
older = self._create_event(title='Bravo Event', slug='bravo-event', start_time=now - timedelta(days=10))
|
||||
middle = self._create_event(title='Alpha Event', slug='alpha-event', start_time=now - timedelta(days=5))
|
||||
newest = self._create_event(title='Charlie Event', slug='charlie-event', start_time=now - timedelta(days=1))
|
||||
self._create_registration(older, self.user, status=Registration.StatusChoices.CANCELLED)
|
||||
self._create_registration(middle, self.user, status=Registration.StatusChoices.CONFIRMED)
|
||||
self._create_registration(newest, self.user, status=Registration.StatusChoices.PENDING)
|
||||
endpoint = '/api/events/my-registrations/paginated'
|
||||
headers = self._auth_headers(self.token)
|
||||
|
||||
all_status_default_order = self.client.get(endpoint, {'status': 'all'}, **headers)
|
||||
legacy_default = self.client.get(endpoint, **headers)
|
||||
confirmed_only = self.client.get(endpoint, {'status': 'confirmed'}, **headers)
|
||||
start_time_ascending = self.client.get(endpoint, {'status': 'all', 'sort_by': 'start_time', 'sort_order': 'asc'}, **headers)
|
||||
title_ascending = self.client.get(endpoint, {'status': 'all', 'sort_by': 'title', 'sort_order': 'asc'}, **headers)
|
||||
title_descending = self.client.get(endpoint, {'status': 'all', 'sort_by': 'title', 'sort_order': 'desc'}, **headers)
|
||||
invalid_sort = self.client.get(endpoint, {'sort_by': 'created_at'}, **headers)
|
||||
invalid_direction = self.client.get(endpoint, {'sort_order': 'sideways'}, **headers)
|
||||
|
||||
assert all_status_default_order.status_code == 200, all_status_default_order.content
|
||||
assert all_status_default_order.json()['count'] == 3
|
||||
assert [row['event']['slug'] for row in all_status_default_order.json()['results']] == [
|
||||
'charlie-event', 'alpha-event', 'bravo-event',
|
||||
]
|
||||
assert legacy_default.json()['count'] == 1
|
||||
assert legacy_default.json()['results'][0]['status'] == Registration.StatusChoices.CONFIRMED
|
||||
assert confirmed_only.json()['count'] == 1
|
||||
assert confirmed_only.json()['results'][0]['status'] == Registration.StatusChoices.CONFIRMED
|
||||
assert [row['event']['slug'] for row in start_time_ascending.json()['results']] == [
|
||||
'bravo-event', 'alpha-event', 'charlie-event',
|
||||
]
|
||||
assert [row['event']['title'] for row in title_ascending.json()['results']] == [
|
||||
'Alpha Event', 'Bravo Event', 'Charlie Event',
|
||||
]
|
||||
assert [row['event']['title'] for row in title_descending.json()['results']] == [
|
||||
'Charlie Event', 'Bravo Event', 'Alpha Event',
|
||||
]
|
||||
assert invalid_sort.status_code == 422
|
||||
assert invalid_direction.status_code == 422
|
||||
|
||||
def test_list_event_registrations(self):
|
||||
event = self.event
|
||||
self._create_registration(event, self.user)
|
||||
|
||||
Reference in New Issue
Block a user