First pass on badge support (#1978)

This commit is contained in:
Dave O'Connor
2025-11-10 16:45:23 -08:00
parent 546f56a5df
commit 8e807956fa
30 changed files with 836 additions and 6 deletions

0
badges/__init__.py Normal file
View File

165
badges/admin.py Normal file
View 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
View File

@@ -0,0 +1,6 @@
from django.apps import AppConfig
class BadgesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "badges"

View 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

View 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

View 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

View 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

View File

View File

View 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")

View 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")

View 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")},
},
),
]

View File

100
badges/models.py Normal file
View 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
View 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
View File

@@ -0,0 +1 @@
# Create your tests here.

54
badges/utils.py Normal file
View 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
View File

@@ -0,0 +1 @@
# Create your views here.

View File

@@ -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"),
# )

View File

@@ -110,6 +110,7 @@ INSTALLED_APPS += [
# Our Apps
INSTALLED_APPS += [
"ak",
"badges",
"users",
"versions",
"libraries",

View File

@@ -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

View 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;
}

View 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

View 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

View 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

View 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

View 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",
),
]

View File

@@ -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
View File

0
versions/migrations/__init__.py Executable file → Normal file
View File