Skip to content

Commit 4ea7902

Browse files
committed
First pass on badge support (#1978)
1 parent 5f016a6 commit 4ea7902

30 files changed

+836
-6
lines changed

badges/__init__.py

Whitespace-only changes.

badges/admin.py

Lines changed: 165 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,165 @@
1+
from django.contrib import admin
2+
from django.utils.safestring import mark_safe
3+
from django.templatetags.static import static
4+
5+
from .models import Badge, UserBadge
6+
7+
8+
class NFTUnapprovedFilter(admin.SimpleListFilter):
9+
title = "NFT approval status"
10+
parameter_name = "nft_approval"
11+
12+
def lookups(self, request, model_admin):
13+
return (
14+
("unapproved", "NFT enabled - Not approved"),
15+
("approved", "NFT enabled - Approved"),
16+
("not_nft", "Not NFT enabled"),
17+
)
18+
19+
def queryset(self, request, queryset):
20+
if self.value() == "unapproved":
21+
return queryset.filter(badge__is_nft_enabled=True, approved=False)
22+
if self.value() == "approved":
23+
return queryset.filter(badge__is_nft_enabled=True, approved=True)
24+
if self.value() == "not_nft":
25+
return queryset.filter(badge__is_nft_enabled=False)
26+
27+
28+
@admin.register(Badge)
29+
class BadgeAdmin(admin.ModelAdmin):
30+
list_display = (
31+
"calculator_class_reference",
32+
"image_preview",
33+
"display_name",
34+
"title",
35+
"created",
36+
"updated",
37+
)
38+
search_fields = (
39+
"title",
40+
"display_name",
41+
"calculator_class_reference",
42+
"description",
43+
)
44+
list_filter = ("is_nft_enabled",)
45+
46+
readonly_fields = (
47+
"description",
48+
"title",
49+
"display_name",
50+
"calculator_class_reference",
51+
"image_light",
52+
"image_dark",
53+
"image_small_light",
54+
"image_small_dark",
55+
"image_preview_large",
56+
"created",
57+
"updated",
58+
)
59+
fields = (
60+
"calculator_class_reference",
61+
"title",
62+
"display_name",
63+
"description",
64+
"image_light",
65+
"image_dark",
66+
"image_small_light",
67+
"image_small_dark",
68+
"image_preview_large",
69+
"created",
70+
"updated",
71+
)
72+
73+
class Media:
74+
css = {"all": ("admin/css/badge_theme_images.css",)}
75+
76+
def has_add_permission(self, request):
77+
"""Disable adding badges through admin - they should be created via update_badges task."""
78+
return False
79+
80+
@admin.display(description="Badge")
81+
def image_preview(self, obj):
82+
html = ""
83+
if obj.image_light:
84+
image_url_light = static(f"img/badges/{obj.image_light}")
85+
html += f'<img src="{image_url_light}" width="30" height="30" class="badge-light-mode" />'
86+
if obj.image_dark:
87+
image_url_dark = static(f"img/badges/{obj.image_dark}")
88+
html += f'<img src="{image_url_dark}" width="30" height="30" class="badge-dark-mode" />'
89+
return mark_safe(html) if html else "-"
90+
91+
@admin.display(description="Badge Preview")
92+
def image_preview_large(self, obj):
93+
html = ""
94+
if obj.image_light:
95+
image_url = static(f"img/badges/{obj.image_light}")
96+
html += (
97+
"<div><strong>Light mode:</strong><br>"
98+
'<div style="display: inline-block; background-color: #fff; padding: 10px;">'
99+
f'<img src="{image_url}" width="100" height="100" />'
100+
"</div></div>"
101+
)
102+
if obj.image_dark:
103+
image_url = static(f"img/badges/{obj.image_dark}")
104+
html += (
105+
'<div style="margin-top: 10px;"><strong>Dark mode:</strong><br>'
106+
'<div style="display: inline-block; background-color: #000; padding: 10px;">'
107+
f'<img src="{image_url}" width="100" height="100" />'
108+
"</div></div>"
109+
)
110+
if obj.image_small_light:
111+
image_url = static(f"img/badges/{obj.image_small_light}")
112+
html += (
113+
'<div style="margin-top: 10px;"><strong>Small light mode:</strong><br>'
114+
'<div style="display: inline-block; background-color: #fff; padding: 10px;">'
115+
f'<img src="{image_url}" width="100" height="100" />'
116+
"</div></div>"
117+
)
118+
if obj.image_small_dark:
119+
image_url = static(f"img/badges/{obj.image_small_dark}")
120+
html += (
121+
'<div style="margin-top: 10px;"><strong>Small dark mode:</strong><br>'
122+
'<div style="display: inline-block; background-color: #000; padding: 10px;">'
123+
f'<img src="{image_url}" width="100" height="100" />'
124+
"</div></div>"
125+
)
126+
return mark_safe(html) if html else "-"
127+
128+
129+
@admin.register(UserBadge)
130+
class UserBadgeAdmin(admin.ModelAdmin):
131+
list_display = ("user", "badge", "created", "updated")
132+
list_filter = (
133+
NFTUnapprovedFilter,
134+
"badge",
135+
"created",
136+
"badge__calculator_class_reference",
137+
"badge__display_name",
138+
)
139+
search_fields = ("user__email", "user__display_name")
140+
readonly_fields = ("badge", "grade", "unclaimed", "created", "updated")
141+
fields = (
142+
"user",
143+
"badge",
144+
"grade",
145+
"approved",
146+
"unclaimed",
147+
"nft_minted",
148+
"published",
149+
"created",
150+
"updated",
151+
)
152+
autocomplete_fields = ["user"]
153+
154+
def get_readonly_fields(self, request, obj=None):
155+
readonly = list(super().get_readonly_fields(request, obj))
156+
if obj:
157+
# make these readonly if already True
158+
if obj.approved:
159+
readonly.append("approved")
160+
if obj.nft_minted:
161+
readonly.append("nft_minted")
162+
# make nft_minted readonly if badge doesn't have NFT enabled
163+
if not obj.badge.is_nft_enabled and "nft_minted" not in readonly:
164+
readonly.append("nft_minted")
165+
return readonly

badges/apps.py

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
from django.apps import AppConfig
2+
3+
4+
class BadgesConfig(AppConfig):
5+
default_auto_field = "django.db.models.BigAutoField"
6+
name = "badges"

badges/calculators/__init__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
import importlib
2+
import inspect
3+
import pkgutil
4+
from typing import Generator
5+
6+
from badges.calculators.base_calculator import BaseCalculator
7+
8+
9+
def get_calculators() -> Generator[BaseCalculator]:
10+
"""
11+
Discover and return all implemented calculator classes.
12+
13+
Returns:
14+
List of calculator classes that inherit from BaseCalculator.
15+
"""
16+
calculators_package = importlib.import_module("badges.calculators")
17+
for importer, modname, ispkg in pkgutil.iter_modules(calculators_package.__path__):
18+
if modname == "base_calculator":
19+
continue
20+
21+
module = importlib.import_module(f"badges.calculators.{modname}")
22+
# get all classes from the module
23+
for name, obj in inspect.getmembers(module, inspect.isclass):
24+
# we want subclasses of BaseCalculator, not BaseCalculator itself
25+
if issubclass(obj, BaseCalculator) and obj is not BaseCalculator:
26+
yield obj
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
from abc import ABCMeta, abstractmethod
2+
from typing import Any
3+
4+
from django.contrib.auth import get_user_model
5+
from django.utils.functional import cached_property
6+
7+
User = get_user_model()
8+
9+
10+
class BaseCalculator(metaclass=ABCMeta):
11+
"""Base class for badge calculators.
12+
13+
Subclasses must define the following class attributes:
14+
class_reference (str): Unique identifier matching the Badge model's calculator_class_reference field
15+
title (str): Badge title displayed on hover
16+
display_name (str): Badge name displayed on user profiles
17+
description (str | None): Description of what the badge represents
18+
badge_image_light (str): Path to light mode badge image, relative to static/img/badges/
19+
badge_image_dark (str): Path to dark mode badge image, relative to static/img/badges/
20+
badge_image_small_light (str): Path to small light mode badge image, relative to static/img/badges/
21+
badge_image_small_dark (str): Path to small dark mode badge image, relative to static/img/badges/
22+
23+
Subclasses must also implement:
24+
retrieve_data(): Returns data needed to calculate the badge
25+
determine_achieved(): Returns whether the badge has been achieved
26+
calculate_grade(): Returns the grade/level for the badge
27+
"""
28+
29+
class_reference: str = None # type: ignore[assignment]
30+
title: str = None # type: ignore[assignment]
31+
display_name: str = None # type: ignore[assignment]
32+
description: str | None = None
33+
badge_image_light: str = None # type: ignore[assignment]
34+
badge_image_dark: str = None # type: ignore[assignment]
35+
badge_image_small_light: str = None # type: ignore[assignment]
36+
badge_image_small_dark: str = None # type: ignore[assignment]
37+
is_nft_enabled: bool = False # type: ignore[assignment]
38+
39+
required_fields = (
40+
"class_reference",
41+
"title",
42+
"display_name",
43+
"badge_image_light",
44+
"badge_image_dark",
45+
"badge_image_small_light",
46+
"badge_image_small_dark",
47+
)
48+
49+
def __init__(self, user: User):
50+
self.validate()
51+
self.user = user
52+
self.data = self.retrieve_data()
53+
54+
@classmethod
55+
def validate(cls):
56+
for field in cls.required_fields:
57+
if not getattr(cls, field, None):
58+
msg = f"'{field}' on the {cls.__name__} calculator class is not defined"
59+
raise NotImplementedError(msg)
60+
61+
@cached_property
62+
def achieved(self) -> bool:
63+
return self.determine_achieved(self.data)
64+
65+
@cached_property
66+
def grade(self) -> bool | None:
67+
return self.calculate_grade(self.data)
68+
69+
@abstractmethod
70+
def retrieve_data(self) -> dict[str, Any]:
71+
"""This method returns the data needed to generate the grade"""
72+
raise NotImplementedError
73+
74+
@abstractmethod
75+
def determine_achieved(self, metrics: dict[str, Any]) -> bool:
76+
"""This method signifies that the badge has been achieved"""
77+
raise NotImplementedError
78+
79+
@abstractmethod
80+
def calculate_grade(self, metrics: dict[str, Any]) -> int | None:
81+
"""This method calculators the grade for the user based on passed in metrics"""
82+
raise NotImplementedError
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from textwrap import dedent
2+
3+
from badges.calculators.base_calculator import BaseCalculator
4+
5+
6+
class GithubMaintainer(BaseCalculator):
7+
class_reference = "github_maintainer"
8+
title = "Active Library Maintainer"
9+
display_name = "Library Maintainer"
10+
description = dedent(
11+
"""
12+
Awarded to users who continuously make contributions to the project via GitHub.
13+
This badge recognizes active participation in the development community.
14+
"""
15+
).strip()
16+
badge_image_light = "github_maintainer_light.svg"
17+
badge_image_dark = "github_maintainer_dark.svg"
18+
badge_image_small_light = "github_maintainer_light.svg"
19+
badge_image_small_dark = "github_maintainer_dark.svg"
20+
is_nft_enabled = False
21+
22+
def retrieve_data(self):
23+
return {}
24+
25+
def determine_achieved(self, metrics):
26+
# e.g. has made N contributions in X days to 1+ library
27+
return True
28+
29+
def calculate_grade(self, metrics) -> int:
30+
# e.g. number of libraries on which the user has made N contributions in X days
31+
return 12
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
from textwrap import dedent
2+
3+
from badges.calculators.base_calculator import BaseCalculator
4+
5+
6+
class LibraryCreator(BaseCalculator):
7+
class_reference = "library_creator"
8+
title = "Library Creator"
9+
display_name = "Library Creator"
10+
description = dedent(
11+
"""
12+
Awarded to users who have created and published libraries. This badge recognizes
13+
contributions to the C++ ecosystem through library development.
14+
"""
15+
).strip()
16+
badge_image_light = "library_creator_light.svg"
17+
badge_image_dark = "library_creator_dark.svg"
18+
badge_image_small_light = "library_creator_light.svg"
19+
badge_image_small_dark = "library_creator_dark.svg"
20+
is_nft_enabled = True
21+
22+
def retrieve_data(self):
23+
return {}
24+
25+
def determine_achieved(self, metrics):
26+
# e.g. has created >1 libraries
27+
return True
28+
29+
def calculate_grade(self, metrics) -> int:
30+
# e.g. number of libraries created by the user
31+
return 1

badges/management/__init__.py

Whitespace-only changes.

badges/management/commands/__init__.py

Whitespace-only changes.
Lines changed: 31 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,31 @@
1+
import djclick as click
2+
from django.contrib.auth import get_user_model
3+
4+
from badges.tasks import award_badges
5+
6+
User = get_user_model()
7+
8+
9+
@click.command()
10+
@click.option(
11+
"--user-email",
12+
type=str,
13+
default=None,
14+
help="Specific user email to calculate badges for. If not provided, calculates for all users.",
15+
)
16+
def command(user_email):
17+
"""Calculate and award badges to users based on their contributions."""
18+
user_id = None
19+
20+
if user_email:
21+
try:
22+
user_id = User.objects.values_list("id", flat=True).get(email=user_email)
23+
except User.DoesNotExist:
24+
click.secho(f"User with {user_email=} doesn't exist", fg="red")
25+
return
26+
if user_id:
27+
click.secho(f"Calculating badges for {user_email}...", fg="green")
28+
else:
29+
click.secho("Calculating badges for all users...", fg="green")
30+
award_badges.delay(user_id)
31+
click.secho("Award badges task queued, output is in logging.", fg="green")

0 commit comments

Comments
 (0)