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

View File

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

View File

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

10
apps/gamification/apps.py Normal file
View File

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

View File

@@ -0,0 +1 @@

View File

@@ -0,0 +1 @@

View File

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

View File

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

View File

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

View File

View File

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

View File

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

View File

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

View File

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

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)