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", "role") .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" and effect["role"] == "actor": 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"] ]