Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
8d86f5e
feat: add the achievement source sync engine and its commands
herzog0 Jul 31, 2026
ae711e9
feat: add declarative admin task buttons
herzog0 Aug 3, 2026
8e1448d
feat: add the badges admin with audited grants and revocations
herzog0 Jul 31, 2026
82a9c5d
feat: make the badge page the single tier configuration surface
herzog0 Jul 31, 2026
51cef33
feat: add the per-member badge page to the admin
herzog0 Jul 31, 2026
ffeae7e
feat: log every achievement sync run
herzog0 Aug 3, 2026
0060a6a
feat: order the badge ladder as submitted and log admin syncs
herzog0 Aug 3, 2026
2dbbd67
refactor: trim sync and task-button docstrings
herzog0 Aug 3, 2026
7d99194
docs: state why three achievement types have no automatic source
herzog0 Aug 3, 2026
830efbe
docs: drop the stale seed-data reference from the badge admin
herzog0 Aug 4, 2026
0c3c1da
fix: record the admin who started a button-triggered sync
herzog0 Aug 4, 2026
6a0241e
refactor: derive the sync trigger from the run's actor
herzog0 Aug 4, 2026
be109db
feat: record on the sync run when it dies part way
herzog0 Aug 17, 2026
2a88a91
refactor: report the backfill through the shared describe helper
herzog0 Aug 17, 2026
adbf3af
fix: read the task-button cache payload defensively
herzog0 Aug 17, 2026
7e85999
test: let the refusal test ingest both commits it deletes
herzog0 Aug 17, 2026
ebddbd6
test: cover the option mapping in the reconcile task wrapper
herzog0 Aug 17, 2026
51c9b9a
refactor: build the unseeded slug set once
herzog0 Aug 17, 2026
9411e98
perf: recalculate once per member when a bulk delete removes grants
herzog0 Aug 17, 2026
0980f10
feat: link the member badge page to that member's account
herzog0 Aug 17, 2026
ee9e28e
fix: retire a tier the members have earned from the badge page
herzog0 Aug 17, 2026
bf27dbe
fix: cap the item lists the admin puts in the browser
herzog0 Aug 17, 2026
cedb6b7
fix: read the sync log flags as success, not as failure
herzog0 Aug 17, 2026
6afd18c
fix: stop granting achievements to deactivated accounts
herzog0 Aug 18, 2026
c5c7db8
fix: unbind commit authors from claims nobody confirmed
herzog0 Aug 21, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1,390 changes: 1,390 additions & 0 deletions badges/admin.py

Large diffs are not rendered by default.

20 changes: 20 additions & 0 deletions badges/forms.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
"""Forms for the badges admin."""

from django import forms
from django.utils.translation import gettext_lazy as _


class NotesActionForm(forms.Form):
"""Intermediate form for admin actions that require an audit note.

Used by the ``UserAchievement`` invalidation and ``UserBadge`` revocation
actions, both of which require a non-empty note explaining the change.
"""

notes = forms.CharField(
label=_("Notes"),
widget=forms.Textarea(attrs={"rows": 4, "cols": 60}),
required=True,
strip=True,
help_text=_("Required. Explain why this action is being taken."),
)
68 changes: 68 additions & 0 deletions badges/management/arguments.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
"""Shared arguments for the badge management commands."""

from argparse import ArgumentTypeError

from django.contrib.auth import get_user_model

from badges.models import SyncTrigger


def positive_integer(value):
"""Parse a strictly positive integer for ``argparse``."""
parsed = int(value)
if parsed <= 0:
raise ArgumentTypeError("must be a positive integer")
return parsed


def add_sync_log_arguments(parser):
"""Register the options describing how a run was started.

Both are for the sync log, and both are how a caller that is not a person at a
shell says so: the release pipeline sets the trigger, and a changelist button
names the admin behind it.
"""
parser.add_argument(
"--trigger",
choices=SyncTrigger.values,
default=None,
help=(
"How this run was started, recorded in the sync log (default: admin "
"when --triggered-by names somebody, otherwise command)."
),
)
parser.add_argument(
"--triggered-by",
dest="actor_id",
type=positive_integer,
metavar="USER_ID",
help="Primary key of the person who started this run, for the sync log.",
)


def resolve_sync_log(options, stderr):
"""``(trigger, actor)`` describing where this run came from.

A named person means somebody pressed a button, which is why a caller passing
``--triggered-by`` need not also state the trigger. An explicit one still wins,
so the release pipeline can label its own sweep.

An id that resolves to nobody is reported and otherwise ignored: attribution is
worth less than the run itself, so a member deleted between a button press and
the worker collecting the job costs the log a name, not the sweep. The trigger
still says a person started it, because one did.
"""
actor_id = options["actor_id"]
trigger = options["trigger"] or (
SyncTrigger.ADMIN if actor_id else SyncTrigger.COMMAND
)
if actor_id is None:
return trigger, None

