diff --git a/badges/__init__.py b/badges/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/badges/admin.py b/badges/admin.py
new file mode 100644
index 00000000..2d99ba12
--- /dev/null
+++ b/badges/admin.py
@@ -0,0 +1,165 @@
+from django.contrib import admin
+from django.utils.safestring import mark_safe
+from django.templatetags.static import static
+
+from .models import Badge, UserBadge
+
+
+class NFTUnapprovedFilter(admin.SimpleListFilter):
+ title = "NFT approval status"
+ parameter_name = "nft_approval"
+
+ def lookups(self, request, model_admin):
+ return (
+ ("unapproved", "NFT enabled - Not approved"),
+ ("approved", "NFT enabled - Approved"),
+ ("not_nft", "Not NFT enabled"),
+ )
+
+ def queryset(self, request, queryset):
+ if self.value() == "unapproved":
+ return queryset.filter(badge__is_nft_enabled=True, approved=False)
+ if self.value() == "approved":
+ return queryset.filter(badge__is_nft_enabled=True, approved=True)
+ if self.value() == "not_nft":
+ return queryset.filter(badge__is_nft_enabled=False)
+
+
+@admin.register(Badge)
+class BadgeAdmin(admin.ModelAdmin):
+ list_display = (
+ "calculator_class_reference",
+ "image_preview",
+ "display_name",
+ "title",
+ "created",
+ "updated",
+ )
+ search_fields = (
+ "title",
+ "display_name",
+ "calculator_class_reference",
+ "description",
+ )
+ list_filter = ("is_nft_enabled",)
+
+ readonly_fields = (
+ "description",
+ "title",
+ "display_name",
+ "calculator_class_reference",
+ "image_light",
+ "image_dark",
+ "image_small_light",
+ "image_small_dark",
+ "image_preview_large",
+ "created",
+ "updated",
+ )
+ fields = (
+ "calculator_class_reference",
+ "title",
+ "display_name",
+ "description",
+ "image_light",
+ "image_dark",
+ "image_small_light",
+ "image_small_dark",
+ "image_preview_large",
+ "created",
+ "updated",
+ )
+
+ class Media:
+ css = {"all": ("admin/css/badge_theme_images.css",)}
+
+ def has_add_permission(self, request):
+ """Disable adding badges through admin - they should be created via update_badges task."""
+ return False
+
+ @admin.display(description="Badge")
+ def image_preview(self, obj):
+ html = ""
+ if obj.image_light:
+ image_url_light = static(f"img/badges/{obj.image_light}")
+ html += f'
'
+ if obj.image_dark:
+ image_url_dark = static(f"img/badges/{obj.image_dark}")
+ html += f'
'
+ return mark_safe(html) if html else "-"
+
+ @admin.display(description="Badge Preview")
+ def image_preview_large(self, obj):
+ html = ""
+ if obj.image_light:
+ image_url = static(f"img/badges/{obj.image_light}")
+ html += (
+ "
Light mode:"
+ '
'
+ f'

'
+ "
"
+ )
+ if obj.image_dark:
+ image_url = static(f"img/badges/{obj.image_dark}")
+ html += (
+ 'Dark mode:'
+ '
'
+ f'

'
+ "
"
+ )
+ if obj.image_small_light:
+ image_url = static(f"img/badges/{obj.image_small_light}")
+ html += (
+ 'Small light mode:'
+ '
'
+ f'

'
+ "
"
+ )
+ if obj.image_small_dark:
+ image_url = static(f"img/badges/{obj.image_small_dark}")
+ html += (
+ 'Small dark mode:'
+ '
'
+ f'

'
+ "
"
+ )
+ return mark_safe(html) if html else "-"
+
+
+@admin.register(UserBadge)
+class UserBadgeAdmin(admin.ModelAdmin):
+ list_display = ("user", "badge", "created", "updated")
+ list_filter = (
+ NFTUnapprovedFilter,
+ "badge",
+ "created",
+ "badge__calculator_class_reference",
+ "badge__display_name",
+ )
+ search_fields = ("user__email", "user__display_name")
+ readonly_fields = ("badge", "grade", "unclaimed", "created", "updated")
+ fields = (
+ "user",
+ "badge",
+ "grade",
+ "approved",
+ "unclaimed",
+ "nft_minted",
+ "published",
+ "created",
+ "updated",
+ )
+ autocomplete_fields = ["user"]
+
+ def get_readonly_fields(self, request, obj=None):
+ readonly = list(super().get_readonly_fields(request, obj))
+ if obj:
+ # make these readonly if already True
+ if obj.approved:
+ readonly.append("approved")
+ if obj.nft_minted:
+ readonly.append("nft_minted")
+ # make nft_minted readonly if badge doesn't have NFT enabled
+ if not obj.badge.is_nft_enabled and "nft_minted" not in readonly:
+ readonly.append("nft_minted")
+ return readonly
diff --git a/badges/apps.py b/badges/apps.py
new file mode 100644
index 00000000..a9750e71
--- /dev/null
+++ b/badges/apps.py
@@ -0,0 +1,6 @@
+from django.apps import AppConfig
+
+
+class BadgesConfig(AppConfig):
+ default_auto_field = "django.db.models.BigAutoField"
+ name = "badges"
diff --git a/badges/calculators/__init__.py b/badges/calculators/__init__.py
new file mode 100644
index 00000000..86565cba
--- /dev/null
+++ b/badges/calculators/__init__.py
@@ -0,0 +1,26 @@
+import importlib
+import inspect
+import pkgutil
+from typing import Generator
+
+from badges.calculators.base_calculator import BaseCalculator
+
+
+def get_calculators() -> Generator[BaseCalculator]:
+ """
+ Discover and return all implemented calculator classes.
+
+ Returns:
+ List of calculator classes that inherit from BaseCalculator.
+ """
+ calculators_package = importlib.import_module("badges.calculators")
+ for importer, modname, ispkg in pkgutil.iter_modules(calculators_package.__path__):
+ if modname == "base_calculator":
+ continue
+
+ module = importlib.import_module(f"badges.calculators.{modname}")
+ # get all classes from the module
+ for name, obj in inspect.getmembers(module, inspect.isclass):
+ # we want subclasses of BaseCalculator, not BaseCalculator itself
+ if issubclass(obj, BaseCalculator) and obj is not BaseCalculator:
+ yield obj
diff --git a/badges/calculators/base_calculator.py b/badges/calculators/base_calculator.py
new file mode 100644
index 00000000..1fb5fa5e
--- /dev/null
+++ b/badges/calculators/base_calculator.py
@@ -0,0 +1,82 @@
+from abc import ABCMeta, abstractmethod
+from typing import Any
+
+from django.contrib.auth import get_user_model
+from django.utils.functional import cached_property
+
+User = get_user_model()
+
+
+class BaseCalculator(metaclass=ABCMeta):
+ """Base class for badge calculators.
+
+ Subclasses must define the following class attributes:
+ class_reference (str): Unique identifier matching the Badge model's calculator_class_reference field
+ title (str): Badge title displayed on hover
+ display_name (str): Badge name displayed on user profiles
+ description (str | None): Description of what the badge represents
+ badge_image_light (str): Path to light mode badge image, relative to static/img/badges/
+ badge_image_dark (str): Path to dark mode badge image, relative to static/img/badges/
+ badge_image_small_light (str): Path to small light mode badge image, relative to static/img/badges/
+ badge_image_small_dark (str): Path to small dark mode badge image, relative to static/img/badges/
+
+ Subclasses must also implement:
+ retrieve_data(): Returns data needed to calculate the badge
+ determine_achieved(): Returns whether the badge has been achieved
+ calculate_grade(): Returns the grade/level for the badge
+ """
+
+ class_reference: str = None # type: ignore[assignment]
+ title: str = None # type: ignore[assignment]
+ display_name: str = None # type: ignore[assignment]
+ description: str | None = None
+ badge_image_light: str = None # type: ignore[assignment]
+ badge_image_dark: str = None # type: ignore[assignment]
+ badge_image_small_light: str = None # type: ignore[assignment]
+ badge_image_small_dark: str = None # type: ignore[assignment]
+ is_nft_enabled: bool = False # type: ignore[assignment]
+
+ required_fields = (
+ "class_reference",
+ "title",
+ "display_name",
+ "badge_image_light",
+ "badge_image_dark",
+ "badge_image_small_light",
+ "badge_image_small_dark",
+ )
+
+ def __init__(self, user: User):
+ self.validate()
+ self.user = user
+ self.data = self.retrieve_data()
+
+ @classmethod
+ def validate(cls):
+ for field in cls.required_fields:
+ if not getattr(cls, field, None):
+ msg = f"'{field}' on the {cls.__name__} calculator class is not defined"
+ raise NotImplementedError(msg)
+
+ @cached_property
+ def achieved(self) -> bool:
+ return self.determine_achieved(self.data)
+
+ @cached_property
+ def grade(self) -> bool | None:
+ return self.calculate_grade(self.data)
+
+ @abstractmethod
+ def retrieve_data(self) -> dict[str, Any]:
+ """This method returns the data needed to generate the grade"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def determine_achieved(self, metrics: dict[str, Any]) -> bool:
+ """This method signifies that the badge has been achieved"""
+ raise NotImplementedError
+
+ @abstractmethod
+ def calculate_grade(self, metrics: dict[str, Any]) -> int | None:
+ """This method calculators the grade for the user based on passed in metrics"""
+ raise NotImplementedError
diff --git a/badges/calculators/github_maintainer.py b/badges/calculators/github_maintainer.py
new file mode 100644
index 00000000..2381e9ee
--- /dev/null
+++ b/badges/calculators/github_maintainer.py
@@ -0,0 +1,31 @@
+from textwrap import dedent
+
+from badges.calculators.base_calculator import BaseCalculator
+
+
+class GithubMaintainer(BaseCalculator):
+ class_reference = "github_maintainer"
+ title = "Active Library Maintainer"
+ display_name = "Library Maintainer"
+ description = dedent(
+ """
+ Awarded to users who continuously make contributions to the project via GitHub.
+ This badge recognizes active participation in the development community.
+ """
+ ).strip()
+ badge_image_light = "github_maintainer_light.svg"
+ badge_image_dark = "github_maintainer_dark.svg"
+ badge_image_small_light = "github_maintainer_light.svg"
+ badge_image_small_dark = "github_maintainer_dark.svg"
+ is_nft_enabled = False
+
+ def retrieve_data(self):
+ return {}
+
+ def determine_achieved(self, metrics):
+ # e.g. has made N contributions in X days to 1+ library
+ return True
+
+ def calculate_grade(self, metrics) -> int:
+ # e.g. number of libraries on which the user has made N contributions in X days
+ return 12
diff --git a/badges/calculators/library_creator.py b/badges/calculators/library_creator.py
new file mode 100644
index 00000000..9ef669a9
--- /dev/null
+++ b/badges/calculators/library_creator.py
@@ -0,0 +1,31 @@
+from textwrap import dedent
+
+from badges.calculators.base_calculator import BaseCalculator
+
+
+class LibraryCreator(BaseCalculator):
+ class_reference = "library_creator"
+ title = "Library Creator"
+ display_name = "Library Creator"
+ description = dedent(
+ """
+ Awarded to users who have created and published libraries. This badge recognizes
+ contributions to the C++ ecosystem through library development.
+ """
+ ).strip()
+ badge_image_light = "library_creator_light.svg"
+ badge_image_dark = "library_creator_dark.svg"
+ badge_image_small_light = "library_creator_light.svg"
+ badge_image_small_dark = "library_creator_dark.svg"
+ is_nft_enabled = True
+
+ def retrieve_data(self):
+ return {}
+
+ def determine_achieved(self, metrics):
+ # e.g. has created >1 libraries
+ return True
+
+ def calculate_grade(self, metrics) -> int:
+ # e.g. number of libraries created by the user
+ return 1
diff --git a/badges/management/__init__.py b/badges/management/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/badges/management/commands/__init__.py b/badges/management/commands/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/badges/management/commands/award_badges.py b/badges/management/commands/award_badges.py
new file mode 100644
index 00000000..d78b66bd
--- /dev/null
+++ b/badges/management/commands/award_badges.py
@@ -0,0 +1,31 @@
+import djclick as click
+from django.contrib.auth import get_user_model
+
+from badges.tasks import award_badges
+
+User = get_user_model()
+
+
+@click.command()
+@click.option(
+ "--user-email",
+ type=str,
+ default=None,
+ help="Specific user email to calculate badges for. If not provided, calculates for all users.",
+)
+def command(user_email):
+ """Calculate and award badges to users based on their contributions."""
+ user_id = None
+
+ if user_email:
+ try:
+ user_id = User.objects.values_list("id", flat=True).get(email=user_email)
+ except User.DoesNotExist:
+ click.secho(f"User with {user_email=} doesn't exist", fg="red")
+ return
+ if user_id:
+ click.secho(f"Calculating badges for {user_email}...", fg="green")
+ else:
+ click.secho("Calculating badges for all users...", fg="green")
+ award_badges.delay(user_id)
+ click.secho("Award badges task queued, output is in logging.", fg="green")
diff --git a/badges/management/commands/update_badges.py b/badges/management/commands/update_badges.py
new file mode 100644
index 00000000..734bec53
--- /dev/null
+++ b/badges/management/commands/update_badges.py
@@ -0,0 +1,14 @@
+import djclick as click
+
+from badges.tasks import update_badges
+
+
+@click.command()
+def command():
+ """Update or create Badge rows based on calculator classes.
+
+ Triggers the badge update task asynchronously via Celery.
+ """
+ click.secho("Triggering badges update task...", fg="green")
+ update_badges.delay()
+ click.secho("Badges update task queued, output is in logging.", fg="green")
diff --git a/badges/migrations/0001_initial.py b/badges/migrations/0001_initial.py
new file mode 100644
index 00000000..bb3a56fb
--- /dev/null
+++ b/badges/migrations/0001_initial.py
@@ -0,0 +1,153 @@
+# Generated by Django 5.2.8 on 2025-11-20 22:37
+
+import django.db.models.deletion
+from django.conf import settings
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+
+ initial = True
+
+ dependencies = [
+ migrations.swappable_dependency(settings.AUTH_USER_MODEL),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name="Badge",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ ("title", models.CharField(max_length=200, verbose_name="title")),
+ (
+ "display_name",
+ models.CharField(
+ blank=True, max_length=100, verbose_name="display name"
+ ),
+ ),
+ (
+ "description",
+ models.TextField(
+ blank=True,
+ help_text="Description of what this badge represents",
+ verbose_name="description",
+ ),
+ ),
+ (
+ "calculator_class_reference",
+ models.CharField(
+ help_text="lookup field for class_reference in badge Calculator implementations",
+ max_length=255,
+ unique=True,
+ verbose_name="calculator class reference",
+ ),
+ ),
+ (
+ "image_light",
+ models.CharField(
+ help_text="Path to badge image for light mode in static directory",
+ max_length=255,
+ verbose_name="image path (light mode)",
+ ),
+ ),
+ (
+ "image_dark",
+ models.CharField(
+ help_text="Path to badge image for dark mode in static directory",
+ max_length=255,
+ verbose_name="image path (dark mode)",
+ ),
+ ),
+ (
+ "image_small_light",
+ models.CharField(
+ help_text="Path to small badge image for light mode in static directory",
+ max_length=255,
+ verbose_name="small image path (light mode)",
+ ),
+ ),
+ (
+ "image_small_dark",
+ models.CharField(
+ help_text="Path to small badge image for dark mode in static directory",
+ max_length=255,
+ verbose_name="small image path (dark mode)",
+ ),
+ ),
+ ("is_nft_enabled", models.BooleanField(default=False)),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ ],
+ options={
+ "ordering": ["calculator_class_reference"],
+ },
+ ),
+ migrations.CreateModel(
+ name="UserBadge",
+ fields=[
+ (
+ "id",
+ models.BigAutoField(
+ auto_created=True,
+ primary_key=True,
+ serialize=False,
+ verbose_name="ID",
+ ),
+ ),
+ (
+ "grade",
+ models.IntegerField(
+ blank=True,
+ help_text="Grade or level of this badge for the user",
+ null=True,
+ ),
+ ),
+ ("approved", models.BooleanField(default=False)),
+ ("nft_minted", models.BooleanField(default=False)),
+ ("nft_transfer_url", models.TextField(blank=True, null=True)),
+ (
+ "unclaimed",
+ models.BooleanField(
+ default=False,
+ help_text="Default false, true when badge is_nft_enabled=True",
+ ),
+ ),
+ (
+ "published",
+ models.BooleanField(
+ default=False, help_text="Visible on the user's profile"
+ ),
+ ),
+ ("created", models.DateTimeField(auto_now_add=True)),
+ ("updated", models.DateTimeField(auto_now=True)),
+ (
+ "badge",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="user_badges",
+ to="badges.badge",
+ ),
+ ),
+ (
+ "user",
+ models.ForeignKey(
+ on_delete=django.db.models.deletion.CASCADE,
+ related_name="user_badges",
+ to=settings.AUTH_USER_MODEL,
+ ),
+ ),
+ ],
+ options={
+ "ordering": ["-created"],
+ "unique_together": {("user", "badge")},
+ },
+ ),
+ ]
diff --git a/badges/migrations/__init__.py b/badges/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/badges/models.py b/badges/models.py
new file mode 100644
index 00000000..12cd5030
--- /dev/null
+++ b/badges/models.py
@@ -0,0 +1,100 @@
+from django.conf import settings
+from django.db import models
+from django.utils.translation import gettext_lazy as _
+
+
+class Badge(models.Model):
+ """Badge that can be awarded to users."""
+
+ title = models.CharField(_("title"), max_length=200)
+ display_name = models.CharField(_("display name"), max_length=100, blank=True)
+ description = models.TextField(
+ _("description"),
+ blank=True,
+ help_text=_("Description of what this badge represents"),
+ )
+ calculator_class_reference = models.CharField(
+ _("calculator class reference"),
+ max_length=255,
+ unique=True,
+ help_text=_(
+ "lookup field for class_reference in badge Calculator implementations"
+ ),
+ )
+ # Reference to a static file path (e.g., 'badges/github-contributor.svg')
+ image_light = models.CharField(
+ _("image path (light mode)"),
+ max_length=255,
+ help_text=_("Path to badge image for light mode in static directory"),
+ )
+ image_dark = models.CharField(
+ _("image path (dark mode)"),
+ max_length=255,
+ help_text=_("Path to badge image for dark mode in static directory"),
+ )
+ image_small_light = models.CharField(
+ _("small image path (light mode)"),
+ max_length=255,
+ help_text=_("Path to small badge image for light mode in static directory"),
+ )
+ image_small_dark = models.CharField(
+ _("small image path (dark mode)"),
+ max_length=255,
+ help_text=_("Path to small badge image for dark mode in static directory"),
+ )
+ is_nft_enabled = models.BooleanField(default=False)
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ ordering = ["calculator_class_reference"]
+
+ def __str__(self):
+ return self.display_name or self.calculator_class_reference
+
+ def __repr__(self):
+ return f""
+
+
+class UserBadge(models.Model):
+ """Through table for User-Badge relationship with grade."""
+
+ user = models.ForeignKey(
+ settings.AUTH_USER_MODEL,
+ on_delete=models.CASCADE,
+ related_name="user_badges",
+ )
+ badge = models.ForeignKey(
+ Badge,
+ on_delete=models.CASCADE,
+ related_name="user_badges",
+ )
+ grade = models.IntegerField(
+ null=True,
+ blank=True,
+ help_text=_("Grade or level of this badge for the user"),
+ )
+ # all defaults below are for safety, for the NFT badges.
+ approved = models.BooleanField(default=False) # minting approved manually by admin
+ nft_minted = models.BooleanField(default=False) # is in common vault if unclaimed
+ nft_transfer_url = models.TextField(blank=True, null=True)
+ unclaimed = models.BooleanField(
+ default=False,
+ help_text=_("Default false, true when badge is_nft_enabled=True"),
+ )
+ published = models.BooleanField(
+ default=False, help_text=_("Visible on the user's profile")
+ ) # visible in the user's profile
+
+ created = models.DateTimeField(auto_now_add=True)
+ updated = models.DateTimeField(auto_now=True)
+
+ class Meta:
+ unique_together = [["user", "badge"]]
+ ordering = ["-created"]
+
+ def __str__(self):
+ return f"{self.user.display_name} - {self.badge.display_name}, ({self.grade})"
+
+ def __repr__(self):
+ return f""
diff --git a/badges/tasks.py b/badges/tasks.py
new file mode 100644
index 00000000..13bbfe6f
--- /dev/null
+++ b/badges/tasks.py
@@ -0,0 +1,56 @@
+import structlog
+from django.contrib.auth import get_user_model
+
+from badges.calculators import get_calculators
+from badges.models import Badge
+from badges.utils import award_user_badges
+from config.celery import app
+
+logger = structlog.getLogger(__name__)
+
+
+User = get_user_model()
+
+
+@app.task
+def update_badges():
+ """Update or create Badge rows in the database for each calculator class."""
+ logger.info("Starting badges updates")
+ for calculator_class in get_calculators():
+ class_reference = calculator_class.class_reference
+ logger.info(f"Updating {class_reference=}, validating...")
+ try:
+ calculator_class.validate()
+ except NotImplementedError as e:
+ logger.error(f"FAILED badge update: {e}")
+ continue
+ logger.info(f"Updating {class_reference=}, valid. Updating...")
+ badge, created = Badge.objects.update_or_create(
+ calculator_class_reference=class_reference,
+ defaults={
+ "title": calculator_class.title,
+ "display_name": calculator_class.display_name,
+ "description": calculator_class.description or "",
+ "image_light": calculator_class.badge_image_light,
+ "image_dark": calculator_class.badge_image_dark,
+ "image_small_light": calculator_class.badge_image_small_light,
+ "image_small_dark": calculator_class.badge_image_small_dark,
+ "is_nft_enabled": calculator_class.is_nft_enabled,
+ },
+ )
+ logger.info(f"{'Created' if created else 'Updated'} {class_reference=} badge")
+
+
+@app.task
+def award_badges(user_id: int | None = None) -> None:
+ """Calculate and award badges to users based on their contributions."""
+ logger.info("Starting badges calculation")
+ if user_id:
+ users = User.objects.filter(pk=user_id)
+ else:
+ users = User.objects.all()
+
+ for user in users:
+ award_user_badges(user)
+
+ logger.info("Badge calculation completed")
diff --git a/badges/tests.py b/badges/tests.py
new file mode 100644
index 00000000..a39b155a
--- /dev/null
+++ b/badges/tests.py
@@ -0,0 +1 @@
+# Create your tests here.
diff --git a/badges/utils.py b/badges/utils.py
new file mode 100644
index 00000000..5fadb537
--- /dev/null
+++ b/badges/utils.py
@@ -0,0 +1,54 @@
+import structlog
+from django.contrib.auth import get_user_model
+
+from badges.calculators import get_calculators
+from badges.models import Badge, UserBadge
+
+logger = structlog.getLogger(__name__)
+
+User = get_user_model()
+
+
+def award_user_badges(user: User) -> None:
+ """
+ Calculate and update all badges for a specific user.
+
+ Args:
+ user: The User instance to calculate badges for
+ """
+ logger.info(f"Starting badge calculations for user_id={user.id}")
+ for calculator_class in get_calculators():
+ try:
+ calculator = calculator_class(user)
+ except NotImplementedError as e:
+ logger.error(f"FAILED instantiating badge calculator: {e}")
+ continue
+ class_reference = calculator_class.class_reference
+
+ try:
+ badge = Badge.objects.get(calculator_class_reference=class_reference)
+ except Badge.DoesNotExist:
+ logger.warning(f"No badge with {class_reference=}. Run update_badges task")
+ continue
+
+ if calculator.achieved:
+ grade = calculator.grade
+ defaults = {"grade": grade}
+
+ if not badge.is_nft_enabled:
+ defaults["published"] = True
+ defaults["approved"] = True
+
+ _, created = UserBadge.objects.update_or_create(
+ user=user,
+ badge=badge,
+ defaults=defaults,
+ )
+ change = "Created" if created else "Updated"
+ logger.info(f"{change} {class_reference} UserBadge, {user.id=} {grade=}")
+ else:
+ # badge not achieved, remove it if it exists
+ UserBadge.objects.filter(user=user, badge=badge).delete()
+ logger.info(f"Deleted {class_reference} UserBadge for {user.id=}")
+
+ logger.info(f"Completed badge calculations for user_id={user.id}")
diff --git a/badges/views.py b/badges/views.py
new file mode 100644
index 00000000..60f00ef0
--- /dev/null
+++ b/badges/views.py
@@ -0,0 +1 @@
+# Create your views here.
diff --git a/config/celery.py b/config/celery.py
index 42834bab..8716b23e 100644
--- a/config/celery.py
+++ b/config/celery.py
@@ -112,3 +112,15 @@ def setup_periodic_tasks(sender, **kwargs):
crontab(day_of_week="sun", hour=2, minute=0),
app.signature("asciidoctor_sandbox.tasks.cleanup_old_sandbox_documents"),
)
+
+ # Update user badges available. Executes daily at 7:35 AM, after the github updates.
+ sender.add_periodic_task(
+ crontab(hour=7, minute=35),
+ app.signature("badges.tasks.update_badges"),
+ )
+
+ # # Award user badges to users. Executes daily at 7:45 AM, after the github updates.
+ # sender.add_periodic_task(
+ # crontab(hour=7, minute=45),
+ # app.signature("badges.tasks.award_badges"),
+ # )
diff --git a/config/settings.py b/config/settings.py
index 0509bec7..cbfb92fc 100755
--- a/config/settings.py
+++ b/config/settings.py
@@ -110,6 +110,7 @@
# Our Apps
INSTALLED_APPS += [
"ak",
+ "badges",
"users",
"versions",
"libraries",
diff --git a/justfile b/justfile
index 1c1b8c1e..fbd4924b 100644
--- a/justfile
+++ b/justfile
@@ -86,6 +86,11 @@ alias shell := console
@makemigrations: ## creates new database migrations
docker compose run --rm web /code/manage.py makemigrations
+ echo "Adjusting migration file permissions (may require password)..."
+ sudo chown -R $(id -u):$(id -g) */migrations/
+ sudo chmod -R 664 */migrations/*.py
+ echo "✓ Migration files ownership and permissions updated"
+
@migrate: ## applies database migrations
docker compose run --rm web /code/manage.py migrate --noinput
diff --git a/static/admin/css/badge_theme_images.css b/static/admin/css/badge_theme_images.css
new file mode 100644
index 00000000..983420d6
--- /dev/null
+++ b/static/admin/css/badge_theme_images.css
@@ -0,0 +1,43 @@
+#result_list td, #result_list th {
+ vertical-align: middle !important;
+}
+
+/* default: Show light mode badge, hide dark mode badge */
+.badge-light-mode {
+ display: inline-block !important;
+ vertical-align: middle !important;
+}
+
+.badge-dark-mode {
+ display: none !important;
+ vertical-align: middle !important;
+}
+
+/* django admin dark mode (data-theme attribute on html element) */
+html[data-theme="dark"] .badge-light-mode {
+ display: none !important;
+}
+
+html[data-theme="dark"] .badge-dark-mode {
+ display: inline-block !important;
+}
+
+/* system preference dark mode (as fallback) */
+@media (prefers-color-scheme: dark) {
+ html:not([data-theme="light"]) .badge-light-mode {
+ display: none !important;
+ }
+
+ html:not([data-theme="light"]) .badge-dark-mode {
+ display: inline-block !important;
+ }
+}
+
+/* explicitly handle light mode when set */
+html[data-theme="light"] .badge-light-mode {
+ display: inline-block !important;
+}
+
+html[data-theme="light"] .badge-dark-mode {
+ display: none !important;
+}
diff --git a/static/img/badges/github_maintainer_dark.svg b/static/img/badges/github_maintainer_dark.svg
new file mode 100644
index 00000000..d5e64918
--- /dev/null
+++ b/static/img/badges/github_maintainer_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/badges/github_maintainer_light.svg b/static/img/badges/github_maintainer_light.svg
new file mode 100644
index 00000000..37fa923d
--- /dev/null
+++ b/static/img/badges/github_maintainer_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/badges/library_creator_dark.svg b/static/img/badges/library_creator_dark.svg
new file mode 100644
index 00000000..d5e64918
--- /dev/null
+++ b/static/img/badges/library_creator_dark.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/static/img/badges/library_creator_light.svg b/static/img/badges/library_creator_light.svg
new file mode 100644
index 00000000..37fa923d
--- /dev/null
+++ b/static/img/badges/library_creator_light.svg
@@ -0,0 +1 @@
+
\ No newline at end of file
diff --git a/users/migrations/0021_remove_user_badges_delete_badge.py b/users/migrations/0021_remove_user_badges_delete_badge.py
new file mode 100644
index 00000000..8bf6e40d
--- /dev/null
+++ b/users/migrations/0021_remove_user_badges_delete_badge.py
@@ -0,0 +1,20 @@
+# Generated by Django 5.2.8 on 2025-11-20 22:37
+
+from django.db import migrations
+
+
+class Migration(migrations.Migration):
+
+ dependencies = [
+ ("users", "0020_rename_image_user_profile_image"),
+ ]
+
+ operations = [
+ migrations.RemoveField(
+ model_name="user",
+ name="badges",
+ ),
+ migrations.DeleteModel(
+ name="Badge",
+ ),
+ ]
diff --git a/users/models.py b/users/models.py
index c7491044..f1012bd1 100644
--- a/users/models.py
+++ b/users/models.py
@@ -186,11 +186,6 @@ def save(self, *args, **kwargs):
return super().save(*args, **kwargs)
-class Badge(models.Model):
- name = models.CharField(_("name"), max_length=100, blank=True)
- display_name = models.CharField(_("display name"), max_length=100, blank=True)
-
-
class User(BaseUser):
"""
Our custom user model.
@@ -198,7 +193,6 @@ class User(BaseUser):
NOTE: See ./signals.py for signals that relate to this model.
"""
- badges = models.ManyToManyField(Badge)
# todo: consider making this unique=True after checking user data for duplicates
github_username = models.CharField(_("github username"), max_length=100, blank=True)
is_commit_author_name_overridden = models.BooleanField(
diff --git a/versions/migrations/0001_initial.py b/versions/migrations/0001_initial.py
old mode 100755
new mode 100644
diff --git a/versions/migrations/__init__.py b/versions/migrations/__init__.py
old mode 100755
new mode 100644