feat: add community contribution ranks
This commit is contained in:
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"]
|
||||
]
|
||||
Reference in New Issue
Block a user