actor = get_user_model().objects.filter(pk=actor_id).first()
if actor is None:
stderr.write(
f"No member with id {actor_id}: the sync log will not record who "
"started this run."
)
return trigger, actor
106 changes: 106 additions & 0 deletions badges/management/commands/backfill_achievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,106 @@
"""Backfill automatic achievements from existing Boost data.

Walks each wired source and creates the grants it yields that the database is
missing, then recalculates badges once per affected (user, achievement) pair.

**Additive only.** This is ``services.sync_source`` with ``remove=False``, so it
can never undo an attribution, which is what makes it safe for the weekly
pipeline to run unattended. To remove the grants a source has stopped supporting,
use ``reconcile_achievements``.
"""

from django.core.management.base import BaseCommand, CommandError

from badges import sources
from badges.management.arguments import (
add_sync_log_arguments,
positive_integer,
resolve_sync_log,
)
from badges.models import Achievement
from badges.services import SYNC_BATCH_SIZE, recalculate_badges, sync_source


class Command(BaseCommand):
"""Create automatic UserAchievement rows from historical data."""

help = "Backfill automatic achievements from existing data."

def add_arguments(self, parser):
"""Register CLI options."""
parser.add_argument(
"--source",
dest="slugs",
action="append",
choices=sources.AUTOMATIC_SLUGS,
help="Backfill only this source slug; repeat for several (default: all).",
)
parser.add_argument(
"--batch-size",
type=positive_integer,
default=SYNC_BATCH_SIZE,
help=f"Rows per bulk_create batch (default: {SYNC_BATCH_SIZE}).",
)
add_sync_log_arguments(parser)

def handle(self, *args, **options):
"""Run the backfill for the requested source(s)."""
explicit = bool(options["slugs"])
slugs = options["slugs"] or sources.AUTOMATIC_SLUGS
batch_size = options["batch_size"]
trigger, actor = resolve_sync_log(options, self.stderr)

achievements = {
achievement.slug: achievement
for achievement in Achievement.objects.filter(slug__in=slugs)
}
missing = sorted(set(slugs) - set(achievements))
if missing:
# A named source that is missing is a deploy bug, so fail on it. A
# scheduled sweep instead reports it and keeps going: one unseeded
# slug must not cost the other five sources their backfill.
if explicit:
raise CommandError(
"No Achievement row for wired source(s): "
f"{', '.join(missing)}. Run migrations to seed the catalogue."
)
self.stderr.write(
"Skipping wired source(s) with no Achievement row: "
f"{', '.join(missing)}. Run migrations to seed the catalogue."
)
unseeded = set(missing)
slugs = [slug for slug in slugs if slug not in unseeded]
if not slugs:
raise CommandError("No wired source has an Achievement row.")

dirty_pairs = set()
for slug in slugs:
achievement = achievements[slug]
result = sync_source(
slug,
achievement,
remove=False,
batch_size=batch_size,
trigger=trigger,
actor=actor,
)
# Only the members who actually gained a row, so a repeat run - the
# weekly one - does not recalculate every pair in the system.
# ``outstanding`` is every one of them here, this run being additive,
# but reading it from the result means no caller has to know that.
dirty_pairs.update(
(user_id, achievement.pk) for user_id in result.outstanding()
)
# ``describe()`` rather than a line of its own, so this and the
# reconcile command and the admin's preview cannot reach different
# conclusions about the same numbers. It can never report a removal
# here: ``remove=False`` leaves the stale set empty.
self.stdout.write(f" {result.slug}: {result.describe()}")

for user_id, achievement_id in dirty_pairs:
recalculate_badges(user_id, achievement_id)
self.stdout.write(
self.style.SUCCESS(
f"Done. Recalculated {len(dirty_pairs)} (user, achievement) pair(s)."
)
)
188 changes: 188 additions & 0 deletions badges/management/commands/reconcile_achievements.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
"""Make automatic achievements agree with the sources they came from.

Two-way, unlike ``backfill_achievements``, which only ever adds. One walk of each
source creates the grants it yields that are missing and deletes the stored grants
it no longer yields - a commit re-assigned to another author, a maintainer dropped
from a library, a news entry unpublished. Manual grants are never touched, and
badges follow in both directions: a tier below its threshold is cascade-revoked, and
one back above it is re-earned.

Scope it and rehearse it: ``--dry-run`` reports without writing, ``--user`` and
``--source`` keep the blast radius to what you meant to fix, and ``--remove-only``
leaves the additive half out when removal is all you want.
"""

from django.contrib.auth import get_user_model
from django.core.management.base import BaseCommand, CommandError

from badges import sources
from badges.management.arguments import (
add_sync_log_arguments,
positive_integer,
resolve_sync_log,
)
from badges.models import Achievement
from badges.services import SYNC_BATCH_SIZE, recalculate_badges, sync_source

