Skip to content

Story #2493 - Add achievement and badge models with badge recalculation - #2569

Open
herzog0 wants to merge 8 commits into
developfrom
teo/2493-badges-foundation
Open

Story #2493 - Add achievement and badge models with badge recalculation#2569
herzog0 wants to merge 8 commits into
developfrom
teo/2493-badges-foundation

Conversation

@herzog0

@herzog0 herzog0 commented Aug 3, 2026

Copy link
Copy Markdown
Collaborator

Issue: #2493

Summary & Context

The data model for achievements and badges, plus the single function allowed to derive badge
state from it. Nothing in this PR is user-visible: no admin, no ingestion, no rendering. It is
the layer everything else in the stack stands on.

The rule the whole feature rests on is that UserAchievement is the only fact and UserBadge
is derived from it
. services.recalculate_badges is the only writer of UserBadge, it both
awards and revokes so the count-vs-threshold invariant always holds, and it is idempotent.

  • Figma link: n/a - no UI in this PR
  • Link to components/page: n/a

Changes

  • badges app: Achievement, UserAchievement, Badge, BadgeTier, UserBadge, with the
    audit fields (granted_by, invalidated_by, revoked_by, notes) and the constraints that
    make repeated ingestion safe - notably unique_automatic_user_achievement_source and
    unique_active_badgetier_per_rank.
  • badges/enums.py: AchievementSlug, BadgeLabel, TierRank. TierRank is ordered by
    declaration and that order is the primary ladder
    ; thresholds are only the arithmetic behind
    one rung, and are not comparable across badges or even reliably within one.
  • badges/catalogue.py + migration 0002: the eight achievements, eight badges and forty tiers
    from the Boost mapping spreadsheet. A bootstrap fixture, not a live source of truth - a re-seed
    never overwrites a threshold staff have tuned, and never resurrects a rank they retired.
  • badges/services.py: recalculate_badges, achievement_pairs, recalculate_many, the tier
    lifecycle (deactivate_tier / reactivate_tier / replace_tier) and
    discard_source_achievements.
  • badges/signals.py: achievement create / invalidate / delete recalculate the one member; a
    tier change goes through recalculate_achievement_task because it affects everybody.
  • recalculate_badges management command.
  • Removes the dead users.Badge model and its User.badges M2M (migration
    users/0026). It has to be in this PR: UserBadge.related_name="badges" collides with the old
    accessor, and Django's system check fails while both exist.

