feat: add community contribution ranks
This commit is contained in:
1
apps/gamification/__init__.py
Normal file
1
apps/gamification/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/gamification/api/__init__.py
Normal file
1
apps/gamification/api/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
45
apps/gamification/api/schemas.py
Normal file
45
apps/gamification/api/schemas.py
Normal 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
|
||||
13
apps/gamification/api/views.py
Normal file
13
apps/gamification/api/views.py
Normal 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
10
apps/gamification/apps.py
Normal 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
|
||||
1
apps/gamification/management/__init__.py
Normal file
1
apps/gamification/management/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
1
apps/gamification/management/commands/__init__.py
Normal file
1
apps/gamification/management/commands/__init__.py
Normal file
@@ -0,0 +1 @@
|
||||
|
||||
@@ -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."))
|
||||
47
apps/gamification/migrations/0001_initial.py
Normal file
47
apps/gamification/migrations/0001_initial.py
Normal 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')],
|
||||
},
|
||||
),
|
||||
]
|
||||
13
apps/gamification/migrations/0002_scoring_epoch.py
Normal file
13
apps/gamification/migrations/0002_scoring_epoch.py
Normal 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)]
|
||||
0
apps/gamification/migrations/__init__.py
Normal file
0
apps/gamification/migrations/__init__.py
Normal file
53
apps/gamification/models.py
Normal file
53
apps/gamification/models.py
Normal 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()
|
||||
98
apps/gamification/rules.py
Normal file
98
apps/gamification/rules.py
Normal 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
|
||||
329
apps/gamification/services.py
Normal file
329
apps/gamification/services.py
Normal 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"]
|
||||
]
|
||||
128
apps/gamification/signals.py
Normal file
128
apps/gamification/signals.py
Normal 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)
|
||||
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