feat: add community contribution ranks

This commit is contained in:
2026-09-29 22:18:57 +03:30
parent 0f6220f72c
commit 77567178ad
46 changed files with 2206 additions and 23 deletions

View File

@@ -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):

View File

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

View File

@@ -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 = (

View File

@@ -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),
),
]

View File

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

View File

@@ -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)