Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Empty file added badges/__init__.py
Empty file.
165 changes: 165 additions & 0 deletions badges/admin.py
Original file line number Diff line number Diff line change
@@ -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 changes: 6 additions & 0 deletions badges/apps.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
from django.apps import AppConfig


class BadgesConfig(AppConfig):
default_auto_field = "django.db.models.BigAutoField"
name = "badges"
26 changes: 26 additions & 0 deletions badges/calculators/__init__.py
Original file line number Diff line number Diff line change
@@ -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 changes: 82 additions & 0 deletions badges/calculators/base_calculator.py
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions badges/calculators/github_maintainer.py
Original file line number Diff line number Diff line change
@@ -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 changes: 31 additions & 0 deletions badges/calculators/library_creator.py
Original file line number Diff line number Diff line change
@@ -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
Empty file added badges/management/__init__.py
Empty file.
Empty file.
31 changes: 31 additions & 0 deletions badges/management/commands/award_badges.py
Original file line number Diff line number Diff line change
@@ -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 changes: 14 additions & 0 deletions badges/management/commands/update_badges.py
Original file line number Diff line number Diff line change
@@ -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")
Loading