feat: add community contribution ranks
This commit is contained in:
1
apps/gamification/tests/integration/__init__.py
Normal file
1
apps/gamification/tests/integration/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
58
apps/gamification/tests/integration/test_api.py
Normal file
58
apps/gamification/tests/integration/test_api.py
Normal 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
|
||||
129
apps/gamification/tests/integration/test_blog.py
Normal file
129
apps/gamification/tests/integration/test_blog.py
Normal 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
|
||||
116
apps/gamification/tests/integration/test_events.py
Normal file
116
apps/gamification/tests/integration/test_events.py
Normal 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
|
||||
43
apps/gamification/tests/integration/test_ledger.py
Normal file
43
apps/gamification/tests/integration/test_ledger.py
Normal 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"])
|
||||
1
apps/gamification/tests/unit/__init__.py
Normal file
1
apps/gamification/tests/unit/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
54
apps/gamification/tests/unit/test_rules.py
Normal file
54
apps/gamification/tests/unit/test_rules.py
Normal 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)
|
||||
Reference in New Issue
Block a user