feat: add community contribution ranks

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

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1 @@

View File

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