diff --git a/apps/blog/admin.py b/apps/blog/admin.py index 2efc5ec..dde928a 100644 --- a/apps/blog/admin.py +++ b/apps/blog/admin.py @@ -100,23 +100,41 @@ class PostAdmin(BaseModelAdmin, ImportExportModelAdmin): actions = BaseModelAdmin.actions + ['submit_for_review', 'request_changes', 'make_published', 'make_draft', 'make_featured', 'restore_posts'] def submit_for_review(self, request, queryset): - queryset.update(status='submitted') - self.message_user(request, f"Submitted {queryset.count()} posts for review.") + count = 0 + for post in queryset: + post.status = Post.StatusChoices.SUBMITTED + post.save(update_fields=["status", "updated_at"]) + count += 1 + self.message_user(request, f"Submitted {count} posts for review.") submit_for_review.short_description = "Submit selected posts for review" def request_changes(self, request, queryset): - queryset.update(status='changes_requested', reviewed_by=request.user) - self.message_user(request, f"Requested changes on {queryset.count()} posts.") + count = 0 + for post in queryset: + post.status = Post.StatusChoices.CHANGES_REQUESTED + post.reviewed_by = request.user + post.save(update_fields=["status", "reviewed_by", "updated_at"]) + count += 1 + self.message_user(request, f"Requested changes on {count} posts.") request_changes.short_description = "Request changes on selected posts" def make_published(self, request, queryset): - queryset.update(status='published', published_by=request.user) - self.message_user(request, f"Published {queryset.count()} posts.") + count = 0 + for post in queryset: + post.status = Post.StatusChoices.PUBLISHED + post.published_by = request.user + post.save(update_fields=["status", "published_by", "published_at", "updated_at"]) + count += 1 + self.message_user(request, f"Published {count} posts.") make_published.short_description = "Mark selected posts as published" def make_draft(self, request, queryset): - queryset.update(status='draft') - self.message_user(request, f"Marked {queryset.count()} posts as draft.") + count = 0 + for post in queryset: + post.status = Post.StatusChoices.DRAFT + post.save(update_fields=["status", "updated_at"]) + count += 1 + self.message_user(request, f"Marked {count} posts as draft.") make_draft.short_description = "Mark selected posts as draft" def make_featured(self, request, queryset): @@ -156,8 +174,19 @@ class CommentAdmin(BaseModelAdmin): content_preview.short_description = 'Content Preview' def approve_comments(self, request, queryset): - queryset.update(is_approved=True, is_hidden=False, hidden_by=None, hidden_at=None, moderation_note='') - self.message_user(request, f"Approved {queryset.count()} comments.") + count = 0 + for comment in queryset: + comment.is_approved = True + comment.is_hidden = False + comment.hidden_by = None + comment.hidden_at = None + comment.moderation_note = "" + comment.save(update_fields=[ + "is_approved", "is_hidden", "hidden_by", "hidden_at", + "moderation_note", "updated_at", + ]) + count += 1 + self.message_user(request, f"Approved {count} comments.") approve_comments.short_description = "Approve selected comments" def hide_comments(self, request, queryset): diff --git a/apps/blog/api/schemas.py b/apps/blog/api/schemas.py index a88146b..7db0123 100644 --- a/apps/blog/api/schemas.py +++ b/apps/blog/api/schemas.py @@ -356,6 +356,22 @@ class BlogProfileActivitySchema(Schema): replies: List[CommentSchema] +class PaginatedProfilePostsSchema(Schema): + count: int + page: int + page_size: int + total_pages: int + results: List[PostListSchema] + + +class PaginatedProfileCommentsSchema(Schema): + count: int + page: int + page_size: int + total_pages: int + results: List[CommentSchema] + + class BlogBannerSchema(ModelSchema): image_url: str diff --git a/apps/blog/api/views.py b/apps/blog/api/views.py index b0b0e59..d63dc48 100644 --- a/apps/blog/api/views.py +++ b/apps/blog/api/views.py @@ -20,6 +20,8 @@ from apps.blog.api.schemas import ( BlogFiltersSchema, BlogInteractionSchema, BlogProfileActivitySchema, + PaginatedProfileCommentsSchema, + PaginatedProfilePostsSchema, CategorySchema, CategoryWriteSchema, CommentCreateSchema, @@ -608,6 +610,48 @@ def my_blog_activity(request): } +def _profile_page_payload(queryset, page: int, page_size: int = 10): + count = queryset.count() + total_pages = max(1, (count + page_size - 1) // page_size) + start = (page - 1) * page_size + return { + "count": count, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "results": list(queryset[start : start + page_size]), + } + + +@blog_router.get("/me/activity/liked", response=PaginatedProfilePostsSchema, auth=jwt_auth) +def my_liked_posts(request, page: int = Query(1, ge=1)): + queryset = _published_queryset().filter(likes__user=request.auth).order_by("-published_at", "-created_at", "-id") + return _profile_page_payload(queryset, page) + + +@blog_router.get("/me/activity/saved", response=PaginatedProfilePostsSchema, auth=jwt_auth) +def my_saved_posts(request, page: int = Query(1, ge=1)): + queryset = _published_queryset().filter(saves__user=request.auth).order_by("-published_at", "-created_at", "-id") + return _profile_page_payload(queryset, page) + + +@blog_router.get("/me/activity/comments", response=PaginatedProfileCommentsSchema, auth=jwt_auth) +def my_blog_comments(request, page: int = Query(1, ge=1)): + queryset = ( + Comment.objects.filter( + author=request.auth, + is_approved=True, + is_hidden=False, + is_deleted=False, + post__status=Post.StatusChoices.PUBLISHED, + post__is_deleted=False, + ) + .select_related("author", "post") + .order_by("-created_at", "-id") + ) + return _profile_page_payload(queryset, page) + + @blog_router.get("/banners", response=List[BlogBannerSchema]) def list_banners(request): return BlogBanner.objects.filter(is_active=True, is_deleted=False).order_by("sort_order", "-created_at") diff --git a/apps/blog/models.py b/apps/blog/models.py index 2315e76..ff3e618 100644 --- a/apps/blog/models.py +++ b/apps/blog/models.py @@ -374,6 +374,9 @@ class Comment(BaseModel): moderation_note=note, updated_at=now, ) + from apps.gamification.services import sync_comment + for comment in self.__class__.all_objects.filter(id__in=ids).select_related("author", "post"): + sync_comment(comment) def unhide(self): now = timezone.now() @@ -386,6 +389,9 @@ class Comment(BaseModel): moderation_note="", updated_at=now, ) + from apps.gamification.services import sync_comment + for comment in self.__class__.all_objects.filter(id__in=ids).select_related("author", "post"): + sync_comment(comment) def descendant_ids(self) -> list[int]: pending = [self.id] @@ -408,6 +414,9 @@ class Comment(BaseModel): delete_note=note, updated_at=now, ) + from apps.gamification.services import sync_comment + for comment in self.__class__.all_objects.filter(id__in=ids).select_related("author", "post"): + sync_comment(comment) class Like(models.Model): diff --git a/apps/blog/tests/integration/test_public_blog.py b/apps/blog/tests/integration/test_public_blog.py index c142d09..34b77b1 100644 --- a/apps/blog/tests/integration/test_public_blog.py +++ b/apps/blog/tests/integration/test_public_blog.py @@ -89,6 +89,33 @@ def test_blog_interactions_and_activity(client, blog_content, user_factory, auth assert not Like.objects.filter(post=post, user=member).exists() +def test_my_blog_activity_paginates_each_category_independently(client, blog_content, user_factory, auth_headers): + _, _, _, _, _, _, _ = blog_content + member = user_factory() + headers = auth_headers(member) + posts = [ + Post.objects.create(title=f"Saved and liked {index}", content="Content", author=member, + status=Post.StatusChoices.PUBLISHED) + for index in range(12) + ] + for post in posts: + Like.objects.create(post=post, user=member) + SavedPost.objects.create(post=post, user=member) + Comment.objects.create(post=post, author=member, content=f"Comment {post.id}") + + liked_page = client.get("/api/blog/me/activity/liked", {"page": 1}, **headers) + saved_page = client.get("/api/blog/me/activity/saved", {"page": 2}, **headers) + comments_page = client.get("/api/blog/me/activity/comments", {"page": 2}, **headers) + + assert liked_page.status_code == 200, liked_page.content + assert liked_page.json()["count"] == 12 + assert liked_page.json()["page_size"] == 10 + assert len(liked_page.json()["results"]) == 10 + assert len(saved_page.json()["results"]) == 2 + assert len(comments_page.json()["results"]) == 2 + assert comments_page.json()["total_pages"] == 2 + + def test_blog_editor_review_and_moderation(client, blog_content, user_factory, auth_headers, mocker): author, _, _, _, _, _, _ = blog_content editor = user_factory(is_staff=True) diff --git a/apps/events/admin.py b/apps/events/admin.py index f793bfe..5331857 100644 --- a/apps/events/admin.py +++ b/apps/events/admin.py @@ -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): diff --git a/apps/events/api/schemas.py b/apps/events/api/schemas.py index 5a809be..1238522 100644 --- a/apps/events/api/schemas.py +++ b/apps/events/api/schemas.py @@ -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 diff --git a/apps/events/api/views.py b/apps/events/api/views.py index 2789168..895ec09 100644 --- a/apps/events/api/views.py +++ b/apps/events/api/views.py @@ -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 = ( diff --git a/apps/events/migrations/0015_registration_attendance_verification_source_and_more.py b/apps/events/migrations/0015_registration_attendance_verification_source_and_more.py new file mode 100644 index 0000000..4306c28 --- /dev/null +++ b/apps/events/migrations/0015_registration_attendance_verification_source_and_more.py @@ -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), + ), + ] diff --git a/apps/events/models.py b/apps/events/models.py index c65225b..0d00bf1 100644 --- a/apps/events/models.py +++ b/apps/events/models.py @@ -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) diff --git a/apps/events/services.py b/apps/events/services.py new file mode 100644 index 0000000..75c4e70 --- /dev/null +++ b/apps/events/services.py @@ -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 diff --git a/apps/events/tests/integration/test_events.py b/apps/events/tests/integration/test_events.py index acdf6b4..0a64171 100644 --- a/apps/events/tests/integration/test_events.py +++ b/apps/events/tests/integration/test_events.py @@ -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) diff --git a/apps/gamification/__init__.py b/apps/gamification/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/gamification/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gamification/api/__init__.py b/apps/gamification/api/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/gamification/api/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gamification/api/schemas.py b/apps/gamification/api/schemas.py new file mode 100644 index 0000000..f5f1c72 --- /dev/null +++ b/apps/gamification/api/schemas.py @@ -0,0 +1,45 @@ +from typing import Dict, List, Optional + +from ninja import Schema + + +class GamificationLevelSchema(Schema): + id: str + label: str + minimum_points: int + + +class GamificationBadgeSchema(Schema): + id: str + category: str + label: str + description: Optional[str] = None + threshold: Optional[int] = None + + +class GamificationActivityCountsSchema(Schema): + attendance: int + posts: int + comments: int + likes: int + + +class GamificationSummarySchema(Schema): + points_total: int + points_event: int + points_blog: int + activity_counts: GamificationActivityCountsSchema + levels: List[GamificationLevelSchema] + points_per_activity: Dict[str, int] + badge_thresholds: Dict[str, List[int]] + current_level: GamificationLevelSchema + next_level: Optional[GamificationLevelSchema] = None + points_to_next: int + progress_percent: int + badges: List[GamificationBadgeSchema] + + +class PublicGamificationBadgeSchema(Schema): + id: str + category: str + label: str diff --git a/apps/gamification/api/views.py b/apps/gamification/api/views.py new file mode 100644 index 0000000..9b25410 --- /dev/null +++ b/apps/gamification/api/views.py @@ -0,0 +1,13 @@ +from ninja import Router + +from apps.gamification.api.schemas import GamificationSummarySchema +from apps.gamification.services import summary_for_user +from core.authentication import jwt_auth + + +gamification_router = Router() + + +@gamification_router.get("/me", response=GamificationSummarySchema, auth=jwt_auth) +def my_gamification_summary(request): + return summary_for_user(request.auth) diff --git a/apps/gamification/apps.py b/apps/gamification/apps.py new file mode 100644 index 0000000..e0646cf --- /dev/null +++ b/apps/gamification/apps.py @@ -0,0 +1,10 @@ +from django.apps import AppConfig + + +class GamificationConfig(AppConfig): + default_auto_field = "django.db.models.BigAutoField" + name = "apps.gamification" + label = "gamification" + + def ready(self): + from apps.gamification import signals # noqa: F401 diff --git a/apps/gamification/management/__init__.py b/apps/gamification/management/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/gamification/management/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gamification/management/commands/__init__.py b/apps/gamification/management/commands/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/gamification/management/commands/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gamification/management/commands/backfill_event_attendance.py b/apps/gamification/management/commands/backfill_event_attendance.py new file mode 100644 index 0000000..5ab4438 --- /dev/null +++ b/apps/gamification/management/commands/backfill_event_attendance.py @@ -0,0 +1,60 @@ +from collections import Counter + +from django.core.management.base import BaseCommand +from django.db import transaction +from django.utils import timezone + +from apps.events.models import Registration +from apps.gamification.rules import ACTIVITY_BADGES, LEVELS, POINTS, level_for_points +from apps.gamification.services import sync_registration_attendance + + +class Command(BaseCommand): + help = "Report or backfill lifetime points from existing attended event registrations." + + def add_arguments(self, parser): + parser.add_argument( + "--apply", + action="store_true", + help="Write historical attendance provenance and ledger awards; default is read-only.", + ) + + def handle(self, *args, **options): + registrations = list( + Registration.objects.filter(status=Registration.StatusChoices.ATTENDED) + .select_related("user") + .order_by("user_id", "id") + ) + attendance_counts = Counter(registration.user_id for registration in registrations) + level_counts = Counter() + for count in attendance_counts.values(): + level_counts[level_for_points(count * POINTS["attendance"])["id"]] += 1 + + self.stdout.write(f"Attended registrations eligible: {len(registrations)}") + self.stdout.write(f"Users with attended registrations: {len(attendance_counts)}") + self.stdout.write("Projected level distribution (event history only):") + for level in LEVELS: + self.stdout.write(f" {level['id']}: {level_counts[level['id']]}") + self.stdout.write("Projected attendance badges (threshold -> users):") + for badge in ACTIVITY_BADGES["attendance"]: + members = sum(count >= badge["threshold"] for count in attendance_counts.values()) + self.stdout.write(f" {badge['threshold']}: {members}") + + if not options["apply"]: + self.stdout.write(self.style.WARNING("Dry run only; no records were changed. Pass --apply to backfill.")) + return + + verified_at = timezone.now() + with transaction.atomic(): + for registration in registrations: + if not registration.attendance_verified_at: + registration.attendance_verified_at = verified_at + registration.attendance_verification_source = Registration.AttendanceVerificationSource.HISTORICAL + registration.save(update_fields=[ + "attendance_verified_at", + "attendance_verification_source", + "updated_at", + ]) + sync_registration_attendance(registration, trust_legacy=True) + + self.stdout.write(self.style.SUCCESS(f"Backfilled {len(registrations)} attended registrations.")) diff --git a/apps/gamification/migrations/0001_initial.py b/apps/gamification/migrations/0001_initial.py new file mode 100644 index 0000000..0f45e26 --- /dev/null +++ b/apps/gamification/migrations/0001_initial.py @@ -0,0 +1,47 @@ +# Generated by Django 5.2.5 on 2026-09-29 12:54 + +import django.db.models.deletion +import uuid +from django.conf import settings +from django.db import migrations, models + + +class Migration(migrations.Migration): + + initial = True + + dependencies = [ + migrations.swappable_dependency(settings.AUTH_USER_MODEL), + ] + + operations = [ + migrations.CreateModel( + name='ScoringEpoch', + fields=[ + ('id', models.PositiveSmallIntegerField(default=1, editable=False, primary_key=True, serialize=False)), + ('started_at', models.DateTimeField()), + ], + ), + migrations.CreateModel( + name='ContributionLedger', + fields=[ + ('id', models.BigAutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('activity', models.CharField(choices=[('attendance', 'Event attendance'), ('post', 'Published post'), ('comment', 'Approved comment or reply'), ('like', 'Like given or received'), ('save', 'Save given or received')], max_length=20)), + ('source_type', models.CharField(max_length=32)), + ('source_id', models.CharField(max_length=80)), + ('effect_key', models.CharField(db_index=True, max_length=240)), + ('role', models.CharField(max_length=20)), + ('points', models.IntegerField()), + ('idempotency_key', models.UUIDField(default=uuid.uuid4, editable=False, unique=True)), + ('reason', models.CharField(blank=True, max_length=120)), + ('created_at', models.DateTimeField(auto_now_add=True)), + ('reversal_of', models.ForeignKey(blank=True, null=True, on_delete=django.db.models.deletion.PROTECT, related_name='reversals', to='gamification.contributionledger')), + ('user', models.ForeignKey(on_delete=django.db.models.deletion.CASCADE, related_name='contribution_ledger_entries', to=settings.AUTH_USER_MODEL)), + ], + options={ + 'ordering': ['created_at', 'id'], + 'indexes': [models.Index(fields=['user', 'activity'], name='gamificatio_user_id_860de2_idx'), models.Index(fields=['source_type', 'source_id'], name='gamificatio_source__6d5f84_idx')], + 'constraints': [models.CheckConstraint(condition=models.Q(('points', 0), _negated=True), name='gamification_nonzero_ledger_points')], + }, + ), + ] diff --git a/apps/gamification/migrations/0002_scoring_epoch.py b/apps/gamification/migrations/0002_scoring_epoch.py new file mode 100644 index 0000000..5df41d3 --- /dev/null +++ b/apps/gamification/migrations/0002_scoring_epoch.py @@ -0,0 +1,13 @@ +from django.db import migrations +from django.utils import timezone + + +def create_scoring_epoch(apps, schema_editor): + ScoringEpoch = apps.get_model("gamification", "ScoringEpoch") + ScoringEpoch.objects.get_or_create(pk=1, defaults={"started_at": timezone.now()}) + + +class Migration(migrations.Migration): + dependencies = [("gamification", "0001_initial")] + + operations = [migrations.RunPython(create_scoring_epoch, migrations.RunPython.noop)] diff --git a/apps/gamification/migrations/__init__.py b/apps/gamification/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/apps/gamification/models.py b/apps/gamification/models.py new file mode 100644 index 0000000..fb1a0e9 --- /dev/null +++ b/apps/gamification/models.py @@ -0,0 +1,53 @@ +import uuid + +from django.conf import settings +from django.db import models +from django.db.models import Q + + +class ContributionLedger(models.Model): + class Activity(models.TextChoices): + ATTENDANCE = "attendance", "Event attendance" + POST = "post", "Published post" + COMMENT = "comment", "Approved comment or reply" + LIKE = "like", "Like given or received" + SAVE = "save", "Save given or received" + + user = models.ForeignKey( + settings.AUTH_USER_MODEL, + on_delete=models.CASCADE, + related_name="contribution_ledger_entries", + ) + activity = models.CharField(max_length=20, choices=Activity.choices) + source_type = models.CharField(max_length=32) + source_id = models.CharField(max_length=80) + effect_key = models.CharField(max_length=240, db_index=True) + role = models.CharField(max_length=20) + points = models.IntegerField() + idempotency_key = models.UUIDField(default=uuid.uuid4, unique=True, editable=False) + reversal_of = models.ForeignKey( + "self", + null=True, + blank=True, + on_delete=models.PROTECT, + related_name="reversals", + ) + reason = models.CharField(max_length=120, blank=True) + created_at = models.DateTimeField(auto_now_add=True) + + class Meta: + ordering = ["created_at", "id"] + indexes = [ + models.Index(fields=["user", "activity"]), + models.Index(fields=["source_type", "source_id"]), + ] + constraints = [ + models.CheckConstraint(condition=~Q(points=0), name="gamification_nonzero_ledger_points"), + ] + + +class ScoringEpoch(models.Model): + """The launch boundary for blog scoring; historical blog rows remain unscored.""" + + id = models.PositiveSmallIntegerField(primary_key=True, default=1, editable=False) + started_at = models.DateTimeField() diff --git a/apps/gamification/rules.py b/apps/gamification/rules.py new file mode 100644 index 0000000..614e9d8 --- /dev/null +++ b/apps/gamification/rules.py @@ -0,0 +1,98 @@ +"""Stable scoring weights and derived lifetime rank/badge rules.""" + +POINTS = { + "attendance": 30, + "post": 20, + "comment": 5, + "like": 1, + "save": 1, +} + +LEVELS = ( + {"id": "companion", "label": "همراه", "minimum_points": 0}, + {"id": "contributor", "label": "مشارکت‌گر", "minimum_points": 30}, + {"id": "ally", "label": "همیار", "minimum_points": 150}, + {"id": "influencer", "label": "اثرگذار", "minimum_points": 750}, + {"id": "pioneer", "label": "پیشرو", "minimum_points": 1500}, +) + +ACTIVITY_BADGES = { + "attendance": ( + {"threshold": 1, "label": "حضور اول", "description": "حضور تأییدشده در یک رویداد"}, + {"threshold": 5, "label": "همراه رویدادها", "description": "حضور تأییدشده در پنج رویداد"}, + {"threshold": 10, "label": "همیار رویدادها", "description": "حضور تأییدشده در ده رویداد"}, + {"threshold": 25, "label": "چهره آشنای رویدادها", "description": "حضور تأییدشده در بیست‌وپنج رویداد"}, + ), + "posts": ( + {"threshold": 1, "label": "نویسنده انجمن", "description": "انتشار یا هم‌نویسندگی یک نوشته"}, + {"threshold": 5, "label": "نویسنده فعال", "description": "انتشار یا هم‌نویسندگی پنج نوشته"}, + {"threshold": 10, "label": "نویسنده برجسته", "description": "انتشار یا هم‌نویسندگی ده نوشته"}, + ), + "comments": ( + {"threshold": 5, "label": "اهل گفت‌وگو", "description": "پنج دیدگاه یا پاسخ تأییدشده"}, + {"threshold": 20, "label": "همراه گفت‌وگو", "description": "بیست دیدگاه یا پاسخ تأییدشده"}, + {"threshold": 50, "label": "صدای اثرگذار", "description": "پنجاه دیدگاه یا پاسخ تأییدشده"}, + ), + "likes": ( + {"threshold": 10, "label": "دست‌گرم", "description": "ده پسند داده‌شده یا دریافت‌شده"}, + {"threshold": 50, "label": "محبوب انجمن", "description": "پنجاه پسند داده‌شده یا دریافت‌شده"}, + {"threshold": 100, "label": "الهام‌بخش", "description": "صد پسند داده‌شده یا دریافت‌شده"}, + ), +} + + +def level_for_points(points: int) -> dict: + points = max(0, int(points)) + return next(level for level in reversed(LEVELS) if points >= level["minimum_points"]) + + +def level_progress(points: int) -> dict: + points = max(0, int(points)) + current_index = max( + index for index, level in enumerate(LEVELS) if points >= level["minimum_points"] + ) + current = LEVELS[current_index] + next_level = LEVELS[current_index + 1] if current_index + 1 < len(LEVELS) else None + + if next_level is None: + return { + "current_level": current, + "next_level": None, + "points_to_next": 0, + "progress_percent": 100, + } + + span = next_level["minimum_points"] - current["minimum_points"] + progress_points = points - current["minimum_points"] + return { + "current_level": current, + "next_level": next_level, + "points_to_next": next_level["minimum_points"] - points, + "progress_percent": min(100, int(progress_points * 100 / span)), + } + + +def earned_badges(total_points: int, activity_counts: dict[str, int]) -> list[dict]: + badges = [] + for level in LEVELS: + if total_points >= level["minimum_points"]: + badges.append({ + "id": f"level-{level['id']}", + "category": "level", + "label": level["label"], + "description": f"سطح کلی با {level['minimum_points']} امتیاز", + "threshold": level["minimum_points"], + }) + + for category, levels in ACTIVITY_BADGES.items(): + count = max(0, int(activity_counts.get(category, 0))) + badge_prefix = "post" if category == "posts" else category + for badge_level in levels: + threshold = badge_level["threshold"] + if count >= threshold: + badges.append({ + "id": f"{badge_prefix}-{threshold}", + "category": category, + **badge_level, + }) + return badges diff --git a/apps/gamification/services.py b/apps/gamification/services.py new file mode 100644 index 0000000..5ab90e7 --- /dev/null +++ b/apps/gamification/services.py @@ -0,0 +1,329 @@ +from collections import Counter + +from django.contrib.auth import get_user_model +from django.db import transaction +from django.db.models import Case, F, IntegerField, Sum, Value, When +from django.utils import timezone + +from apps.gamification.models import ContributionLedger, ScoringEpoch +from apps.gamification.rules import ACTIVITY_BADGES, LEVELS, POINTS, earned_badges, level_progress + + +def build_effect_key(source_type: str, source_id: str, activity: str, role: str, user_id: int) -> str: + return f"{source_type}:{source_id}:{activity}:{role}:{user_id}" + + +def scoring_started_at(): + epoch, _ = ScoringEpoch.objects.get_or_create( + pk=1, + defaults={"started_at": timezone.now()}, + ) + return epoch.started_at + + +def has_source_history(source_type: str, source_id: str) -> bool: + return ContributionLedger.objects.filter(source_type=source_type, source_id=str(source_id)).exists() + + +def _net_effect_points(effect_key: str) -> int: + return ContributionLedger.objects.filter(effect_key=effect_key).aggregate(total=Sum("points"))["total"] or 0 + + +def _append_adjustment(*, effect, effect_key, delta: int, reason: str): + if not delta: + return None + reversal_of = None + if delta < 0: + reversal_of = ( + ContributionLedger.objects.filter(effect_key=effect_key, points__gt=0) + .order_by("-created_at", "-id") + .first() + ) + return ContributionLedger.objects.create( + user_id=effect["user_id"], + activity=effect["activity"], + source_type=effect["source_type"], + source_id=str(effect["source_id"]), + effect_key=effect_key, + role=effect["role"], + points=delta, + reversal_of=reversal_of, + reason=reason, + ) + + +def _sync_effect_locked(effect, *, valid: bool, reason: str): + source_type = effect["source_type"] + source_id = str(effect["source_id"]) + effect_key = build_effect_key( + source_type, + source_id, + effect["activity"], + effect["role"], + effect["user_id"], + ) + current_points = _net_effect_points(effect_key) + target_points = int(effect["points"]) if valid else 0 + delta = target_points - current_points + if delta: + _append_adjustment( + effect={**effect, "source_type": source_type, "source_id": source_id}, + effect_key=effect_key, + delta=delta, + reason=reason if not valid else "source effect restored or adjusted", + ) + + +@transaction.atomic +def sync_effect(effect, *, valid: bool, reason: str = "source state changed"): + """Ensure one user's effect is currently awarded or reversed.""" + user_id = effect["user"].pk if hasattr(effect["user"], "pk") else int(effect["user"]) + User = get_user_model() + list(User.objects.select_for_update().filter(pk=user_id).values_list("pk", flat=True)) + normalized = { + **effect, + "user_id": user_id, + "source_id": str(effect["source_id"]), + } + _sync_effect_locked(normalized, valid=valid, reason=reason) + + +@transaction.atomic +def sync_source_effects(source_type: str, source_id: str, desired_effects, *, reason: str = "source state changed"): + """Reconcile the ledger to a source's current valid point recipients. + + A positive row is awarded when an effect becomes valid. A signed adjustment row + reverses or restores it when the source state changes. Repeated calls are safe. + """ + source_id = str(source_id) + desired = {} + for item in desired_effects: + user = item["user"] + user_id = user.pk if hasattr(user, "pk") else int(user) + activity = str(item["activity"]) + role = str(item["role"]) + points = int(item["points"]) + if points <= 0: + raise ValueError("Contribution awards must have positive point values") + effect = { + "user_id": user_id, + "activity": activity, + "role": role, + "points": points, + "source_type": source_type, + "source_id": source_id, + } + key = build_effect_key(source_type, source_id, activity, role, user_id) + desired[key] = effect + + existing = list( + ContributionLedger.objects.filter(source_type=source_type, source_id=source_id) + .values("effect_key", "user_id", "activity", "role") + .distinct() + ) + user_ids = {item["user_id"] for item in existing} | { + item["user_id"] for item in desired.values() + } + if user_ids: + User = get_user_model() + list(User.objects.select_for_update().filter(pk__in=sorted(user_ids)).order_by("pk").values_list("pk", flat=True)) + + effect_keys = {item["effect_key"] for item in existing} | set(desired) + for key in effect_keys: + effect = desired.get(key) + if effect is None: + previous = next(item for item in existing if item["effect_key"] == key) + effect = { + **previous, + "source_type": source_type, + "source_id": source_id, + "points": 0, + } + _sync_effect_locked(effect, valid=effect["points"] > 0, reason=reason) + + +def summary_for_user(user): + rows = ContributionLedger.objects.filter(user=user) + totals = rows.aggregate( + points_total=Sum("points"), + points_event=Sum( + Case(When(activity=ContributionLedger.Activity.ATTENDANCE, then=F("points")), default=Value(0), output_field=IntegerField()) + ), + points_blog=Sum( + Case(When(activity__in=["post", "comment", "like", "save"], then=F("points")), default=Value(0), output_field=IntegerField()) + ), + ) + activity_counts = Counter() + active_effects = ( + rows.values("effect_key", "activity") + .annotate(net_points=Sum("points")) + .filter(net_points__gt=0) + ) + for effect in active_effects: + if effect["activity"] == "attendance": + activity_counts["attendance"] += 1 + elif effect["activity"] == "post": + activity_counts["posts"] += 1 + elif effect["activity"] == "comment": + activity_counts["comments"] += 1 + elif effect["activity"] == "like": + activity_counts["likes"] += 1 + + points_total = totals["points_total"] or 0 + progress = level_progress(points_total) + return { + "points_total": points_total, + "points_event": totals["points_event"] or 0, + "points_blog": totals["points_blog"] or 0, + "activity_counts": { + key: activity_counts[key] + for key in ("attendance", "posts", "comments", "likes") + }, + "levels": list(LEVELS), + "points_per_activity": POINTS, + "badge_thresholds": { + category: [badge["threshold"] for badge in definitions] + for category, definitions in ACTIVITY_BADGES.items() + }, + **progress, + "badges": earned_badges(points_total, activity_counts), + } + + +def desired_effect(user, activity: str, role: str, points: int | None = None): + return { + "user": user, + "activity": activity, + "role": role, + "points": points if points is not None else POINTS[activity], + } + + +def _post_is_public(post) -> bool: + from apps.blog.models import Post + + return ( + post.status == Post.StatusChoices.PUBLISHED + and not post.is_deleted + ) + + +def _credited_author_ids(post) -> set[int]: + return {post.author_id, *post.writers.values_list("id", flat=True)} + + +def sync_registration_attendance(registration, *, trust_legacy: bool = False): + from apps.events.models import Registration + + valid = ( + registration.status == Registration.StatusChoices.ATTENDED + and not registration.is_deleted + and (trust_legacy or bool(registration.attendance_verified_at)) + ) + desired = [desired_effect(registration.user, "attendance", "participant")] if valid else [] + sync_source_effects( + "event_registration", + registration.pk, + desired, + reason="event attendance no longer verified", + ) + + +def sync_post_publication(post, *, valid: bool): + if not valid: + sync_source_effects("blog_post", post.pk, [], reason="post is no longer published") + return + from apps.users.models import User + + users = User.objects.filter(pk__in=_credited_author_ids(post)) + sync_source_effects( + "blog_post", + post.pk, + [desired_effect(user, "post", "author") for user in users], + reason="credited post authors changed", + ) + + +def sync_comment(comment, *, allow_new: bool = False): + from apps.blog.models import Comment, Post + + post = Post.all_objects.get(pk=comment.post_id) + + valid = ( + comment.is_approved + and not comment.is_hidden + and not comment.is_deleted + and _post_is_public(post) + ) + source_id = str(comment.pk) + source_type = "blog_comment" + eligible = ( + allow_new + or comment.created_at >= scoring_started_at() + or has_source_history(source_type, source_id) + ) + if not eligible: + return + desired = [desired_effect(comment.author, "comment", "commenter")] if valid else [] + sync_source_effects(source_type, source_id, desired, reason="comment is no longer approved and visible") + + +def _sync_engagement(interaction, *, source_type: str, activity: str, allow_new: bool): + from apps.blog.models import Post + + post = Post.all_objects.get(pk=interaction.post_id) + source_id = str(interaction.pk) + if not _post_is_public(post): + sync_source_effects(source_type, source_id, [], reason="post engagement is no longer public") + return + eligible = allow_new or interaction.created_at >= scoring_started_at() or has_source_history(source_type, source_id) + if not eligible: + return + + recipients = [] + recipients.append(desired_effect(interaction.user, activity, "actor")) + for author_id in _credited_author_ids(post): + if author_id != interaction.user_id: + recipients.append(desired_effect(author_id, activity, "author")) + sync_source_effects(source_type, source_id, recipients, reason="engagement was removed or invalidated") + + +def sync_like(like, *, allow_new: bool = True): + _sync_engagement(like, source_type="blog_like", activity="like", allow_new=allow_new) + + +def sync_save(saved_post, *, allow_new: bool = True): + _sync_engagement(saved_post, source_type="blog_save", activity="save", allow_new=allow_new) + + +def invalidate_post_engagement(post): + """Reverse only post interactions already tracked by this scoring epoch.""" + from apps.blog.models import Comment, Like, SavedPost + + for comment in Comment.all_objects.filter(post=post): + sync_comment(comment, allow_new=False) + for like in Like.objects.filter(post=post): + sync_like(like, allow_new=False) + for saved_post in SavedPost.objects.filter(post=post): + sync_save(saved_post, allow_new=False) + + +def sync_post_engagement_for_credited_authors(post): + """Refresh received-like/save credits after a writer is added or removed.""" + from apps.blog.models import Comment, Like, SavedPost + + if not _post_is_public(post): + return + for comment in Comment.all_objects.filter(post=post): + sync_comment(comment, allow_new=False) + for like in Like.objects.filter(post=post): + sync_like(like, allow_new=False) + for saved_post in SavedPost.objects.filter(post=post): + sync_save(saved_post, allow_new=False) + + +def public_badges_for_user(user): + return [ + {"id": badge["id"], "category": badge["category"], "label": badge["label"]} + for badge in summary_for_user(user)["badges"] + ] diff --git a/apps/gamification/signals.py b/apps/gamification/signals.py new file mode 100644 index 0000000..9879875 --- /dev/null +++ b/apps/gamification/signals.py @@ -0,0 +1,128 @@ +from django.db.models.signals import m2m_changed, post_delete, post_save, pre_save +from django.dispatch import receiver + +from apps.blog.models import Comment, Like, Post, SavedPost +from apps.events.models import Registration +from apps.gamification.services import ( + desired_effect, + has_source_history, + invalidate_post_engagement, + sync_comment, + sync_effect, + sync_like, + sync_post_engagement_for_credited_authors, + sync_post_publication, + sync_registration_attendance, + sync_save, + sync_source_effects, +) +from apps.users.models import User + + +def _post_is_public(post): + return Post.StatusChoices.PUBLISHED == post.status and not post.is_deleted + + +def _credited_author_ids(post): + return {post.author_id, *post.writers.values_list("id", flat=True)} + + +@receiver(pre_save, sender=Post) +def remember_post_visibility(sender, instance, **kwargs): + if not instance.pk: + instance._gamification_was_public = False + return + old = Post.all_objects.filter(pk=instance.pk).values("status", "is_deleted").first() + instance._gamification_was_public = bool( + old + and old["status"] == Post.StatusChoices.PUBLISHED + and not old["is_deleted"] + ) + + +@receiver(post_save, sender=Post) +def score_post_visibility(sender, instance, created, **kwargs): + was_public = getattr(instance, "_gamification_was_public", False) + is_public = _post_is_public(instance) + if is_public and not was_public: + sync_post_publication(instance, valid=True) + sync_post_engagement_for_credited_authors(instance) + elif was_public and not is_public: + sync_post_publication(instance, valid=False) + invalidate_post_engagement(instance) + + +@receiver(post_delete, sender=Post) +def reverse_deleted_post(sender, instance, **kwargs): + sync_source_effects("blog_post", instance.pk, [], reason="post was deleted") + + +@receiver(m2m_changed, sender=Post.writers.through) +def score_credited_writer_changes(sender, instance, action, reverse, pk_set, **kwargs): + if reverse or not _post_is_public(instance): + return + writer_ids = set(pk_set or getattr(instance, "_gamification_writer_ids_before_clear", set())) + if action in {"pre_clear"}: + instance._gamification_writer_ids_before_clear = set(instance.writers.values_list("id", flat=True)) + return + if action not in {"post_add", "post_remove", "post_clear"}: + return + + for user in User.objects.filter(pk__in=writer_ids): + if action != "post_add" and user.pk == instance.author_id: + continue + effect = { + **desired_effect(user, "post", "author"), + "source_type": "blog_post", + "source_id": str(instance.pk), + } + sync_effect(effect, valid=action == "post_add", reason="post coauthor credit was removed") + sync_post_engagement_for_credited_authors(instance) + instance._gamification_writer_ids_before_clear = set() + + +@receiver(pre_save, sender=Comment) +def remember_comment_approval(sender, instance, **kwargs): + if not instance.pk: + instance._gamification_was_approved = False + return + old = Comment.all_objects.filter(pk=instance.pk).values("is_approved", "is_hidden", "is_deleted").first() + instance._gamification_was_approved = bool( + old and old["is_approved"] and not old["is_hidden"] and not old["is_deleted"] + ) + + +@receiver(post_save, sender=Comment) +def score_approved_comment(sender, instance, created, **kwargs): + became_approved = not getattr(instance, "_gamification_was_approved", False) and instance.is_approved + sync_comment(instance, allow_new=created or became_approved) + + +@receiver(post_delete, sender=Comment) +def reverse_deleted_comment(sender, instance, **kwargs): + sync_source_effects("blog_comment", instance.pk, [], reason="comment was deleted") + + +@receiver(post_save, sender=Like) +def score_like(sender, instance, created, **kwargs): + sync_like(instance, allow_new=created) + + +@receiver(post_delete, sender=Like) +def reverse_like(sender, instance, **kwargs): + sync_source_effects("blog_like", instance.pk, [], reason="like was removed") + + +@receiver(post_save, sender=SavedPost) +def score_saved_post(sender, instance, created, **kwargs): + sync_save(instance, allow_new=created) + + +@receiver(post_delete, sender=SavedPost) +def reverse_saved_post(sender, instance, **kwargs): + sync_source_effects("blog_save", instance.pk, [], reason="saved post was removed") + + +@receiver(post_save, sender=Registration) +def score_registration_attendance(sender, instance, **kwargs): + sync_registration_attendance(instance) diff --git a/apps/gamification/tests/integration/__init__.py b/apps/gamification/tests/integration/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/gamification/tests/integration/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gamification/tests/integration/test_api.py b/apps/gamification/tests/integration/test_api.py new file mode 100644 index 0000000..bca1bbe --- /dev/null +++ b/apps/gamification/tests/integration/test_api.py @@ -0,0 +1,58 @@ +import pytest + +from apps.gamification.services import desired_effect, sync_source_effects + + +pytestmark = [pytest.mark.integration, pytest.mark.django_db] + + +def test_authenticated_summary_returns_progress_and_activity_breakdown(client, user_factory, auth_headers): + member = user_factory() + sync_source_effects( + "event_registration", + "verified-1", + [desired_effect(member, "attendance", "participant")], + ) + + response = client.get("/api/gamification/me", **auth_headers(member)) + + assert response.status_code == 200 + summary = response.json() + assert summary["points_total"] == 30 + assert summary["points_event"] == 30 + assert summary["points_blog"] == 0 + assert summary["current_level"]["id"] == "contributor" + assert summary["next_level"]["id"] == "ally" + assert summary["activity_counts"]["attendance"] == 1 + assert "attendance-1" in {badge["id"] for badge in summary["badges"]} + assert summary["points_per_activity"] == {"attendance": 30, "post": 20, "comment": 5, "like": 1, "save": 1} + assert summary["badge_thresholds"]["likes"] == [10, 50, 100] + + +def test_public_profile_exposes_badges_but_never_points_even_when_details_are_hidden( + client, user_factory, +): + member = user_factory( + username="ranked_public_member", + show_public_profile_details=False, + mobile="09195550999", + ) + sync_source_effects( + "event_registration", + "verified-public-1", + [desired_effect(member, "attendance", "participant")], + ) + + response = client.get(f"/api/auth/public-profile/{member.username}") + + assert response.status_code == 200 + profile = response.json() + assert profile["details_visible"] is False + assert profile["mobile"] is None + assert "attendance-1" in {badge["id"] for badge in profile["badges"]} + assert all(set(badge) == {"id", "category", "label"} for badge in profile["badges"]) + assert not {"points_total", "points_event", "points_blog", "activity_counts"} & profile.keys() + + +def test_gamification_summary_requires_authentication(client): + assert client.get("/api/gamification/me").status_code == 401 diff --git a/apps/gamification/tests/integration/test_blog.py b/apps/gamification/tests/integration/test_blog.py new file mode 100644 index 0000000..f0b0551 --- /dev/null +++ b/apps/gamification/tests/integration/test_blog.py @@ -0,0 +1,129 @@ +import pytest +from django.contrib import admin +from types import SimpleNamespace + +from apps.blog.admin import CommentAdmin, PostAdmin +from apps.blog.models import Comment, Like, Post, SavedPost +from apps.gamification.models import ContributionLedger +from apps.gamification.services import summary_for_user + + +pytestmark = [pytest.mark.integration, pytest.mark.django_db] + + +def test_publish_coauthor_like_and_save_award_expected_contributions(user_factory): + author = user_factory() + coauthor = user_factory() + actor = user_factory() + post = Post.objects.create( + title="Community story", + content="A real story", + author=author, + status=Post.StatusChoices.PUBLISHED, + ) + post.writers.add(coauthor) + + Like.objects.create(post=post, user=actor) + SavedPost.objects.create(post=post, user=actor) + + assert summary_for_user(author)["points_total"] == 22 + assert summary_for_user(coauthor)["points_total"] == 22 + assert summary_for_user(actor)["points_total"] == 2 + assert summary_for_user(actor)["activity_counts"]["likes"] == 1 + assert summary_for_user(coauthor)["activity_counts"]["likes"] == 1 + + post.writers.remove(coauthor) + post.writers.remove(author) + assert summary_for_user(author)["points_total"] == 22 + assert summary_for_user(coauthor)["points_total"] == 0 + assert summary_for_user(actor)["points_total"] == 2 + + +def test_self_like_and_save_count_once_each_and_only_like_advances_badge_ladder(user_factory): + member = user_factory() + post = Post.objects.create( + title="My post", + content="My content", + author=member, + status=Post.StatusChoices.PUBLISHED, + ) + + Like.objects.create(post=post, user=member) + SavedPost.objects.create(post=post, user=member) + + summary = summary_for_user(member) + assert summary["points_total"] == 22 + assert summary["activity_counts"]["likes"] == 1 + assert not any(badge["category"] == "save" for badge in summary["badges"]) + + +def test_approved_comments_and_replies_score_and_hiding_reverses_them(user_factory): + author = user_factory() + commenter = user_factory() + post = Post.objects.create( + title="Discussion", + content="Discuss this", + author=author, + status=Post.StatusChoices.PUBLISHED, + ) + comment = Comment.objects.create(post=post, author=commenter, content="A comment") + reply = Comment.objects.create(post=post, author=author, content="A reply", parent=comment) + assert summary_for_user(commenter)["points_total"] == 5 + assert summary_for_user(author)["points_total"] == 25 + + comment.hide(user=author, note="moderated") + + assert summary_for_user(commenter)["points_total"] == 0 + assert summary_for_user(author)["points_total"] == 20 + assert summary_for_user(commenter)["activity_counts"]["comments"] == 0 + assert ContributionLedger.objects.filter(user=commenter).count() == 2 + assert ContributionLedger.objects.filter(user=author, source_type="blog_comment", source_id=str(reply.pk)).count() == 2 + + +def test_removing_like_and_unpublishing_post_reverse_only_valid_points(user_factory): + author = user_factory() + actor = user_factory() + post = Post.objects.create( + title="Published then withdrawn", + content="Content", + author=author, + status=Post.StatusChoices.PUBLISHED, + ) + like = Like.objects.create(post=post, user=actor) + SavedPost.objects.create(post=post, user=actor) + assert summary_for_user(actor)["points_total"] == 2 + + like.delete() + assert summary_for_user(actor)["points_total"] == 1 + assert summary_for_user(author)["points_total"] == 21 + + post.status = Post.StatusChoices.ARCHIVED + post.save(update_fields=["status", "updated_at"]) + assert summary_for_user(author)["points_total"] == 0 + assert summary_for_user(actor)["points_total"] == 0 + + +def test_blog_admin_publish_and_approve_actions_flow_through_scoring(user_factory, mocker): + editor = user_factory(is_staff=True) + author = user_factory() + commenter = user_factory() + draft = Post.objects.create(title="Draft", content="Content", author=author) + post_admin = PostAdmin(Post, admin.site) + comment_admin = CommentAdmin(Comment, admin.site) + mocker.patch.object(PostAdmin, "message_user") + mocker.patch.object(CommentAdmin, "message_user") + + post_admin.make_published(SimpleNamespace(user=editor), Post.objects.filter(pk=draft.pk)) + draft.refresh_from_db() + pending_comment = Comment.objects.create( + post=draft, + author=commenter, + content="Awaiting approval", + is_approved=False, + ) + assert summary_for_user(author)["points_total"] == 20 + assert summary_for_user(commenter)["points_total"] == 0 + + comment_admin.approve_comments(SimpleNamespace(user=editor), Comment.objects.filter(pk=pending_comment.pk)) + + assert summary_for_user(commenter)["points_total"] == 5 diff --git a/apps/gamification/tests/integration/test_events.py b/apps/gamification/tests/integration/test_events.py new file mode 100644 index 0000000..962e7d3 --- /dev/null +++ b/apps/gamification/tests/integration/test_events.py @@ -0,0 +1,116 @@ +from io import StringIO +from types import SimpleNamespace + +import pytest +from django.contrib import admin +from django.core.management import call_command + +from apps.events.admin import RegistrationAdmin +from apps.events.models import Registration +from apps.events.services import verify_registration_attendance +from apps.gamification.models import ContributionLedger +from apps.gamification.services import summary_for_user + + +pytestmark = [pytest.mark.integration, pytest.mark.django_db] + + +def test_attendee_cannot_mark_their_own_registration_as_attended( + client, event_factory, user_factory, auth_headers, +): + attendee = user_factory() + event = event_factory() + registration = Registration.objects.create( + event=event, + user=attendee, + status=Registration.StatusChoices.CONFIRMED, + ) + + response = client.put( + f"/api/events/registrations/{registration.id}", + data={"status": "attended"}, + content_type="application/json", + **auth_headers(attendee), + ) + + registration.refresh_from_db() + assert response.status_code == 403 + assert registration.status == Registration.StatusChoices.CONFIRMED + assert summary_for_user(attendee)["points_total"] == 0 + + +def test_staff_attendance_action_records_verifier_and_awards_once( + event_factory, user_factory, mocker, +): + staff = user_factory(is_staff=True) + attendee = user_factory() + registration = Registration.objects.create( + event=event_factory(), + user=attendee, + status=Registration.StatusChoices.CONFIRMED, + ) + mocker.patch.object(RegistrationAdmin, "message_user") + registration_admin = RegistrationAdmin(Registration, admin.site) + request = SimpleNamespace(user=staff) + + registration_admin.mark_attended(request, Registration.objects.filter(pk=registration.pk)) + registration.refresh_from_db() + registration_admin.mark_attended(request, Registration.objects.filter(pk=registration.pk)) + + assert registration.status == Registration.StatusChoices.ATTENDED + assert registration.attendance_verified_by_id == staff.id + assert registration.attendance_verification_source == Registration.AttendanceVerificationSource.STAFF_ADMIN + assert summary_for_user(attendee)["points_total"] == 30 + assert ContributionLedger.objects.filter(user=attendee).count() == 1 + + +def test_attendance_backfill_is_read_only_by_default_and_safe_to_repeat( + event_factory, user_factory, +): + attendee = user_factory() + event = event_factory() + registration = Registration.objects.create( + event=event, + user=attendee, + status=Registration.StatusChoices.ATTENDED, + ) + Registration.objects.create( + event=event_factory(), + user=user_factory(), + status=Registration.StatusChoices.CONFIRMED, + ) + output = StringIO() + + call_command("backfill_event_attendance", stdout=output) + assert summary_for_user(attendee)["points_total"] == 0 + registration.refresh_from_db() + assert registration.attendance_verification_source == "" + assert "Dry run only" in output.getvalue() + + call_command("backfill_event_attendance", apply=True, stdout=StringIO()) + call_command("backfill_event_attendance", apply=True, stdout=StringIO()) + + registration.refresh_from_db() + assert registration.attendance_verification_source == Registration.AttendanceVerificationSource.HISTORICAL + assert registration.attendance_verified_at is not None + assert summary_for_user(attendee)["points_total"] == 30 + assert ContributionLedger.objects.filter(user=attendee).count() == 1 + + +def test_removing_verified_attendance_reverses_points_and_badges(event_factory, user_factory): + attendee = user_factory() + registration = Registration.objects.create( + event=event_factory(), + user=attendee, + status=Registration.StatusChoices.CONFIRMED, + ) + verify_registration_attendance(registration) + assert summary_for_user(attendee)["points_total"] == 30 + registration.status = Registration.StatusChoices.CANCELLED + registration.save(update_fields=["status", "updated_at"]) + + summary = summary_for_user(attendee) + assert summary["points_total"] == 0 + assert summary["activity_counts"]["attendance"] == 0 + assert not any(badge["category"] == "attendance" for badge in summary["badges"]) + assert ContributionLedger.objects.filter(user=attendee).count() == 2 diff --git a/apps/gamification/tests/integration/test_ledger.py b/apps/gamification/tests/integration/test_ledger.py new file mode 100644 index 0000000..9016616 --- /dev/null +++ b/apps/gamification/tests/integration/test_ledger.py @@ -0,0 +1,43 @@ +import pytest + +from apps.gamification.services import summary_for_user, sync_source_effects +from apps.gamification.models import ContributionLedger + + +pytestmark = [pytest.mark.integration, pytest.mark.django_db] + + +def test_source_award_is_idempotent_and_reversal_then_restore_tracks_current_state(user_factory): + member = user_factory() + award = [{"user": member, "activity": "post", "role": "author", "points": 20}] + + sync_source_effects("blog_post", "post-1", award) + sync_source_effects("blog_post", "post-1", award) + assert summary_for_user(member)["points_total"] == 20 + assert ContributionLedger.objects.filter(user=member).count() == 1 + + sync_source_effects("blog_post", "post-1", []) + sync_source_effects("blog_post", "post-1", []) + assert summary_for_user(member)["points_total"] == 0 + assert ContributionLedger.objects.filter(user=member).count() == 2 + + sync_source_effects("blog_post", "post-1", award) + assert summary_for_user(member)["points_total"] == 20 + assert summary_for_user(member)["activity_counts"]["posts"] == 1 + assert ContributionLedger.objects.filter(user=member).count() == 3 + + +def test_summary_does_not_count_saves_as_activity_badges(user_factory): + member = user_factory() + sync_source_effects( + "blog_save", + "save-1", + [{"user": member, "activity": "save", "role": "actor", "points": 1}], + ) + + summary = summary_for_user(member) + + assert summary["points_total"] == 1 + assert summary["points_blog"] == 1 + assert summary["activity_counts"]["likes"] == 0 + assert not any(badge["category"] == "save" for badge in summary["badges"]) diff --git a/apps/gamification/tests/unit/__init__.py b/apps/gamification/tests/unit/__init__.py new file mode 100644 index 0000000..8b13789 --- /dev/null +++ b/apps/gamification/tests/unit/__init__.py @@ -0,0 +1 @@ + diff --git a/apps/gamification/tests/unit/test_rules.py b/apps/gamification/tests/unit/test_rules.py new file mode 100644 index 0000000..5442f50 --- /dev/null +++ b/apps/gamification/tests/unit/test_rules.py @@ -0,0 +1,54 @@ +import pytest + +from apps.gamification.rules import earned_badges, level_progress, level_for_points + + +pytestmark = pytest.mark.unit + + +@pytest.mark.parametrize( + ("points", "level_id"), + [ + (0, "companion"), + (29, "companion"), + (30, "contributor"), + (149, "contributor"), + (150, "ally"), + (300, "ally"), + (750, "influencer"), + (1500, "pioneer"), + ], +) +def test_level_for_points_uses_fixed_lifetime_thresholds(points, level_id): + assert level_for_points(points)["id"] == level_id + + +def test_level_progress_reports_points_needed_and_percent_to_next_level(): + progress = level_progress(90) + + assert progress["current_level"]["id"] == "contributor" + assert progress["next_level"]["id"] == "ally" + assert progress["points_to_next"] == 60 + assert progress["progress_percent"] == 50 + + +def test_highest_level_has_no_next_level_or_remaining_progress(): + progress = level_progress(2000) + + assert progress["current_level"]["id"] == "pioneer" + assert progress["next_level"] is None + assert progress["points_to_next"] == 0 + assert progress["progress_percent"] == 100 + + +def test_earned_badges_include_every_reached_global_and_activity_milestone(): + badges = earned_badges( + total_points=300, + activity_counts={"attendance": 5, "posts": 1, "comments": 4, "likes": 50}, + ) + badge_ids = {badge["id"] for badge in badges} + + assert {"level-companion", "level-contributor", "level-ally"} <= badge_ids + assert {"attendance-1", "attendance-5", "post-1", "likes-10", "likes-50"} <= badge_ids + assert "comment-5" not in badge_ids + assert not any(badge_id.startswith("save-") for badge_id in badge_ids) diff --git a/apps/payments/api/schemas.py b/apps/payments/api/schemas.py index a20b26b..57a01b2 100644 --- a/apps/payments/api/schemas.py +++ b/apps/payments/api/schemas.py @@ -1,3 +1,5 @@ +from typing import Literal, Optional + from ninja import Schema from datetime import datetime @@ -64,6 +66,30 @@ class PaymentDetailOut(Schema): verified_at: str | None = None event: dict + +class UserPaymentEventSchema(Schema): + title: str + slug: str + image_url: Optional[str] = None + + +class UserPaymentSchema(Schema): + event: UserPaymentEventSchema + base_amount: int + discount_amount: int + amount: int + status: Literal["initiated", "pending", "paid", "failed", "canceled"] + created_at: datetime + paid_at: Optional[datetime] = None + + +class PaginatedUserPaymentsSchema(Schema): + count: int + page: int + page_size: int + total_pages: int + results: list[UserPaymentSchema] + class CouponVerifyIn(Schema): event_id: int code: str diff --git a/apps/payments/api/views.py b/apps/payments/api/views.py index b5fe602..b96bbf5 100644 --- a/apps/payments/api/views.py +++ b/apps/payments/api/views.py @@ -2,6 +2,7 @@ from django.conf import settings from django.shortcuts import redirect, get_object_or_404 from django.utils import timezone from django.db.models import Count, Q +from typing import List from ninja import Query, Router from ninja.errors import HttpError @@ -22,11 +23,70 @@ from apps.payments.api.schemas import ( DiscountCodeWriteSchema, PagedDiscountCodeSchema, PaymentDetailOut, + PaginatedUserPaymentsSchema, + UserPaymentSchema, ) payments_router = Router(tags=["Payments"]) +@payments_router.get("/me", response=List[UserPaymentSchema], auth=jwt_auth) +def my_payments(request): + status_names = { + Payment.OrderStatusChoices.INIT: "initiated", + Payment.OrderStatusChoices.PENDING: "pending", + Payment.OrderStatusChoices.PAID: "paid", + Payment.OrderStatusChoices.FAILED: "failed", + Payment.OrderStatusChoices.CANCELED: "canceled", + } + payments = Payment.objects.filter(user=request.auth).select_related("event").order_by("-created_at")[:50] + return [ + _user_payment_payload(request, payment, status_names) + for payment in payments + ] + + +def _user_payment_payload(request, payment, status_names): + return { + "event": { + "title": payment.event.title, + "slug": payment.event.slug, + "image_url": request.build_absolute_uri(payment.event.featured_image.url) + if payment.event.featured_image + else None, + }, + "base_amount": payment.base_amount, + "discount_amount": payment.discount_amount, + "amount": payment.amount, + "status": status_names[payment.status], + "created_at": payment.created_at, + "paid_at": payment.verified_at, + } + + +@payments_router.get("/me/paginated", response=PaginatedUserPaymentsSchema, auth=jwt_auth) +def my_payments_paginated(request, page: int = Query(1, ge=1)): + page_size = 10 + status_names = { + Payment.OrderStatusChoices.INIT: "initiated", + Payment.OrderStatusChoices.PENDING: "pending", + Payment.OrderStatusChoices.PAID: "paid", + Payment.OrderStatusChoices.FAILED: "failed", + Payment.OrderStatusChoices.CANCELED: "canceled", + } + queryset = Payment.objects.filter(user=request.auth).select_related("event").order_by("-created_at", "-id") + count = queryset.count() + total_pages = max(1, (count + page_size - 1) // page_size) + start = (page - 1) * page_size + return { + "count": count, + "page": page, + "page_size": page_size, + "total_pages": total_pages, + "results": [_user_payment_payload(request, item, status_names) for item in queryset[start : start + page_size]], + } + + def _staff_required(user): return bool(user and (user.is_staff or user.is_superuser)) diff --git a/apps/payments/tests/integration/test_payments.py b/apps/payments/tests/integration/test_payments.py index 6a9724f..f728f4f 100644 --- a/apps/payments/tests/integration/test_payments.py +++ b/apps/payments/tests/integration/test_payments.py @@ -135,3 +135,82 @@ class TestPaymentsAPIIntegrationTests: settings.ZARINPAL_STARTPAY = 'https://zarinpal/start/' settings.ZARINPAL_VERIFY_URL = 'https://zarinpal/verify' settings.ZARINPAL_CALLBACK_URL = 'https://frontend/callback' + + +@pytest.mark.django_db +@pytest.mark.integration +def test_my_payments_are_user_scoped_and_omit_gateway_identifiers(client, user_factory, auth_headers, event_factory): + current_user = user_factory(username="payment_history_owner") + other_user = user_factory(username="payment_history_other") + own_event = event_factory(slug="payment-history-own", title="My workshop") + other_event = event_factory(slug="payment-history-other", title="Someone else's workshop") + Payment.objects.create( + user=current_user, + event=own_event, + base_amount=120000, + discount_amount=20000, + amount=100000, + status=Payment.OrderStatusChoices.PAID, + authority="internal-authority-owner", + ref_id="internal-reference-owner", + card_pan="6037-****-****-1234", + card_hash="internal-card-hash", + ) + Payment.objects.create( + user=other_user, + event=other_event, + base_amount=50000, + amount=50000, + status=Payment.OrderStatusChoices.PENDING, + authority="internal-authority-other", + ) + + response = client.get("/api/payments/me", **auth_headers(current_user)) + assert response.status_code == 200, response.content + payload = response.json() + assert len(payload) == 1 + assert payload[0]["event"]["title"] == own_event.title + assert payload[0]["amount"] == 100000 + assert payload[0]["discount_amount"] == 20000 + assert payload[0]["status"] == "paid" + assert set(payload[0]) == {"event", "base_amount", "discount_amount", "amount", "status", "created_at", "paid_at"} + assert "internal-authority-owner" not in response.content.decode() + assert "internal-reference-owner" not in response.content.decode() + assert "internal-card-hash" not in response.content.decode() + + assert client.get("/api/payments/me").status_code == 401 + + +@pytest.mark.django_db +@pytest.mark.integration +def test_my_payments_pagination_is_user_scoped(client, user_factory, auth_headers, event_factory): + current_user = user_factory(username="payment_paging_owner") + other_user = user_factory(username="payment_paging_other") + event = event_factory(slug="payment-paging-event") + for index in range(12): + Payment.objects.create( + user=current_user, + event=event, + base_amount=1000 + index, + amount=1000 + index, + status=Payment.OrderStatusChoices.PAID, + ) + Payment.objects.create( + user=other_user, + event=event, + base_amount=9000, + amount=9000, + status=Payment.OrderStatusChoices.PAID, + ) + + first_page = client.get("/api/payments/me/paginated", {"page": 1}, **auth_headers(current_user)) + second_page = client.get("/api/payments/me/paginated", {"page": 2}, **auth_headers(current_user)) + + 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["event"]["slug"] == event.slug for item in first_page.json()["results"]) + assert "authority" not in first_page.json()["results"][0] diff --git a/apps/users/api/schemas.py b/apps/users/api/schemas.py index 22f7bab..b407498 100644 --- a/apps/users/api/schemas.py +++ b/apps/users/api/schemas.py @@ -1,9 +1,12 @@ """Authentication-related API schemas.""" from datetime import datetime -from typing import List, Optional +from typing import List, Optional, Literal from ninja import ModelSchema, Schema +from pydantic import Field + +from apps.gamification.api.schemas import PublicGamificationBadgeSchema from apps.users.models import User from apps.blog.permissions import can_access_blog_admin, can_review_blog_posts, can_write_blog_posts @@ -94,13 +97,16 @@ class UserProfileSchema(ModelSchema): profile_picture_preview_url: Optional[str] = None student_id: Optional[str] = None major: Optional[str] = None + major_code: Optional[str] = None university: Optional[str] = None + university_code: Optional[str] = None mobile: Optional[str] = None requires_mobile_verification: bool has_google_link: bool can_access_blog_admin: bool can_write_blog_posts: bool can_review_blog_posts: bool + show_public_profile_details: bool = False class Meta: model = User @@ -119,6 +125,7 @@ class UserProfileSchema(ModelSchema): "date_joined", "is_email_verified", "is_mobile_verified", + "show_public_profile_details", "is_active", "is_staff", "is_superuser", @@ -130,10 +137,18 @@ class UserProfileSchema(ModelSchema): def resolve_major(obj): return obj.get_major_display() + @staticmethod + def resolve_major_code(obj): + return obj.major.code if obj.major_id else None + @staticmethod def resolve_university(obj): return obj.get_university_display() + @staticmethod + def resolve_university_code(obj): + return obj.university.code if obj.university_id else None + @staticmethod def resolve_requires_mobile_verification(obj): return obj.requires_mobile_verification @@ -284,14 +299,42 @@ class UserAuthorizationUpdateSchema(Schema): class UserUpdateSchema(Schema): + username: Optional[str] = None email: Optional[str] = None first_name: Optional[str] = None last_name: Optional[str] = None - bio: Optional[str] = None + bio: Optional[str] = Field(default=None, max_length=500) year_of_study: Optional[int] = None major: Optional[str] = None university: Optional[str] = None student_id: Optional[str] = None + show_public_profile_details: Optional[bool] = None + + +class PublicProfileEventSchema(Schema): + title: str + slug: str + start_time: datetime + image_url: Optional[str] = None + participation: Literal["confirmed", "attended"] + + +class PublicMemberProfileSchema(Schema): + username: str + first_name: str + last_name: str + full_name: str + profile_picture: Optional[str] = None + bio: str = "" + details_visible: bool + mobile: Optional[str] = None + email: Optional[str] = None + student_id: Optional[str] = None + year_of_study: Optional[int] = None + university: Optional[str] = None + major: Optional[str] = None + badges: List[PublicGamificationBadgeSchema] = [] + events: List[PublicProfileEventSchema] = [] class TokenSchema(Schema): diff --git a/apps/users/api/views.py b/apps/users/api/views.py index 1fb1fde..19f6d56 100644 --- a/apps/users/api/views.py +++ b/apps/users/api/views.py @@ -2,15 +2,19 @@ from __future__ import annotations import jwt import uuid +import re from django.conf import settings from django.contrib.auth.models import Group +from django.core.exceptions import ValidationError from django.core.files.base import ContentFile from django.db.models import Q from django.http import HttpResponseRedirect from django.shortcuts import get_object_or_404 from ninja import Query, Router +from apps.events.models import Event, Registration +from apps.gamification.services import public_badges_for_user from apps.users.api.schemas import ( AuthorizationRoleSchema, GoogleClaimVerifySchema, @@ -32,12 +36,13 @@ from apps.users.api.schemas import ( UserLoginSchema, UserOtpLoginSchema, UserProfileSchema, + PublicMemberProfileSchema, UserRegistrationSchema, UserUpdateSchema, UsernameCheckSchema, ) from apps.blog.permissions import ASSOCIATION_ADMIN_GROUP, BLOG_EDITOR_GROUP, BLOG_SUPERVISOR_GROUP -from apps.users.email_identity import normalize_email_identity +from apps.users.email_identity import is_placeholder_email, normalize_email_identity from apps.users.models import Major, University, User from apps.users.services.auth import ( AuthServiceError, @@ -81,6 +86,9 @@ CURATED_ROLE_GROUPS = { ASSOCIATION_ADMIN_GROUP, } +PUBLIC_USERNAME_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") +PUBLIC_USERNAME_MIN_LENGTH = 5 + ROLE_SPECS = [ { "key": BLOG_EDITOR_GROUP, @@ -374,11 +382,83 @@ def get_profile(request): return request.auth +@auth_router.get("/public-profile/{username}", response={200: PublicMemberProfileSchema, 404: ErrorSchema}) +def get_public_profile(request, username: str): + user = User.objects.filter(username=username, is_active=True, is_deleted=False).select_related( + "university", "major" + ).first() + if not user: + return 404, {"error": "پروفایل پیدا نشد."} + + details_visible = user.show_public_profile_details + profile_picture = None + if user.profile_picture and user.profile_picture.name: + profile_picture = request.build_absolute_uri(user.profile_picture.url) + + events = [] + registrations = ( + Registration.objects.filter( + user=user, + event__is_deleted=False, + event__status__in=[Event.StatusChoices.PUBLISHED, Event.StatusChoices.COMPLETED], + status__in=[Registration.StatusChoices.CONFIRMED, Registration.StatusChoices.ATTENDED], + ) + .select_related("event") + .order_by("-event__start_time")[:12] + ) + for registration in registrations: + event = registration.event + events.append({ + "title": event.title, + "slug": event.slug, + "start_time": event.start_time, + "image_url": request.build_absolute_uri(event.featured_image.url) if event.featured_image else None, + "participation": registration.status, + }) + + profile = { + "username": user.username, + "first_name": user.first_name, + "last_name": user.last_name, + "full_name": user.get_full_name() or user.username, + "profile_picture": profile_picture, + "bio": user.bio or "", + "details_visible": details_visible, + "mobile": user.mobile if details_visible else None, + "student_id": user.student_id if details_visible else None, + "year_of_study": user.year_of_study if details_visible else None, + "university": user.get_university_display() if details_visible else None, + "major": user.get_major_display() if details_visible else None, + "badges": public_badges_for_user(user), + "events": events, + } + if details_visible and user.email and not is_placeholder_email(user.email): + profile["email"] = user.email + return 200, profile + + @auth_router.put("/profile", response={200: UserProfileSchema, 400: ErrorSchema}, auth=jwt_auth) def update_profile(request, data: UserUpdateSchema): user = request.auth payload = data.dict(exclude_unset=True) + if "username" in payload: + username = (payload["username"] or "").strip() + username_field = User._meta.get_field("username") + if not username or len(username) > username_field.max_length: + return 400, {"error": "نام کاربری باید بین ۱ تا ۱۵۰ نویسه باشد."} + if username != user.username and ( + len(username) < PUBLIC_USERNAME_MIN_LENGTH or not PUBLIC_USERNAME_PATTERN.fullmatch(username) + ): + return 400, {"error": "نام کاربری جدید باید حداقل ۵ نویسه و فقط شامل حروف انگلیسی، عدد و زیرخط باشد."} + try: + username_field.run_validators(username) + except ValidationError: + return 400, {"error": "نام کاربری فقط می‌تواند شامل حروف، عدد و نویسه‌های مجاز باشد."} + if User.objects.filter(username__iexact=username).exclude(pk=user.pk).exists(): + return 400, {"error": "این نام کاربری قبلاً استفاده شده است."} + payload["username"] = username + if "major" in payload: code = payload.pop("major") if code: @@ -565,7 +645,7 @@ def update_user_authorization(request, user_id: int, data: UserAuthorizationUpda @auth_router.get("/check-username", response=UsernameCheckSchema) def check_username_availability(request, username: str): - return {"exists": User.objects.filter(username=username).exists()} + return {"exists": User.objects.filter(username__iexact=username).exists()} @auth_router.get("/check-mobile", response={200: MobileLookupSchema, 400: ErrorSchema}) diff --git a/apps/users/migrations/0010_user_show_public_profile_details.py b/apps/users/migrations/0010_user_show_public_profile_details.py new file mode 100644 index 0000000..6b16241 --- /dev/null +++ b/apps/users/migrations/0010_user_show_public_profile_details.py @@ -0,0 +1,15 @@ +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("users", "0009_alter_user_options_and_more"), + ] + + operations = [ + migrations.AddField( + model_name="user", + name="show_public_profile_details", + field=models.BooleanField(default=False), + ), + ] diff --git a/apps/users/models.py b/apps/users/models.py index 40b9307..7916232 100644 --- a/apps/users/models.py +++ b/apps/users/models.py @@ -76,6 +76,7 @@ class User(AbstractUser, BaseModel): ) is_email_verified = models.BooleanField(default=False) is_mobile_verified = models.BooleanField(default=False) + show_public_profile_details = models.BooleanField(default=False) USERNAME_FIELD = 'username' REQUIRED_FIELDS = ['mobile'] diff --git a/apps/users/tests/integration/test_api_flows.py b/apps/users/tests/integration/test_api_flows.py index 22970b1..e94f1e2 100644 --- a/apps/users/tests/integration/test_api_flows.py +++ b/apps/users/tests/integration/test_api_flows.py @@ -77,6 +77,74 @@ def test_profile_and_authorization_roles(client, user_factory, auth_headers): assert client.get("/api/auth/check-mobile", {"mobile": member.mobile}).json()["exists"] +def test_public_profile_hides_personal_fields_until_the_user_opts_in(client, user_factory, auth_headers, event_factory): + from apps.events.models import Event, Registration + + member = user_factory( + username="public_member", + first_name="Public", + last_name="Member", + email="public.member@example.org", + mobile="09195550123", + student_id="402123456", + year_of_study=1402, + bio="A public introduction", + ) + event = event_factory(slug="public-profile-event") + Registration.objects.create(event=event, user=member, status=Registration.StatusChoices.CONFIRMED) + draft_event = event_factory(slug="private-profile-event", status=Event.StatusChoices.DRAFT) + Registration.objects.create(event=draft_event, user=member, status=Registration.StatusChoices.ATTENDED) + + public_path = f"/api/auth/public-profile/{member.username}" + hidden_response = client.get(public_path) + assert hidden_response.status_code == 200 + hidden = hidden_response.json() + assert hidden["full_name"] == "Public Member" + assert hidden["details_visible"] is False + assert hidden["mobile"] is None + assert hidden["student_id"] is None + assert hidden["year_of_study"] is None + assert "date_joined" not in hidden + assert "blog_activity" not in hidden + assert [row["slug"] for row in hidden["events"]] == [event.slug] + assert hidden.get("email") is None + + updated = client.put( + "/api/auth/profile", + data=json.dumps({"show_public_profile_details": True}), + content_type="application/json", + **auth_headers(member), + ) + assert updated.status_code == 200, updated.content + visible = client.get(public_path).json() + assert visible["details_visible"] is True + assert visible["mobile"] == member.mobile + assert visible["student_id"] == member.student_id + assert visible["year_of_study"] == member.year_of_study + assert visible["email"] == member.email + assert "date_joined" not in visible + assert "blog_activity" not in visible + + +def test_public_profile_does_not_expose_placeholder_email(client, user_factory): + member = user_factory( + username="public_placeholder_email", + email="pending-public-placeholder@noemail.local", + show_public_profile_details=True, + ) + + response = client.get(f"/api/auth/public-profile/{member.username}") + + assert response.status_code == 200 + assert response.json().get("email") is None + + +def test_public_profile_does_not_expose_inactive_users(client, user_factory): + member = user_factory(username="inactive_public_member", is_active=False) + response = client.get(f"/api/auth/public-profile/{member.username}") + assert response.status_code == 404 + + def test_profile_picture_upload_validation_and_delete(client, user_factory, auth_headers, settings, tmp_path): settings.MEDIA_ROOT = tmp_path user = user_factory() @@ -150,6 +218,8 @@ def test_profile_rejects_duplicate_email_and_resolves_education_codes(client, us changed = client.put(path, data=json.dumps({"major": major.code, "university": university.code}), content_type="application/json", **headers) assert changed.status_code == 200, changed.content + assert changed.json()["major_code"] == major.code + assert changed.json()["university_code"] == university.code user.refresh_from_db() assert user.major == major and user.university == university assert client.put(path, data=json.dumps({"major": None, "university": None}), @@ -158,6 +228,92 @@ def test_profile_rejects_duplicate_email_and_resolves_education_codes(client, us assert user.major is None and user.university is None +def test_profile_update_allows_username_changes_and_limits_plain_text_bio(client, user_factory, auth_headers): + member = user_factory(username="before_edit") + another = user_factory(username="already_taken") + headers = auth_headers(member) + path = "/api/auth/profile" + + changed = client.put( + path, + data=json.dumps({"username": "after_edit", "bio": "A short plain text introduction"}), + content_type="application/json", + **headers, + ) + assert changed.status_code == 200, changed.content + assert changed.json()["username"] == "after_edit" + + duplicate = client.put( + path, + data=json.dumps({"username": another.username}), + content_type="application/json", + **headers, + ) + assert duplicate.status_code == 400 + + too_long = client.put( + path, + data=json.dumps({"bio": "a" * 501}), + content_type="application/json", + **headers, + ) + assert too_long.status_code == 422 + at_limit = client.put( + path, + data=json.dumps({"bio": "a" * 500}), + content_type="application/json", + **headers, + ) + assert at_limit.status_code == 200, at_limit.content + + +def test_profile_username_changes_follow_public_username_rules(client, user_factory, auth_headers): + member = user_factory(username="amir") + user_factory(username="already_taken") + headers = auth_headers(member) + path = "/api/auth/profile" + + unchanged_legacy_name = client.put( + path, + data=json.dumps({"username": "amir"}), + content_type="application/json", + **headers, + ) + too_short = client.put( + path, + data=json.dumps({"username": "abc1"}), + content_type="application/json", + **headers, + ) + invalid_characters = client.put( + path, + data=json.dumps({"username": "valid-name"}), + content_type="application/json", + **headers, + ) + duplicate = client.put( + path, + data=json.dumps({"username": "already_taken"}), + content_type="application/json", + **headers, + ) + available = client.put( + path, + data=json.dumps({"username": "valid_name42"}), + content_type="application/json", + **headers, + ) + case_insensitive_availability = client.get("/api/auth/check-username?username=ALREADY_TAKEN") + + assert unchanged_legacy_name.status_code == 200 + assert too_short.status_code == 400 + assert invalid_characters.status_code == 400 + assert duplicate.status_code == 400 + assert available.status_code == 200, available.content + assert available.json()["username"] == "valid_name42" + assert case_insensitive_availability.status_code == 200 + assert case_insensitive_availability.json()["exists"] is True + def test_authorization_rejects_unknown_group_and_refresh_bad_token(client, user_factory, auth_headers): admin = user_factory(is_superuser=True, is_staff=True) member = user_factory() diff --git a/config/api.py b/config/api.py index 2dd80fe..eba06f8 100644 --- a/config/api.py +++ b/config/api.py @@ -7,6 +7,7 @@ from apps.communications.api.views import communications_router from apps.content.api.views import content_router from apps.events.api.views import events_router from apps.gallery.api.views import gallery_router +from apps.gamification.api.views import gamification_router from apps.notifications.api.views import notifications_router from apps.payments.api.views import payments_router from apps.users.api.meta import meta_router @@ -19,6 +20,7 @@ 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("gamification/", gamification_router, tags=["Gamification"]) router.add_router("notifications/", notifications_router, tags=["Notifications"]) router.add_router("communications/", communications_router, tags=["Communications"]) router.add_router("v1/content/", content_router, tags=["Content"]) diff --git a/config/settings/base.py b/config/settings/base.py index 1abff79..ebe4849 100644 --- a/config/settings/base.py +++ b/config/settings/base.py @@ -44,6 +44,7 @@ LOCAL_APPS = [ "apps.certificates", "apps.communications", "apps.payments", + "apps.gamification.apps.GamificationConfig", ] INSTALLED_APPS = DJANGO_APPS + THIRD_PARTY_APPS + LOCAL_APPS diff --git a/tasks/plan.md b/tasks/plan.md new file mode 100644 index 0000000..e0834b4 --- /dev/null +++ b/tasks/plan.md @@ -0,0 +1,78 @@ +# Profile Gamification Implementation Plan + +## Overview + +Replace the signed-in profile overview's duplicate recent-events section with a lifetime community-contribution rank card. Calculate points from verified event attendance and real blog contributions, show a global level plus separate activity badges, and expose earned badges on public profiles. Payments, transaction amounts, a leaderboard, and a new Q&A feature are out of scope. + +## Decisions + +- Lifetime global levels are fixed thresholds: همراه (0), مشارکت‌گر (30), همیار (150), اثرگذار (750), and پیشرو (1,500). The first advancement is one verified attendance; the second aligns with five verified attendances. The dry run showed no event-only history above 150 points, so the remaining thresholds stay aspirational until trusted blog contributions accumulate. +- Draft point values: verified attendance 30; published/coauthored post 20 per credited author; approved comment or reply 5; like and save 1 to the actor and 1 to each credited post author. No caps; reverse points when the source activity becomes invalid. +- A user's own like/save counts, but this plan assumes one point total for a self-action rather than awarding the same user in both roles. For a coauthored post, this plan assumes each credited author receives the engagement point. +- Badge ladders are separate for attendance (1/5/10/25), published posts (1/5/10), approved comments/replies (5/20/50), and likes given plus received combined (10/50/100). Saves have no badge ladder. These are draft thresholds; tune them alongside global thresholds. +- Public profiles show the global level badge and earned activity badges regardless of personal-detail visibility. They do not show points, progress, category totals, or a leaderboard. +- Trust existing event registrations whose current status is attended for historical backfill. Going forward, only staff/system actions can verify attendance. Do not backfill blog activity because existing activity is not reliable; scoring starts with valid actions after rollout. +- Remove the recent-events card from the overview only; retain the Events tab and its registration list. +- Start with existing blog posts, comments/replies, likes, and saves. Defer Q&A, votes, and accepted answers. + +## Architecture + +Add an apps/gamification domain to the backend. Keep source actions in their owning apps and record their scoring effects in an idempotent, auditable ledger. Each award must identify the user, activity category, source action, point amount, and reversal relationship/reason. Use database uniqueness and transactions so retries cannot award twice. The displayed score is the sum of valid awards and reversals; levels and badges are derived from that current valid state. + +Integrate the ledger at the domain transition points: staff/system attendance verification; post publication and credited-writer changes; comment approval/removal; like and save creation/removal. Avoid relying only on model signals where bulk updates bypass them; route admin actions and APIs through shared services. Reconciliation commands should be safe to rerun. + +Expose an authenticated summary for the signed-in user and add badge-only fields to the public-profile response. The authenticated summary includes lifetime points, current/next level and progress, event/blog point totals, and earned badges. Public serialization includes only public badge identifiers/labels and the level badge, without numeric scores or category activity. + +Frontend changes belong in the profile overview and public profile. Use the existing Dialog component for an accessible Persian help modal. Reuse existing design tokens, number formatting, RTL layout, loading, and error patterns. + +## Ordered Tasks + +### Phase 1: Scoring foundation + +1. Add gamification models, fixed rule definitions, aggregation services, and tests for ledger idempotency, reversal, tier thresholds, badge thresholds, and current totals. +2. Add a dry-run/report command for event-history distributions and threshold calibration. It must not write ledger rows by default. + +Checkpoint: tests cover duplicate delivery and reversal; dry run reports attendance counts and projected point/level/badge distributions without modifying data. + +### Phase 2: Trusted source activity + +3. Secure attendance verification: reject attendee self-updates to attended, persist who/what verified attendance and when for new records, and route staff/admin attendance changes through the scoring service. +4. Add an idempotent event backfill for existing attended registrations. Provide dry-run and apply modes; exclude confirmed, pending, cancelled, payment, and transaction data. +5. Connect blog publication/coauthoring, approved comments/replies, likes, and saves to the ledger. Reverse entries when a source is unpublished, unapproved, hidden, deleted, unliked, or unsaved. Do not import historical blog activity. + +Checkpoint: integration tests prove attendance cannot be self-awarded, trusted history is backfilled once, valid blog sources award once, and invalidated sources reverse points and badges. + +### Phase 3: API and profile surfaces + +6. Add the authenticated gamification summary endpoint and extend public-profile output with only earned badges and the global level badge. Add authorization, privacy, and serialization tests. +7. Replace the recent-events card in the signed-in overview with a rank card showing level, points-to-next-level progress, event/blog point breakdown, and earned badges. Add a question-mark control opening a keyboard-accessible help dialog explaining scores, reversals, badge ladders, and rank progression. +8. Display earned rank/activity badges on public profiles even when personal details are hidden. Do not expose scores, category totals, or progress publicly. + +Checkpoint: authenticated and public-profile tests confirm the separate data contracts and badge visibility; frontend build and local browser inspection confirm the RTL layout and modal behavior. + +### Phase 4: Calibration and release + +9. Run dry-run reports using approved event history, review projected level/badge distribution, tune draft thresholds, apply the event backfill once, and verify reconciled totals. Completed in the local development database: 297 attended registrations across 248 users, 8,910 net attendance points, and level projection of 245 contributors / 3 allies. +10. Deploy scoring and UI together; monitor award/reversal errors and provide a safe reconciliation command for recovery. + +## Likely Files + +Backend: apps/gamification (new app), apps/events/models.py, apps/events/api/views.py, apps/events/admin.py, apps/blog/models.py, apps/blog/api/views.py, apps/users/api/views.py, apps/users/api/schemas.py, config/api.py, and focused app tests/migrations. + +Frontend: src/views/Profile.tsx, src/views/PublicProfile.tsx, src/lib/api.ts, src/lib/types.ts, and focused component/API tests. Reuse src/components/ui/dialog.tsx. + +## Risks and Mitigations + +| Risk | Mitigation | +|---|---| +| Current event rows do not record who marked attendance | Trust current attended rows only for the explicitly approved historical backfill; persist verifier/source metadata for future transitions. | +| Bulk updates can bypass model hooks | Centralize scoring in domain services and route API/admin transitions through them; include reconciliation tests. | +| Repeated callbacks or toggling actions can inflate totals | Unique source-action awards, atomic updates, reversal linkage, and tests for unlike/relike and repeated delivery. | +| Historical blog actions are not trustworthy | Start blog scoring at rollout and do not backfill those rows. | +| Draft thresholds could make top levels too easy or unreachable | Dry-run against trusted event history before applying the final thresholds. | +| Public rank could leak private activity | Public API returns badges only; keep exact points and category totals in the authenticated endpoint. | + +## Open Assumptions for Review + +- Confirmed implementation choices: a self-like/save earns one point total; engagement on a coauthored post credits each other credited author. +- Activity badge thresholds remain provisional; blog data will not be used for historical calibration. diff --git a/tasks/todo.md b/tasks/todo.md new file mode 100644 index 0000000..0590c1b --- /dev/null +++ b/tasks/todo.md @@ -0,0 +1,51 @@ +# Profile Gamification Task List + +## Phase 1: Scoring foundation + +- [x] 1. Add gamification ledger, rules, aggregations, and unit tests. + - Acceptance: duplicate source delivery cannot duplicate points; reversals are auditable; global level and activity-badge boundaries are tested. + - Verification: focused gamification tests pass. +- [x] 2. Add a read-only event-history dry run and threshold distribution report; dry-run results support a 30-point first overall level. + - Acceptance: dry run defaults to no writes and reports counts/distributions for draft global and activity thresholds. + - Verification: command tests assert no ledger mutation in dry-run mode. + +### Checkpoint: Scoring foundation + +- [x] Ledger tests and dry-run tests pass without touching production data. + +## Phase 2: Trusted source activity + +- [x] 3. Restrict attendance verification to staff/system and record verification provenance. + - Acceptance: a user cannot set their own registration to attended; staff/system transition is recorded and awards 30 points once. + - Verification: event API/admin integration tests cover denial, verified award, and repeated update. +- [x] 4. Backfill existing attended registrations with dry-run/apply modes; applied to the local development database after dry-run review. + - Acceptance: only current attended registrations are included; apply is idempotent; confirmed registrations and all payments are excluded. + - Verification: command tests cover dry run, apply, and rerun. +- [x] 5. Score blog actions from rollout forward and reverse invalidated activity. + - Acceptance: posts/coauthors, approved comments/replies, likes, and saves receive the agreed scores; invalidated sources reverse them; no old blog actions are imported. + - Verification: blog integration tests cover all award/reversal paths. + +### Checkpoint: Source integration + +- [x] Event and blog integration suites pass; ledger totals reconcile with valid source rows. + +## Phase 3: API and profile surfaces + +- [x] 6. Add authenticated summary and public badge-only API contracts. + - Acceptance: authenticated response includes points/progress/breakdown; public response includes earned badges and level only regardless of personal-detail setting. + - Verification: schema, authorization, and privacy tests pass. +- [x] 7. Replace overview recent events with rank card and explanatory dialog. + - Acceptance: events remain in the Events tab; overview shows level, progress, event/blog points, badges, and an accessible help dialog. + - Verification: frontend build passes; inspect the rendered RTL profile and help dialog in the local browser. +- [x] 8. Render badges on public profiles without exposing numeric score data. + - Acceptance: badges appear with personal details both visible and hidden; public response/rendering omits exact points and category totals. + - Verification: backend public-profile contract tests pass; inspect badge-only public rendering in the local browser. + +### Checkpoint: End-to-end profile flow + +- [x] Authenticated and public surfaces show consistent current badges and level; frontend build, relevant backend tests, and local browser review pass. + +## Phase 4: Calibration and release + +- [x] 9. Review dry-run distributions, tune thresholds, apply the event backfill, and reconcile totals (297 verified ledger awards; 8,910 net event points). +- [ ] 10. Release backend/frontend together and monitor scoring/reversal failures.