mirror of
https://github.com/boostorg/website-v2.git
synced 2026-01-19 16:52:16 +00:00
First pass on badge support (#1978)
This commit is contained in:
0
badges/__init__.py
Normal file
0
badges/__init__.py
Normal file
165
badges/admin.py
Normal file
165
badges/admin.py
Normal file
@@ -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'<img src="{image_url_light}" width="30" height="30" class="badge-light-mode" />'
|
||||
if obj.image_dark:
|
||||
image_url_dark = static(f"img/badges/{obj.image_dark}")
|
||||
html += f'<img src="{image_url_dark}" width="30" height="30" class="badge-dark-mode" />'
|
||||
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 += (
|
||||
"<div><strong>Light mode:</strong><br>"
|
||||
'<div style="display: inline-block; background-color: #fff; padding: 10px;">'
|
||||
f'<img src="{image_url}" width="100" height="100" />'
|
||||
"</div></div>"
|
||||
)
|
||||
if obj.image_dark:
|
||||
image_url = static(f"img/badges/{obj.image_dark}")
|
||||
html += (
|
||||
'<div style="margin-top: 10px;"><strong>Dark mode:</strong><br>'
|
||||
'<div style="display: inline-block; background-color: #000; padding: 10px;">'
|
||||
f'<img src="{image_url}" width="100" height="100" />'
|
||||
"</div></div>"
|
||||
)
|
||||
if obj.image_small_light:
|
||||
image_url = static(f"img/badges/{obj.image_small_light}")
|
||||
html += (
|
||||
'<div style="margin-top: 10px;"><strong>Small light mode:</strong><br>'
|
||||
'<div style="display: inline-block; background-color: #fff; padding: 10px;">'
|
||||
f'<img src="{image_url}" width="100" height="100" />'
|
||||
"</div></div>"
|
||||
)
|
||||
if obj.image_small_dark:
|
||||
image_url = static(f"img/badges/{obj.image_small_dark}")
|
||||
html += (
|
||||
'<div style="margin-top: 10px;"><strong>Small dark mode:</strong><br>'
|
||||
'<div style="display: inline-block; background-color: #000; padding: 10px;">'
|
||||
f'<img src="{image_url}" width="100" height="100" />'
|
||||
"</div></div>"
|
||||
)
|
||||
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
|
||||
6
badges/apps.py
Normal file
6
badges/apps.py
Normal file
@@ -0,0 +1,6 @@
|
||||
from django.apps import AppConfig
|
||||
|
||||
|
||||
class BadgesConfig(AppConfig):
|
||||
default_auto_field = "django.db.models.BigAutoField"
|
||||
name = "badges"
|
||||
26
badges/calculators/__init__.py
Normal file
26
badges/calculators/__init__.py
Normal file
@@ -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
|
||||
82
badges/calculators/base_calculator.py
Normal file
82
badges/calculators/base_calculator.py
Normal file
@@ -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
|
||||
31
badges/calculators/github_maintainer.py
Normal file
31
badges/calculators/github_maintainer.py
Normal file
@@ -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
|
||||
31
badges/calculators/library_creator.py
Normal file
31
badges/calculators/library_creator.py
Normal file
@@ -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
|
||||
0
badges/management/__init__.py
Normal file
0
badges/management/__init__.py
Normal file
0
badges/management/commands/__init__.py
Normal file
0
badges/management/commands/__init__.py
Normal file
31
badges/management/commands/award_badges.py
Normal file
31
badges/management/commands/award_badges.py
Normal file
@@ -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")
|
||||
14
badges/management/commands/update_badges.py
Normal file
14
badges/management/commands/update_badges.py
Normal file
@@ -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")
|
||||
153
badges/migrations/0001_initial.py
Normal file
153
badges/migrations/0001_initial.py
Normal file
@@ -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")},
|
||||
},
|
||||
),
|
||||
]
|
||||
0
badges/migrations/__init__.py
Normal file
0
badges/migrations/__init__.py
Normal file
100
badges/models.py
Normal file
100
badges/models.py
Normal file
@@ -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"<Badge: {self.calculator_class_reference} (id={self.pk})>"
|
||||
|
||||
|
||||
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"<UserBadge: user_id={self.user_id}, badge_id={self.badge_id}>"
|
||||
56
badges/tasks.py
Normal file
56
badges/tasks.py
Normal file
@@ -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")
|
||||
1
badges/tests.py
Normal file
1
badges/tests.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your tests here.
|
||||
54
badges/utils.py
Normal file
54
badges/utils.py
Normal file
@@ -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}")
|
||||
1
badges/views.py
Normal file
1
badges/views.py
Normal file
@@ -0,0 +1 @@
|
||||
# Create your views here.
|
||||
@@ -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"),
|
||||
# )
|
||||
|
||||
@@ -110,6 +110,7 @@ INSTALLED_APPS += [
|
||||
# Our Apps
|
||||
INSTALLED_APPS += [
|
||||
"ak",
|
||||
"badges",
|
||||
"users",
|
||||
"versions",
|
||||
"libraries",
|
||||
|
||||
5
justfile
5
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
|
||||
|
||||
43
static/admin/css/badge_theme_images.css
Normal file
43
static/admin/css/badge_theme_images.css
Normal file
@@ -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;
|
||||
}
|
||||
1
static/img/badges/github_maintainer_dark.svg
Normal file
1
static/img/badges/github_maintainer_dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
1
static/img/badges/github_maintainer_light.svg
Normal file
1
static/img/badges/github_maintainer_light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
||||
|
After Width: | Height: | Size: 963 B |
1
static/img/badges/library_creator_dark.svg
Normal file
1
static/img/badges/library_creator_dark.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 960 B |
1
static/img/badges/library_creator_light.svg
Normal file
1
static/img/badges/library_creator_light.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg width="98" height="96" xmlns="http://www.w3.org/2000/svg"><path fill-rule="evenodd" clip-rule="evenodd" d="M48.854 0C21.839 0 0 22 0 49.217c0 21.756 13.993 40.172 33.405 46.69 2.427.49 3.316-1.059 3.316-2.362 0-1.141-.08-5.052-.08-9.127-13.59 2.934-16.42-5.867-16.42-5.867-2.184-5.704-5.42-7.17-5.42-7.17-4.448-3.015.324-3.015.324-3.015 4.934.326 7.523 5.052 7.523 5.052 4.367 7.496 11.404 5.378 14.235 4.074.404-3.178 1.699-5.378 3.074-6.6-10.839-1.141-22.243-5.378-22.243-24.283 0-5.378 1.94-9.778 5.014-13.2-.485-1.222-2.184-6.275.486-13.038 0 0 4.125-1.304 13.426 5.052a46.97 46.97 0 0 1 12.214-1.63c4.125 0 8.33.571 12.213 1.63 9.302-6.356 13.427-5.052 13.427-5.052 2.67 6.763.97 11.816.485 13.038 3.155 3.422 5.015 7.822 5.015 13.2 0 18.905-11.404 23.06-22.324 24.283 1.78 1.548 3.316 4.481 3.316 9.126 0 6.6-.08 11.897-.08 13.526 0 1.304.89 2.853 3.316 2.364 19.412-6.52 33.405-24.935 33.405-46.691C97.707 22 75.788 0 48.854 0z" fill="#24292f"/></svg>
|
||||
|
After Width: | Height: | Size: 963 B |
20
users/migrations/0021_remove_user_badges_delete_badge.py
Normal file
20
users/migrations/0021_remove_user_badges_delete_badge.py
Normal file
@@ -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",
|
||||
),
|
||||
]
|
||||
@@ -186,11 +186,6 @@ class BaseUser(AbstractBaseUser, PermissionsMixin):
|
||||
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(
|
||||
|
||||
0
versions/migrations/0001_initial.py
Executable file → Normal file
0
versions/migrations/0001_initial.py
Executable file → Normal file
0
versions/migrations/__init__.py
Executable file → Normal file
0
versions/migrations/__init__.py
Executable file → Normal file
Reference in New Issue
Block a user