‼️ Risks & Considerations ‼️

  • Two things land unused here and get their callers later in the PR stack:
    discard_source_achievements (used by Setup Forum using django-machina #2, the review importer) and the
    grant_from_source test helper (used by Setup Forum using django-machina #2 and Version app and models #3). Their docstrings forward-reference
    badges.sources, which arrives in Version app and models #3. That is the stack, not a mistake.
  • Grandfathering is intentional and load-bearing. Retiring a tier keeps the badges already
    awarded against it, and replace_tier exists so retuning a threshold never revokes a member
    who only ever met the old one. test_grandfathering_threshold_change_does_not_revoke and
    test_deactivating_tier_preserves_existing_user_badges pin it. Please do not "fix" it.
  • The seeded thresholds are the original spreadsheet values, not the Notion revised set. This
    blocks nothing here, but it must be settled before the first production backfill (PR Option for users to receive an email for forum thread replies quickly #13),
    because backfill awards badges against whatever thresholds are live at that moment.
  • users.Badge had no rows in production and no reader other than the placeholder
    User.badge_url, which this PR leaves alone.

Screenshots

A summary of the data model
image

Peer-review testing steps

docker compose up
just migrate          # applies badges 0001 + 0002 and users 0026
docker compose exec celery-worker pytest -q badges/

Then in a shell (just manage shell), check the catalogue seeded:

from badges.models import Achievement, Badge, BadgeTier
Achievement.objects.count(), Badge.objects.count(), BadgeTier.objects.count()
# -> (8, 8, 40)

You do not call recalculate_badges to exercise it. Creating a UserAchievement fires it
through post_save, so the test is "make grants, watch badges appear". Paste the whole block:

from django.contrib.auth import get_user_model
from badges.models import Achievement, Badge, BadgeTier, UserAchievement, UserBadge

me = get_user_model().objects.first()
ach = Achievement.objects.get(slug="code-commits")
badge = Badge.objects.get(achievement=ach)

# read the ladder rather than assuming the numbers
ladder = badge.tiers.filter(is_active=True).order_by("threshold")
list(ladder.values_list("rank", "threshold"))

# make exactly enough grants to clear the lowest rung
bronze = ladder.first()
for _ in range(bronze.threshold):
    UserAchievement.objects.create(user=me, achievement=ach, source_type="manual")

list(UserBadge.objects.filter(user=me).values_list("tier__rank", "revoked_at"))
# -> [('bronze', None)]

Keep adding grants and the rank climbs. Five things worth poking at:

  1. Idempotence - just manage recalculate_badges twice changes no rows the second time.
  2. The invariant both ways - invalidating a grant revokes the badge, it does not merely stop
    awarding it; revalidating brings it back.
  3. Grandfathering - raising a threshold in place makes the holder lose the badge. That is the
    whole reason replace_tier exists and the field is read-only in PR version app, test and black fixes #6.
  4. No demotion - shift a ladder up and a gold holder is not awarded the new bronze they now clear
    (test_recalculation_refuses_a_rank_below_one_already_held).
  5. Why achievement_pairs unions grants and badges - a bulk update() fires no signal, so
    badges survive until a rebuild. Same for a deleted UserBadge row: the rebuild restores it.

2, 5 and 3 in the shell, in that order - 3 retunes the ladder, so it has to be last. Each block
leaves the state it started with, so 2 and 5 can be re-run freely:

# 2 - the invariant both ways
ua = UserAchievement.objects.filter(user=me, achievement=ach).first()
ua.is_valid = False
ua.save()
list(UserBadge.objects.filter(user=me).values_list("tier__rank", "revocation_source"))
# -> [('bronze', 'cascade')]     revoked, not merely un-awarded

ua.is_valid = True
ua.save()
UserBadge.objects.filter(user=me, revoked_at__isnull=True).exists()    # -> True, back
# 5 - why achievement_pairs unions grants *and* badges
from badges.services import achievement_pairs, recalculate_many

UserAchievement.objects.filter(user=me, achievement=ach).update(is_valid=False)   # no signal
UserBadge.objects.filter(user=me, revoked_at__isnull=True).exists()    # -> True, still held
recalculate_many(achievement_pairs())                                  # the rebuild
UserBadge.objects.filter(user=me, revoked_at__isnull=True).exists()    # -> False, now revoked

UserAchievement.objects.filter(user=me, achievement=ach).update(is_valid=True)
recalculate_many(achievement_pairs())                                  # -> held again
# 3 - grandfathering, the WRONG way, on purpose. Do this last: it permanently retunes the ladder.
from badges.services import recalculate_badges

held = UserBadge.objects.filter(user=me, revoked_at__isnull=True).first().tier
held.threshold += 100
held.save()                        # never do this outside a shell
recalculate_badges(me.pk, ach.pk)
UserBadge.objects.filter(user=me, revoked_at__isnull=False).exists()   # -> True, they lost it

The supported path is replace_tier(tier), which retires the old rung and adds the replacement so
holders keep what they earned - test_replace_tier_keeps_the_old_tiers_holders.

Three things that look like bugs and are not: recalculate_badges takes ids, and a stale one
counts zero rather than raising; a tier change needs a running worker (it goes through a task on
commit, because a tier affects everybody); and bulk paths skip the signals by design, which is why
PR #3's engine recalculates explicitly.

The contested decisions are written down as tests rather than prose -
test_recalculation_refuses_a_rank_below_one_already_held,
test_grandfathering_threshold_change_does_not_revoke,
test_deactivating_tier_preserves_existing_user_badges,
test_manual_revocation_survives_recalculation,
test_re_earn_after_cascade_revocation_when_count_sufficient,
test_achievement_pairs_includes_badge_only_pairs, and
test_recalculation_cost_does_not_grow_with_tiers for the one performance claim.

Not testable here, on purpose: no admin, so nothing can be granted through a UI; no ingestion, so
every grant is one you typed; no display, so the profile keeps its placeholder medal. And
discard_source_achievements and the grant_from_source test helper have no callers in this PR -
they arrive with #2 and #3.

Self-review Checklist

  • Link this PR to the related GitHub Project ticket

Backend

  • Black + Ruff clean
  • New models include migrations; makemigrations --check clean

Summary by CodeRabbit

  • New Features

    • Added an achievements and badges system with configurable tiers, thresholds, rankings, and audit history.
    • Badges are automatically awarded, updated, restored, or revoked as achievement and tier status changes.
    • Added a catalogue of predefined achievements and badges.
    • Added a command and background processing support to recalculate badge status.
  • Bug Fixes

    • Added safeguards for invalid, deleted, retired, and manually revoked achievements and badges.
  • Tests

    • Added comprehensive coverage for awarding, revocation, tier management, seeding, signals, and recalculation.

@coderabbitai

coderabbitai Bot commented Aug 3, 2026

Copy link
Copy Markdown

Review Change Stack

📝 Walkthrough

Walkthrough

Adds the badges Django app with typed catalogue data, persistent achievement and badge models, seeded tiers, reconciliation services, signal and task integration, a recalculation command, tests, and removal of the legacy users.Badge relationship.

Changes

Badges domain

Layer / File(s) Summary
Domain contracts and persistence
badges/enums.py, badges/models.py, badges/migrations/0001_initial.py, badges/tests/test_enums.py, badges/tests/test_models.py
Adds typed achievement slugs, badge labels, tier ordering, achievement records, badge tiers, user achievements, and user badges. Adds validation, audit fields, relationships, and database constraints.
Catalogue bootstrap
badges/seed_data.py, badges/migrations/0002_seed_achievements_and_badges.py, badges/tests/fixtures.py, badges/tests/test_seed_data.py
Defines eight seeded achievements with badge labels and tier thresholds. Adds idempotent seeding that preserves retired and replacement tiers. Adds catalogue integrity and seeding tests.
Reconciliation and event processing
badges/services.py, badges/signals.py, badges/tasks.py, badges/management/commands/recalculate_badges.py, badges/tests/fixtures.py, badges/tests/test_services.py, badges/tests/test_signals.py, badges/tests/test_commands.py
Adds transactional badge recalculation, tier lifecycle operations, revocation auditing, achievement-pair discovery, signals, Celery processing, and a full rebuild command. Tests cover awarding, revocation, re-earning, tier changes, scoping, and idempotency.
Application integration and migration
badges/apps.py, config/settings.py, conftest.py, users/models.py, users/migrations/0026_remove_user_badges_delete_badge.py
Registers the badges app and signals, loads shared pytest fixtures, and removes the legacy Badge model and User.badges field.

Estimated code review effort: 4 (Complex) | ~60 minutes

Sequence Diagram(s)

sequenceDiagram
  participant UserAchievement
  participant badges_signals
  participant badges_tasks
  participant badges_services
  participant UserBadge
  UserAchievement->>badges_signals: save or delete achievement
  badges_signals->>badges_tasks: schedule achievement recalculation
  badges_tasks->>badges_services: recalculate_many(achievement_pairs)
  badges_services->>UserBadge: award, restore, or revoke badge tiers
Loading

Possibly related issues

  • Issue 2493: The PR implements the badges app, catalogue, reconciliation service, signals, migrations, seeding, and removal of the legacy users.Badge model.
🚥 Pre-merge checks | ✅ 5
✅ Passed checks (5 passed)
Check name Status Explanation
Title check ✅ Passed The title accurately describes the main change: introduction of achievement and badge models with recalculation logic, which is the foundation of story #2493.
Description check ✅ Passed The description is comprehensive and addresses the template's requirements. It includes issue reference, summary and context, changes, risks and considerations, and a self-review checklist. It exceeds minimum expectations with detailed explanations, testing steps, and design decisions.
Docstring Coverage ✅ Passed Docstring coverage is 95.10% which is sufficient. The required threshold is 80.00%.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.
✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch teo/2493-badges-foundation

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands.

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 2

🧹 Nitpick comments (15)
badges/models.py (2)

157-162: 🗄️ Data Integrity & Integration | 🔵 Trivial | ⚡ Quick win

Consider PROTECT instead of CASCADE for Badge.achievement.

UserBadge.tier uses PROTECT to keep the record of why a member earned a badge. That protection is bypassed by this chain: deleting an Achievement cascades to Badge, then to BadgeTier and to every UserBadge row, including revoked rows kept for audit. Achievement is admin-editable, so a single admin delete can erase the badge history.

PROTECT on Badge.achievement (and on BadgeTier.badge) makes the failure loud instead of destructive.

♻️ Proposed change
     achievement = models.ForeignKey(
         Achievement,
-        on_delete=models.CASCADE,
+        on_delete=models.PROTECT,
         related_name="badges",
         help_text=_("Which achievement type feeds this badge."),
     )

UserAchievement.achievement cascading is a separate decision; the fact rows arguably follow the achievement type. Confirm the intended deletion policy before changing both.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/models.py` around lines 157 - 162, Update the Badge.achievement
ForeignKey to use Django’s PROTECT deletion policy instead of CASCADE,
preserving badge, tier, and UserBadge audit history when an Achievement is
deleted. Do not change UserAchievement.achievement; handle only the
Badge.achievement relationship shown in the diff.

331-333: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use UniqueConstraint for the composite uniqueness rule.

In Django 6.0, UniqueConstraint is the recommended replacement for unique_together. Since badges/models.py already uses UniqueConstraint elsewhere, move this rule to Meta.constraints and regenerate badges/migrations/0001_initial.py so the model schema appears as AddConstraint instead of AlterUniqueTogether.

♻️ Proposed change
     class Meta:
         ordering = ("-awarded_at",)
-        unique_together = ("badge", "user", "tier")
+        constraints = [
+            models.UniqueConstraint(
+                fields=["badge", "user", "tier"],
+                name="unique_user_badge_tier",
+            )
+        ]
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/models.py` around lines 331 - 333, Replace unique_together in the
affected model’s Meta with a matching UniqueConstraint in Meta.constraints,
preserving the badge/user/tier composite uniqueness. Regenerate
badges/migrations/0001_initial.py so the schema uses AddConstraint rather than
AlterUniqueTogether, following the existing UniqueConstraint pattern in
badges/models.py.
badges/tests/fixtures.py (1)

102-109: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Note: the badge fixture collides with catalogue on MAINTAINER.

The badge fixture creates a BadgeLabel.MAINTAINER badge with tiers 1/3/5. The catalogue fixture also seeds MAINTAINER, with 1/2/5/10/20, and badges/tests/test_catalogue.py (Lines 110 and 125) reaches for the MAINTAINER rows by label.

No test in this PR requests both fixtures, so nothing fails today. If a future test requests both, seed_catalogue finds the existing badge and bronze/silver/gold rows and skips them, and the resulting ladder is a silent mix of both fixtures. Using a label that the catalogue does not seed is not possible, because the catalogue covers every BadgeLabel. A short warning in the fixture docstring prevents the trap.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/fixtures.py` around lines 102 - 109, Update the badge fixture
docstring to warn that its MAINTAINER badge and 1/3/5 tiers collide with the
catalogue fixture’s seeded MAINTAINER data, causing a mixed ladder if both
fixtures are requested. Keep the fixture behavior unchanged and document that
tests should avoid combining these fixtures.
badges/migrations/0001_initial.py (1)

361-373: 🚀 Performance & Scalability | 🔵 Trivial

Consider covering indexes for the recalculation count query.

Badge recalculation counts valid UserAchievement rows per (user, achievement) and then reads UserBadge rows per (user, badge). The schema provides single-column FK indexes only, plus the partial unique index on automatic rows, whose leading column order does not serve a user + achievement + is_valid count.

Row counts are small today, so this is a forward-looking suggestion. If recalculation runs per achievement event, add composite indexes when the table grows:

class Meta:
    indexes = [
        models.Index(fields=["user", "achievement", "is_valid"]),
    ]

and, on UserBadge, an index on ("user", "badge").

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/migrations/0001_initial.py` around lines 361 - 373, Extend the
migration’s schema changes to add covering composite indexes for recalculation
lookups: index UserAchievement by user, achievement, and is_valid, and index
UserBadge by user and badge. Use the models.Index definitions in the relevant
model Meta configurations or equivalent migration operations, preserving the
existing unique constraint.
badges/tests/test_models.py (1)

62-65: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

Cover the revoked branch and the active() queryset.

The test asserts is_active for an un-revoked badge only. Neither is_active is False after revocation nor UserBadge.objects.active() is covered, and the revocation semantics carry the whole soft-delete design.

💚 Proposed additional tests
 def test_user_badge_is_active_property(badge, plain_user):
     tier = badge.tiers.get(rank=TierRank.BRONZE)
     ub = UserBadge.objects.create(badge=badge, user=plain_user, tier=tier)
     assert ub.is_active is True
+
+
+def test_user_badge_is_inactive_once_revoked(badge, plain_user):
+    """Setting revoked_at flips is_active and removes the row from active()."""
+    tier = badge.tiers.get(rank=TierRank.BRONZE)
+    ub = UserBadge.objects.create(badge=badge, user=plain_user, tier=tier)
+
+    ub.revoked_at = timezone.now()
+    ub.revocation_source = RevocationSource.MANUAL
+    ub.save()
+
+    ub.refresh_from_db()
+    assert ub.is_active is False
+    assert not UserBadge.objects.active().filter(pk=ub.pk).exists()

The new test needs RevocationSource in the import at Line 10.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_models.py` around lines 62 - 65, Extend
test_user_badge_is_active_property to revoke the created UserBadge using
RevocationSource, assert is_active is False afterward, and verify
UserBadge.objects.active() excludes the revoked record while retaining an
unrevoked record. Add RevocationSource to the test imports and cover both
active-queryset behavior and revocation semantics.
badges/catalogue.py (1)

145-155: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Optional: detect a badge whose achievement no longer matches the catalogue.

get_or_create(label=label, ...) keeps an existing Badge row untouched, which is the documented intent. It also means a Badge that points at a different Achievement than the catalogue declares stays silently wrong, and recalculation then counts the wrong achievement.

Consider logging a warning when the existing row disagrees, so the mismatch surfaces on the next deploy instead of as wrong badge counts.

♻️ Proposed change
         badge, _created = badge_model.objects.get_or_create(
             label=label,
             defaults={"achievement": achievement, "description": description},
         )
+        if badge.achievement_id != achievement.pk:
+            logger.warning(
+                "Badge %s points at achievement %s, catalogue expects %s.",
+                label,
+                badge.achievement_id,
+                achievement.pk,
+            )

The migration runs this with historical models, so keep the check read-only and never repoint the row automatically.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/catalogue.py` around lines 145 - 155, After get_or_create in the badge
catalogue setup, detect when the existing Badge’s achievement differs from the
catalogue’s achievement and log a warning identifying the mismatch. Keep the
check read-only: do not update or repoint the Badge, and preserve the existing
tier creation behavior.
badges/tests/test_services.py (2)

297-297: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Move the function-local imports to the module level.

Four imports sit inside test bodies. BadgeTier at Line 322 shadows the module-level import at Line 10 with the same symbol, which adds no value. ProtectedError, BadgeLabel, and ValidationError have no circular-import reason to stay local.

Also applies to: 321-322, 380-380

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_services.py` at line 297, Move the function-local imports
of ProtectedError, BadgeLabel, ValidationError, and BadgeTier in the affected
tests to the module-level imports, removing the redundant local BadgeTier
shadowing while preserving the existing test behavior.

305-314: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

The final assertion is trivially true without the badge fixture.

This test requests achievement and plain_user but not badge. No Badge or BadgeTier exists for the achievement, so UserBadge.objects.exists() returns False regardless of what recalculate_badges does. The test proves that stale ids do not raise, but not that they write nothing.

Add the badge fixture so the achievement has active tiers. The assertion then covers the documented claim that a missing user counts zero achievements.

💚 Proposed change
-def test_recalculation_noop_when_user_or_achievement_gone(achievement, plain_user):
+def test_recalculation_noop_when_user_or_achievement_gone(
+    achievement, badge, plain_user
+):
     """Stale ids are a no-op rather than a crash.
 
     A missing user simply counts zero achievements; a missing achievement has
     nothing to reconcile against, so it logs and returns.
     """
+    assert badge.tiers.filter(is_active=True).exists()
     recalculate_badges(99999999, achievement.pk)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_services.py` around lines 305 - 314, Add the existing badge
fixture to test_recalculation_noop_when_user_or_achievement_gone so the
achievement has active tiers before invoking recalculate_badges. Keep the
stale-ID calls and final UserBadge.objects.exists() assertion unchanged,
ensuring the test verifies no records are written when the user is missing.
badges/tests/test_signals.py (1)

94-97: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Assert that no task is queued, not only that the tier row is gone.

The docstring states that a tier cascaded away with its badge has no achievement left to visit. The assertion checks only that the BadgeTier row was deleted, which the cascade guarantees regardless of the handler. The early-return branch at badges/signals.py Line 62 is therefore not covered.

Capture the callback list and assert it is empty.

💚 Proposed assertion
-    with django_capture_on_commit_callbacks(execute=True):
+    with django_capture_on_commit_callbacks(execute=True) as callbacks:
         Badge.objects.filter(pk=other.pk).delete()
 
+    assert callbacks == []
     assert not BadgeTier.objects.filter(pk=tier.pk).exists()
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_signals.py` around lines 94 - 97, Update the deletion test
around django_capture_on_commit_callbacks to capture the queued callbacks, then
assert the callback list is empty after deleting the badge tier. Keep the
existing BadgeTier deletion assertion, and ensure this exercises the
early-return path in the badge signal handler.
badges/tasks.py (1)

13-16: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

Add a time limit and a retry policy to the sweep task.

The task visits every (user, achievement) pair for one achievement, so its runtime grows with the table. The bare @shared_task decorator applies no soft time limit, so a slow sweep can hold a worker slot indefinitely. The task is idempotent, so a retry is safe.

Logging the returned count also helps confirm that a queued sweep actually ran, because the count currently reaches the result backend only.

♻️ Proposed task options
-@shared_task
-def recalculate_achievement_task(achievement_id):
-    """Recalculate every (user, achievement) pair for one achievement type."""
-    return recalculate_many(achievement_pairs([achievement_id]))
+@shared_task(
+    autoretry_for=(Exception,),
+    retry_backoff=True,
+    max_retries=3,
+    soft_time_limit=600,
+    time_limit=660,
+)
+def recalculate_achievement_task(achievement_id):
+    """Recalculate every (user, achievement) pair for one achievement type."""
+    count = recalculate_many(achievement_pairs([achievement_id]))
+    logger.info(
+        "Recalculated %s pair(s) for achievement %s.", count, achievement_id
+    )
+    return count

Match the limits and the retry settings to the conventions used by the other task modules in the project.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tasks.py` around lines 13 - 16, Update the shared_task configuration
on recalculate_achievement_task to add a soft time limit and the project’s
standard retry policy, matching conventions used by other task modules. Capture
the return value from recalculate_many and log the completed sweep count before
returning it, preserving the existing task result.
badges/services.py (1)

49-59: 🗄️ Data Integrity & Integration | 🔵 Trivial | 💤 Low value

Consider wrapping the delete and recalculation in one transaction.

grants.delete() commits before the loop runs. If the process fails partway through the loop, some badge rows stay above their threshold with no achievement rows behind them. A single transaction.atomic() block around the delete and the loop keeps the two writes consistent.

Also note that QuerySet.delete() fires post_delete, and recalculate_on_achievement_delete in badges/signals.py already recalculates each pair. The explicit loop is therefore a second, idempotent pass. Keep it if you want protection against future fast-delete paths, otherwise it can go.

♻️ Proposed change to make the discard atomic
     content_type = ContentType.objects.get_for_model(model)
-    grants = UserAchievement.objects.filter(
-        source_content_type=content_type, source_object_id__in=object_ids
-    )
-    pairs = set(grants.values_list("user_id", "achievement_id"))
-    grants.delete()
-    for user_id, achievement_id in pairs:
-        recalculate_badges(user_id, achievement_id)
+    with transaction.atomic():
+        grants = UserAchievement.objects.filter(
+            source_content_type=content_type, source_object_id__in=object_ids
+        )
+        pairs = set(grants.values_list("user_id", "achievement_id"))
+        grants.delete()
+        for user_id, achievement_id in pairs:
+            recalculate_badges(user_id, achievement_id)
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/services.py` around lines 49 - 59, Wrap the grants.delete() call and
the subsequent recalculate_badges loop in a single transaction.atomic() block
within the surrounding function. Preserve the existing pair collection and
explicit recalculation unless intentionally removing the redundant pass,
ensuring partial deletion and recalculation cannot leave inconsistent badge
state.
badges/management/commands/recalculate_badges.py (1)

21-26: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Consider exposing the scoping filters that achievement_pairs already supports.

achievement_pairs accepts achievement_ids and user_ids, and badges/tests/test_services.py covers both. The command always sweeps the whole table. Adding --user and --achievement options lets an operator repair one member or one achievement type without a full sweep.

♻️ Proposed options
     help = "Recalculate UserBadge state for every (user, achievement) pair."
 
+    def add_arguments(self, parser):
+        """Allow a scoped rebuild instead of a full sweep."""
+        parser.add_argument("--user", type=int, action="append", dest="user_ids")
+        parser.add_argument(
+            "--achievement", type=int, action="append", dest="achievement_ids"
+        )
+
     def handle(self, *args, **options):
         """Recalculate each distinct (user, achievement) pair worth visiting."""
-        count = recalculate_many(achievement_pairs())
+        count = recalculate_many(
+            achievement_pairs(
+                achievement_ids=options["achievement_ids"],
+                user_ids=options["user_ids"],
+            )
+        )
🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/management/commands/recalculate_badges.py` around lines 21 - 26,
Update the handle method to accept --user and --achievement options, pass their
values as user_ids and achievement_ids to achievement_pairs, and preserve the
current full-table recalculation when either option is omitted. Keep the
existing success output and recalculate_many flow intact.
badges/signals.py (1)

57-65: 🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

Deduplicate the queued sweeps per transaction.

Each BadgeTier save or delete queues one recalculate_achievement_task for the whole achievement. A bulk tier edit therefore queues one full-achievement sweep per tier. The catalogue migration in this PR creates forty tiers in one transaction, so forty sweeps are queued at commit, and each one visits every (user, achievement) pair for its achievement.

replace_tier in badges/services.py also produces two saves per replacement, so a single threshold edit queues two sweeps.

The work is idempotent, so this is throughput, not correctness. Collect the achievement ids in a per-transaction set and queue one task per id at commit.

♻️ Sketch of a per-transaction dedupe
+def _queue_once(achievement_id):
+    """Queue at most one sweep per achievement per transaction."""
+    conn = transaction.get_connection()
+    pending = getattr(conn, "_badges_pending_sweeps", None)
+    if pending is None:
+        pending = conn._badges_pending_sweeps = set()
+
+        def flush(ids=pending):
+            conn._badges_pending_sweeps = None
+            for achievement_pk in ids:
+                recalculate_achievement_task.delay(achievement_pk)
+
+        transaction.on_commit(flush)
+    pending.add(achievement_id)
+
+
 `@receiver`([post_save, post_delete], sender=BadgeTier)
 def recalculate_on_tier_change(sender, instance, **kwargs):
@@
     if achievement_id is None:  # the badge cascaded away with the tier
         return
 
-    transaction.on_commit(lambda: recalculate_achievement_task.delay(achievement_id))
+    _queue_once(achievement_id)

Confirm the attribute lifetime against the connection object before adopting this exact form.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/signals.py` around lines 57 - 65, Update the BadgeTier save/delete
signal flow around recalculate_achievement_task to collect achievement IDs in a
per-transaction set associated with the database connection, rather than
registering one on_commit callback per change. Register a single commit callback
that queues one recalculation task for each collected ID, and verify the set is
scoped and reset correctly across transactions.
badges/tests/test_commands.py (2)

25-29: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Use the enum values instead of the raw strings.

_grant passes source_type="manual" as a literal, while badges/enums.py defines SourceType for this field. Line 40 asserts against the literal "library-authoring", while Line 34 grants through AchievementSlug.LIBRARY_AUTHORING. If the slug value changes, the grant follows the enum but the assertion does not, and the test fails for the wrong reason.

♻️ Proposed change
-from badges.enums import AchievementSlug
+from badges.enums import AchievementSlug, SourceType
@@
     return UserAchievement.objects.create(
         user=user,
         achievement=Achievement.objects.get(slug=slug),
-        source_type="manual",
+        source_type=SourceType.MANUAL,
     )
@@
     assert UserBadge.objects.filter(
-        user=plain_user, badge__achievement__slug="library-authoring"
+        user=plain_user,
+        badge__achievement__slug=AchievementSlug.LIBRARY_AUTHORING,
     ).exists()

Confirm the SourceType member name in badges/enums.py before applying this.

Also applies to: 39-41

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_commands.py` around lines 25 - 29, Update the test helper
_grant and the related assertions to use the appropriate SourceType enum member
from badges/enums.py instead of raw "manual" and "library-authoring" strings,
confirming the exact member name before applying the change. Keep the existing
achievement slug behavior unchanged.

69-70: 📐 Maintainability & Code Quality | 🔵 Trivial | 💤 Low value

Avoid _raw_delete in the tests that simulate signal-free UserAchievement deletion. QuerySet._raw_delete is a private Django API, so these tests can break on Django changes. Use a dedicated test helper that clears post_delete temporarily and calls normal delete(), or use supported raw SQL inside django.test.TestCase.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_commands.py` around lines 69 - 70, Replace the private
QuerySet._raw_delete usage in the signal-free UserAchievement deletion setup in
badges/tests/test_commands.py lines 69-70 and badges/tests/test_services.py line
448 with a supported approach: add or reuse a dedicated test helper that
temporarily disconnects post_delete receivers and invokes normal delete(), or
use supported raw SQL through django.test.TestCase. Apply the same behavior at
both sites while ensuring post_delete receivers are restored afterward.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@badges/models.py`:
- Around line 128-141: The Meta constraints for the badge model must enforce
that automatic grants include both source_content_type and source_object_id. Add
a CheckConstraint alongside unique_automatic_user_achievement_source requiring
both fields to be non-null whenever source_type is "automatic", and update the
corresponding migration to create the same database constraint.

In `@badges/services.py`:
- Around line 182-214: Serialize recalculations for each (user_id, achievement)
pair inside recalculate_badges’s atomic transaction before reading valid_count
or held rows. Use the established PostgreSQL advisory-lock approach keyed by
both identifiers, or lock the existing user-badge rows with select_for_update
while ensuring the no-row first-award case is also serialized; keep all
derived-state reads and award/revoke writes within that protected section.

---

Nitpick comments:
In `@badges/catalogue.py`:
- Around line 145-155: After get_or_create in the badge catalogue setup, detect
when the existing Badge’s achievement differs from the catalogue’s achievement
and log a warning identifying the mismatch. Keep the check read-only: do not
update or repoint the Badge, and preserve the existing tier creation behavior.

In `@badges/management/commands/recalculate_badges.py`:
- Around line 21-26: Update the handle method to accept --user and --achievement
options, pass their values as user_ids and achievement_ids to achievement_pairs,
and preserve the current full-table recalculation when either option is omitted.
Keep the existing success output and recalculate_many flow intact.

In `@badges/migrations/0001_initial.py`:
- Around line 361-373: Extend the migration’s schema changes to add covering
composite indexes for recalculation lookups: index UserAchievement by user,
achievement, and is_valid, and index UserBadge by user and badge. Use the
models.Index definitions in the relevant model Meta configurations or equivalent
migration operations, preserving the existing unique constraint.

In `@badges/models.py`:
- Around line 157-162: Update the Badge.achievement ForeignKey to use Django’s
PROTECT deletion policy instead of CASCADE, preserving badge, tier, and
UserBadge audit history when an Achievement is deleted. Do not change
UserAchievement.achievement; handle only the Badge.achievement relationship
shown in the diff.
- Around line 331-333: Replace unique_together in the affected model’s Meta with
a matching UniqueConstraint in Meta.constraints, preserving the badge/user/tier
composite uniqueness. Regenerate badges/migrations/0001_initial.py so the schema
uses AddConstraint rather than AlterUniqueTogether, following the existing
UniqueConstraint pattern in badges/models.py.

In `@badges/services.py`:
- Around line 49-59: Wrap the grants.delete() call and the subsequent
recalculate_badges loop in a single transaction.atomic() block within the
surrounding function. Preserve the existing pair collection and explicit
recalculation unless intentionally removing the redundant pass, ensuring partial
deletion and recalculation cannot leave inconsistent badge state.

In `@badges/signals.py`:
- Around line 57-65: Update the BadgeTier save/delete signal flow around
recalculate_achievement_task to collect achievement IDs in a per-transaction set
associated with the database connection, rather than registering one on_commit
callback per change. Register a single commit callback that queues one
recalculation task for each collected ID, and verify the set is scoped and reset
correctly across transactions.

In `@badges/tasks.py`:
- Around line 13-16: Update the shared_task configuration on
recalculate_achievement_task to add a soft time limit and the project’s standard
retry policy, matching conventions used by other task modules. Capture the
return value from recalculate_many and log the completed sweep count before
returning it, preserving the existing task result.

In `@badges/tests/fixtures.py`:
- Around line 102-109: Update the badge fixture docstring to warn that its
MAINTAINER badge and 1/3/5 tiers collide with the catalogue fixture’s seeded
MAINTAINER data, causing a mixed ladder if both fixtures are requested. Keep the
fixture behavior unchanged and document that tests should avoid combining these
fixtures.

In `@badges/tests/test_commands.py`:
- Around line 25-29: Update the test helper _grant and the related assertions to
use the appropriate SourceType enum member from badges/enums.py instead of raw
"manual" and "library-authoring" strings, confirming the exact member name
before applying the change. Keep the existing achievement slug behavior
unchanged.
- Around line 69-70: Replace the private QuerySet._raw_delete usage in the
signal-free UserAchievement deletion setup in badges/tests/test_commands.py
lines 69-70 and badges/tests/test_services.py line 448 with a supported
approach: add or reuse a dedicated test helper that temporarily disconnects
post_delete receivers and invokes normal delete(), or use supported raw SQL
through django.test.TestCase. Apply the same behavior at both sites while
ensuring post_delete receivers are restored afterward.

In `@badges/tests/test_models.py`:
- Around line 62-65: Extend test_user_badge_is_active_property to revoke the
created UserBadge using RevocationSource, assert is_active is False afterward,
and verify UserBadge.objects.active() excludes the revoked record while
retaining an unrevoked record. Add RevocationSource to the test imports and
cover both active-queryset behavior and revocation semantics.

In `@badges/tests/test_services.py`:
- Line 297: Move the function-local imports of ProtectedError, BadgeLabel,
ValidationError, and BadgeTier in the affected tests to the module-level
imports, removing the redundant local BadgeTier shadowing while preserving the
existing test behavior.
- Around line 305-314: Add the existing badge fixture to
test_recalculation_noop_when_user_or_achievement_gone so the achievement has
active tiers before invoking recalculate_badges. Keep the stale-ID calls and
final UserBadge.objects.exists() assertion unchanged, ensuring the test verifies
no records are written when the user is missing.

In `@badges/tests/test_signals.py`:
- Around line 94-97: Update the deletion test around
django_capture_on_commit_callbacks to capture the queued callbacks, then assert
the callback list is empty after deleting the badge tier. Keep the existing
BadgeTier deletion assertion, and ensure this exercises the early-return path in
the badge signal handler.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: c0b2e490-014d-4d6a-a39b-f0057aa1e953

📥 Commits

Reviewing files that changed from the base of the PR and between 3cabc15 and 6d29b7a.

📒 Files selected for processing (26)
  • badges/__init__.py
  • badges/apps.py
  • badges/catalogue.py
  • badges/enums.py
  • badges/management/__init__.py
  • badges/management/commands/__init__.py
  • badges/management/commands/recalculate_badges.py
  • badges/migrations/0001_initial.py
  • badges/migrations/0002_seed_achievements_and_badges.py
  • badges/migrations/__init__.py
  • badges/models.py
  • badges/services.py
  • badges/signals.py
  • badges/tasks.py
  • badges/tests/__init__.py
  • badges/tests/fixtures.py
  • badges/tests/test_catalogue.py
  • badges/tests/test_commands.py
  • badges/tests/test_enums.py
  • badges/tests/test_models.py
  • badges/tests/test_services.py
  • badges/tests/test_signals.py
  • config/settings.py
  • conftest.py
  • users/migrations/0026_remove_user_badges_delete_badge.py
  • users/models.py

Comment thread badges/models.py
Comment thread badges/services.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (2)
badges/services.py (2)

73-76: 🚀 Performance & Scalability | 🟠 Major | 🏗️ Heavy lift

Suppress per-row recalculation during bulk source disposal.

grants.delete() emits post_delete for every UserAchievement. badges/signals.py recalculates each row, then this loop recalculates each distinct pair again. Deleting many grants for one pair causes repeated identical reconciliation work.

Add a scoped signal-suppression mechanism for this bulk operation. Recalculate the collected distinct pairs once after deletion.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/services.py` around lines 73 - 76, Update the bulk disposal flow
around grants.delete() in the relevant service function to use a scoped
suppression mechanism for UserAchievement post-delete recalculation, preventing
badges/signals.py from reconciling each deleted row. Keep collecting distinct
user_id/achievement_id pairs, perform the deletion within the suppression scope,
then invoke recalculate_badges once for each collected pair afterward.

108-121: 🎯 Functional Correctness | 🟠 Major | ⚡ Quick win

Validate the replacement before creating it.

BadgeTier.objects.create() bypasses BadgeTier.clean(), so replace_tier() persists only against the one-active-rank database constraint. Add a validated bulk replacement API that checks the complete proposed ladder, including existing active siblings, before retiring/creating tiers.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/services.py` around lines 108 - 121, The replace_tier flow must
validate the complete proposed badge ladder before mutating storage, rather than
relying on BadgeTier.objects.create and only the database constraint. Add or
reuse a validated bulk replacement API that includes existing active sibling
tiers and the replacement values, invoke it from replace_tier before
deactivate_tier, and preserve the atomic retire/create behavior only after
validation succeeds.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Outside diff comments:
In `@badges/services.py`:
- Around line 73-76: Update the bulk disposal flow around grants.delete() in the
relevant service function to use a scoped suppression mechanism for
UserAchievement post-delete recalculation, preventing badges/signals.py from
reconciling each deleted row. Keep collecting distinct user_id/achievement_id
pairs, perform the deletion within the suppression scope, then invoke
recalculate_badges once for each collected pair afterward.
- Around line 108-121: The replace_tier flow must validate the complete proposed
badge ladder before mutating storage, rather than relying on
BadgeTier.objects.create and only the database constraint. Add or reuse a
validated bulk replacement API that includes existing active sibling tiers and
the replacement values, invoke it from replace_tier before deactivate_tier, and
preserve the atomic retire/create behavior only after validation succeeds.

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: cf2123c6-3113-4d25-9ea1-d194608ceaf1

📥 Commits

Reviewing files that changed from the base of the PR and between 6d29b7a and 9872a27.

📒 Files selected for processing (13)
  • badges/catalogue.py
  • badges/enums.py
  • badges/management/commands/recalculate_badges.py
  • badges/migrations/0001_initial.py
  • badges/models.py
  • badges/services.py
  • badges/signals.py
  • badges/tasks.py
  • badges/tests/fixtures.py
  • badges/tests/test_commands.py
  • badges/tests/test_models.py
  • badges/tests/test_services.py
  • badges/tests/test_signals.py
🚧 Files skipped from review as they are similar to previous changes (9)
  • badges/tasks.py
  • badges/management/commands/recalculate_badges.py
  • badges/enums.py
  • badges/migrations/0001_initial.py
  • badges/signals.py
  • badges/catalogue.py
  • badges/tests/test_signals.py
  • badges/tests/fixtures.py
  • badges/tests/test_commands.py

@coderabbitai coderabbitai Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
badges/seed_data.py (1)

135-151: 🗄️ Data Integrity & Integration | 🟠 Major | 🏗️ Heavy lift

Freeze the data used by migration 0002.

badges/migrations/0002_seed_achievements_and_badges.py calls seed_catalogue from this mutable module. Django replays migrations on fresh databases, so later edits to SEED_CATALOGUE change the output of the historical migration. Fresh and upgraded databases can then diverge. A later replacement migration can also fail to find the threshold it expects.

Keep an immutable copy of the initial catalogue in migration 0002, or call a versioned helper that will not change. Use a new data migration for every catalogue change.

Also applies to: 1-18

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/seed_data.py` around lines 135 - 151, Freeze the catalogue consumed by
seed_catalogue in migration 0002 by moving the initial data into an immutable
migration-local constant or versioned helper, rather than reading the mutable
SEED_CATALOGUE. Keep seed_catalogue’s existing idempotent creation behavior and
require future catalogue changes to use new data migrations.
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In `@badges/tests/test_seed_data.py`:
- Around line 27-29: Update the import guard test around SEED_DATA_IMPORTERS,
IMPORT_FORMS, and its scanning logic to parse Python files with ast.Import and
ast.ImportFrom nodes instead of searching raw text. Resolve relative imports
such as from .seed_data and from . import seed_data against the badges package,
then assert the resolved module is not an offender while ignoring comments and
docstrings.

---

Outside diff comments:
In `@badges/seed_data.py`:
- Around line 135-151: Freeze the catalogue consumed by seed_catalogue in
migration 0002 by moving the initial data into an immutable migration-local
constant or versioned helper, rather than reading the mutable SEED_CATALOGUE.
Keep seed_catalogue’s existing idempotent creation behavior and require future
catalogue changes to use new data migrations.
🪄 Autofix

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: defaults

Review profile: CHILL

Plan: Pro Plus

Run ID: 17d1eb4d-f110-4c1b-a30a-9f0a37bdeee5

📥 Commits

Reviewing files that changed from the base of the PR and between 9872a27 and 3acf533.

📒 Files selected for processing (5)
  • badges/enums.py
  • badges/migrations/0002_seed_achievements_and_badges.py
  • badges/seed_data.py
  • badges/tests/fixtures.py
  • badges/tests/test_seed_data.py
🚧 Files skipped from review as they are similar to previous changes (2)
  • badges/migrations/0002_seed_achievements_and_badges.py
  • badges/tests/fixtures.py

Comment on lines +27 to +29
SEED_DATA_IMPORTERS = ("badges/seed_data.py", "badges/migrations/", "badges/tests/")
IMPORT_FORMS = ("badges.seed_data", "from badges import seed_data")
UNSEARCHED_DIRS = {".git", ".venv", "venv", "node_modules", "__pycache__", "media"}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Parse imports instead of scanning raw text.

The guard only searches absolute import forms. A runtime module can use from .seed_data import ... or from . import seed_data and pass this test. The substring scan can also flag comments and docstrings.

Parse ast.Import and ast.ImportFrom nodes, and resolve relative imports from the badges package before asserting that no offenders exist.

Also applies to: 42-58

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@badges/tests/test_seed_data.py` around lines 27 - 29, Update the import guard
test around SEED_DATA_IMPORTERS, IMPORT_FORMS, and its scanning logic to parse
Python files with ast.Import and ast.ImportFrom nodes instead of searching raw
text. Resolve relative imports such as from .seed_data and from . import
seed_data against the badges package, then assert the resolved module is not an
offender while ignoring comments and docstrings.

@julhoang
julhoang self-requested a review August 4, 2026 22:25
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

Task: Model achievements and badges, and derive badge state from a single function

1 participant