User = get_user_model()


class Command(BaseCommand):
"""Sync automatic UserAchievement rows against the sources they derive from."""

help = "Add and remove automatic achievements so they match their sources."

def add_arguments(self, parser):
"""Register CLI options."""
parser.add_argument(
"--source",
dest="slugs",
action="append",
choices=sources.AUTOMATIC_SLUGS,
help="Reconcile only this source slug; repeat for several (default: all).",
)
parser.add_argument(
"--user",
dest="users",
action="append",
metavar="EMAIL_OR_ID",
help=(
"Restrict the run to this member, by email or primary key; "
"repeat for several (default: every member)."
),
)
parser.add_argument(
"--dry-run",
action="store_true",
help="Report what would change and write nothing.",
)
parser.add_argument(
"--remove-only",
action="store_true",
help=(
"Delete stale grants without creating missing ones, which is what "
"this command did before it was two-way."
),
)
parser.add_argument(
"--allow-empty",
action="store_true",
help=(
"Delete even when a source yields no rows at all. Without this, an "
"empty source is treated as broken rather than as evidence that "
"every grant it feeds is stale."
),
)
parser.add_argument(
"--batch-size",
type=positive_integer,
default=SYNC_BATCH_SIZE,
help=f"Rows per insert and delete batch (default: {SYNC_BATCH_SIZE}).",
)
add_sync_log_arguments(parser)

def handle(self, *args, **options):
"""Sync the requested source(s) and report what changed."""
dry_run = options["dry_run"]
slugs = options["slugs"] or sources.AUTOMATIC_SLUGS
user_ids = self._resolve_users(options["users"])
trigger, actor = resolve_sync_log(options, self.stderr)

achievements = {
achievement.slug: achievement
for achievement in Achievement.objects.filter(slug__in=slugs)
}
missing = sorted(set(slugs) - set(achievements))
if missing:
# Unlike the backfill, an unseeded slug is never merely skipped: it
# has no stored grants either, so there is nothing to reconcile and
# nothing to lose by insisting the catalogue is intact first.
raise CommandError(
"No Achievement row for wired source(s): "
f"{', '.join(missing)}. Run migrations to seed the catalogue."
)

if dry_run:
self.stdout.write("Dry run: nothing will be written.")

results = [
sync_source(
slug,
achievements[slug],
user_ids=user_ids,
add=not options["remove_only"],
dry_run=dry_run,
allow_empty=options["allow_empty"],
batch_size=options["batch_size"],
trigger=trigger,
actor=actor,
)
for slug in slugs
]

# Two sets, because they answer different questions. ``dirty_pairs`` is
# what moved, which is what the summary line reports. ``pending_pairs`` is
# what still needs recalculating: the run already did that for every member
# it deleted from, chunk by chunk, and repeating them would be another pass
# per member for the same answer.
dirty_pairs = set()
pending_pairs = set()
for result in results:
self.stdout.write(f" {result.slug}: {result.describe()}")
achievement_id = achievements[result.slug].pk
dirty_pairs.update((user_id, achievement_id) for user_id in result.changed)
pending_pairs.update(
(user_id, achievement_id) for user_id in result.outstanding()
)

if not dry_run:
for user_id, achievement_id in pending_pairs:
recalculate_badges(user_id, achievement_id)

added = sum(result.added for result in results)
removed = sum(result.removed for result in results if not result.refused)
lead = "Would add" if dry_run else "Added"
tail = "remove" if dry_run else "removed"
self.stdout.write(
self.style.SUCCESS(
f"Done. {lead} {added} and {tail} {removed} grant(s) across "
f"{len(dirty_pairs)} (user, achievement) pair(s)."
)
)
refused = [result.slug for result in results if result.refused]
if refused:
# Loud, and on stderr: a refusal means a source read empty, which is
# a data problem outliving this command.
self.stderr.write(
"Refused to remove anything for: "
f"{', '.join(refused)}. The source(s) yielded nothing while grants "
"exist. Investigate the source, or pass --allow-empty if the "
"emptiness is real."
)

def _resolve_users(self, identifiers):
"""Turn ``--user`` values into primary keys, or fail listing the strays.

Accepts an email or a primary key because both are how a member gets
named in practice - an email from a bug report, an id from an admin URL.
"""
if not identifiers:
return None

user_ids = set()
unknown = []
for identifier in identifiers:
lookup = (
{"pk": int(identifier)}
if identifier.isdigit()
else {"email__iexact": identifier}
)
pk = User.objects.filter(**lookup).values_list("pk", flat=True).first()
if pk is None:
unknown.append(identifier)
else:
user_ids.add(pk)

if unknown:
raise CommandError(f"No such member(s): {', '.join(unknown)}.")
return user_ids
Loading
Loading