Files
guilan-ace-backend/apps/gamification/services.py

330 lines
12 KiB
Python

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