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