diff --git a/backend/apps/github/management/commands/github_update_users.py b/backend/apps/github/management/commands/github_update_users.py
index 42a6e51e4d..830d5586ae 100644
--- a/backend/apps/github/management/commands/github_update_users.py
+++ b/backend/apps/github/management/commands/github_update_users.py
@@ -2,6 +2,7 @@
import logging
+from django.core.management import call_command
from django.core.management.base import BaseCommand
from django.db.models import Q, Sum
@@ -57,3 +58,26 @@ def handle(self, *args, **options):
User.bulk_save(users, fields=("contributions_count",))
User.bulk_save(users, fields=("contributions_count",))
+
+ # Sync badges after user data refresh
+ self.stdout.write("Syncing badges...")
+
+ badge_sync_failed = False
+
+ try:
+ call_command("nest_update_staff_badges", stdout=self.stdout)
+ except Exception as e:
+ logger.exception("Staff badge sync failed")
+ self.stderr.write(self.style.ERROR(f"Staff badge sync failed: {e}"))
+ badge_sync_failed = True
+ try:
+ call_command("nest_update_project_leader_badges", stdout=self.stdout)
+ except Exception as e:
+ logger.exception("Project leader badge sync failed")
+ self.stderr.write(self.style.ERROR(f"Project leader badge sync failed: {e}"))
+ badge_sync_failed = True
+
+ if badge_sync_failed:
+ self.stderr.write(
+ self.style.WARNING("User update completed but badge sync had errors")
+ )
diff --git a/backend/apps/nest/management/commands/nest_update_project_leader_badges.py b/backend/apps/nest/management/commands/nest_update_project_leader_badges.py
index 346f277750..7d8df7df67 100644
--- a/backend/apps/nest/management/commands/nest_update_project_leader_badges.py
+++ b/backend/apps/nest/management/commands/nest_update_project_leader_badges.py
@@ -12,7 +12,7 @@
class Command(BaseBadgeCommand):
help = "Sync OWASP Project Leader badges"
- badge_css_class = "fa-user-shield"
+ badge_css_class = "star"
badge_description = "Official OWASP Project Leader"
badge_name = "OWASP Project Leader"
badge_weight = 90
diff --git a/backend/apps/nest/management/commands/nest_update_staff_badges.py b/backend/apps/nest/management/commands/nest_update_staff_badges.py
index 12080c2816..c51dc06369 100644
--- a/backend/apps/nest/management/commands/nest_update_staff_badges.py
+++ b/backend/apps/nest/management/commands/nest_update_staff_badges.py
@@ -9,7 +9,7 @@
class Command(BaseBadgeCommand):
help = "Sync OWASP Staff badges"
- badge_css_class = "fa-user-shield"
+ badge_css_class = "ribbon"
badge_description = "Official OWASP Staff"
badge_name = "OWASP Staff"
badge_weight = 100
diff --git a/backend/apps/nest/migrations/0009_rename_bug_slash_css_class.py b/backend/apps/nest/migrations/0009_rename_bug_slash_css_class.py
new file mode 100644
index 0000000000..99301bee93
--- /dev/null
+++ b/backend/apps/nest/migrations/0009_rename_bug_slash_css_class.py
@@ -0,0 +1,26 @@
+from django.db import migrations
+
+
+def update_bug_slash_css_class(apps, _schema_editor):
+ """Rename stored css_class from 'bug_slash' to 'bugSlash'."""
+ Badge = apps.get_model("nest", "Badge")
+ Badge.objects.filter(css_class="bug_slash").update(css_class="bugSlash")
+
+
+def reverse_bug_slash_css_class(apps, _schema_editor):
+ """Rollback css_class from 'bugSlash' to 'bug_slash'."""
+ Badge = apps.get_model("nest", "Badge")
+ Badge.objects.filter(css_class="bugSlash").update(css_class="bug_slash")
+
+
+class Migration(migrations.Migration):
+ dependencies = [
+ ("nest", "0008_alter_badge_css_class"),
+ ]
+
+ operations = [
+ migrations.RunPython(
+ update_bug_slash_css_class,
+ reverse_bug_slash_css_class,
+ ),
+ ]
diff --git a/backend/apps/nest/models/badge.py b/backend/apps/nest/models/badge.py
index 112b3d1575..a686967338 100644
--- a/backend/apps/nest/models/badge.py
+++ b/backend/apps/nest/models/badge.py
@@ -12,7 +12,7 @@ class Badge(BulkSaveModel, TimestampedModel):
class BadgeCssClass(models.TextChoices):
AWARD = "award", "Award"
- BUG_SLASH = "bug_slash", "Bug Slash"
+ BUG_SLASH = "bugSlash", "Bug Slash"
CERTIFICATE = "certificate", "Certificate"
MEDAL = "medal", "Medal"
RIBBON = "ribbon", "Ribbon"
diff --git a/backend/docs/Badges.md b/backend/docs/Badges.md
new file mode 100644
index 0000000000..4772a0247c
--- /dev/null
+++ b/backend/docs/Badges.md
@@ -0,0 +1,283 @@
+# Badge System
+
+This document explains how user badges work in OWASP Nest, how they are synced,
+and what contributors should consider when adding or modifying badges.
+
+Badges are automatically managed by the system. Contributors should not need to
+manually assign or update badges in the database.
+
+---
+
+## Overview
+
+Badges represent user roles, achievements, or responsibilities inside the platform.
+
+Examples:
+- OWASP Staff
+- Project Leaders
+- Special contributors
+- Future recognition roles
+
+Badges are:
+- Stored in the database
+- Automatically synced using management commands
+- Rendered on the frontend using icons
+
+Each badge contains:
+
+| Field | Purpose |
+|-------|---------|
+| name | Display name shown in UI |
+| description | Optional explanation |
+| weight | Controls ordering/priority |
+| css_class | Used by frontend to choose the icon |
+
+---
+
+## Architecture
+
+The badge system has two layers that **must always stay in sync**:
+
+### Backend (source of truth)
+Location: `apps/nest/models/badge.py`
+
+`BadgeCssClass` enum defines all valid stored values.
+
+Example:
+
+```python
+class BadgeCssClass(models.TextChoices):
+ AWARD = "award"
+ MEDAL = "medal"
+ BUG_SLASH = "bugSlash"
+```
+
+Only these values should ever be stored in the database.
+
+### Frontend (icon rendering)
+Location: `frontend/utils/data.ts`
+
+```typescript
+export const BADGE_CLASS_MAP = {
+ award: FaAward,
+ medal: FaMedal,
+ bugSlash: FaBug,
+}
+```
+
+⚠️ **IMPORTANT**
+
+Backend `css_class` values must exactly match frontend keys.
+
+If they differ:
+- Icon not rendered
+- Fallback icon used
+- Tests may fail
+
+Example:
+- ✅ `bugSlash` → works
+- ❌ `bug_slash` → icon missing
+
+---
+
+## Badge Sync Flow
+
+Badges are refreshed automatically during GitHub user updates.
+
+Main command:
+```bash
+python manage.py github_update_users
+```
+
+Flow:
+1. Fetch users
+2. Calculate GitHub contributions
+3. Bulk save users
+4. Sync badges
+
+After updating users, the command internally calls:
+- `nest_update_staff_badges`
+- `nest_update_project_leader_badges`
+
+These commands:
+- Create badges if missing
+- Assign badges to eligible users
+- Remove outdated badges
+- Keep roles consistent
+
+So badge sync is **automatic** — no manual steps required.
+
+### Where sync happens in code
+Location: `apps/github/management/commands/github_update_users.py`
+
+Inside `handle()`:
+
+```python
+call_command("nest_update_staff_badges")
+call_command("nest_update_project_leader_badges")
+```
+
+This guarantees badges stay updated whenever users are refreshed.
+
+---
+
+## Manual Sync (for debugging)
+
+You can run badge updates manually:
+
+```bash
+python manage.py nest_update_staff_badges
+python manage.py nest_update_project_leader_badges
+```
+
+Useful when:
+- Testing locally
+- Verifying new logic
+- Debugging assignments
+
+---
+
+## When are badges updated?
+
+Badges update when:
+- `github_update_users` runs
+- Sync commands are run manually
+- New badge logic is added
+
+---
+
+## Adding a new badge
+
+Steps:
+1. Add enum value in `BadgeCssClass`
+2. Add icon mapping in frontend `BADGE_CLASS_MAP`
+3. Add assignment logic in management command
+4. Add tests
+5. Run checks
+
+Example:
+
+**Backend:**
+```python
+NEW_BADGE = "newBadge"
+```
+
+**Frontend:**
+```typescript
+newBadge: FaStar
+```
+
+---
+
+## Changing an existing css_class value
+
+If you rename a `css_class`, you **MUST** create a data migration.
+
+Reason: Existing rows already store the old value.
+
+Example: `bug_slash` → `bugSlash`
+
+Migration:
+```python
+Badge.objects.filter(css_class='bug_slash').update(css_class='bugSlash')
+```
+
+Without migration:
+- Icons break
+- Validation errors
+- Inconsistent data
+
+---
+
+## How to verify locally
+
+Run:
+```bash
+python manage.py github_update_users
+```
+
+Expected output:
+```text
+Syncing badges...
+Syncing OWASP Staff...
+Syncing Project Leaders...
+```
+
+Then:
+- Check admin panel
+- Check UI
+- Confirm badges appear
+
+---
+
+## Testing
+
+**Backend:**
+```bash
+make check-test
+```
+
+**Frontend:**
+```bash
+pnpm test
+```
+
+Verify:
+- Badges created
+- Icons render correctly
+- Sync commands execute successfully
+
+---
+
+## Common Issues
+
+### Icon not showing
+→ css_class mismatch with frontend map
+
+### Badge not assigned
+→ Sync command not executed
+
+### CI failing
+→ Missing migration or tests
+
+### Many files modified
+→ Run pre-commit
+
+---
+
+## Best Practices
+
+Always:
+- Keep backend + frontend keys identical
+- Create migration when renaming values
+- Add tests
+- Run `make check-test`
+
+Never:
+- Manually edit DB values
+- Rename enums without migration
+- Hardcode icons
+
+---
+
+## Mental Model
+
+Think of badges as:
+
+**Database → Sync Commands → Frontend Icons**
+
+If you update one layer, update the others.
+
+---
+
+## Summary
+
+Badges in OWASP Nest are:
+- Automatic
+- Synced
+- Role-based
+- Icon-driven
+
+Running `github_update_users` keeps everything consistent.
+
+If something breaks, check: **model → sync → frontend mapping**.
diff --git a/backend/tests/apps/github/management/commands/github_update_users_test.py b/backend/tests/apps/github/management/commands/github_update_users_test.py
index 2f4f7e6080..278fab9a58 100644
--- a/backend/tests/apps/github/management/commands/github_update_users_test.py
+++ b/backend/tests/apps/github/management/commands/github_update_users_test.py
@@ -1,6 +1,6 @@
"""Tests for the github_update_users Django management command."""
-from unittest.mock import MagicMock, patch
+from unittest.mock import ANY, MagicMock, call, patch
from django.core.management.base import BaseCommand
@@ -30,10 +30,13 @@ def test_add_arguments(self):
"--offset", default=0, required=False, type=int
)
+ @patch("apps.github.management.commands.github_update_users.call_command")
@patch("apps.github.management.commands.github_update_users.User")
@patch("apps.github.management.commands.github_update_users.RepositoryContributor")
@patch("apps.github.management.commands.github_update_users.BATCH_SIZE", 2)
- def test_handle_with_default_offset(self, mock_repository_contributor, mock_user):
+ def test_handle_with_default_offset(
+ self, mock_repository_contributor, mock_user, mock_call_command
+ ):
"""Test command execution with default offset."""
mock_user1 = MagicMock(id=1, title="User 1", contributions_count=0)
mock_user2 = MagicMock(id=2, title="User 2", contributions_count=0)
@@ -41,7 +44,11 @@ def test_handle_with_default_offset(self, mock_repository_contributor, mock_user
mock_users_queryset = MagicMock()
mock_users_queryset.count.return_value = 3
- mock_users_queryset.__getitem__.return_value = [mock_user1, mock_user2, mock_user3]
+ mock_users_queryset.__getitem__.return_value = [
+ mock_user1,
+ mock_user2,
+ mock_user3,
+ ]
mock_user.objects.order_by.return_value = mock_users_queryset
@@ -74,12 +81,19 @@ def test_handle_with_default_offset(self, mock_repository_contributor, mock_user
assert mock_user3.contributions_count == 30
assert mock_user.bulk_save.call_count == 2
- assert mock_user.bulk_save.call_args_list[-1][0][0] == [mock_user1, mock_user2, mock_user3]
+ assert mock_user.bulk_save.call_args_list[-1][0][0] == [
+ mock_user1,
+ mock_user2,
+ mock_user3,
+ ]
+ @patch("apps.github.management.commands.github_update_users.call_command")
@patch("apps.github.management.commands.github_update_users.User")
@patch("apps.github.management.commands.github_update_users.RepositoryContributor")
@patch("apps.github.management.commands.github_update_users.BATCH_SIZE", 2)
- def test_handle_with_custom_offset(self, mock_repository_contributor, mock_user):
+ def test_handle_with_custom_offset(
+ self, mock_repository_contributor, mock_user, mock_call_command
+ ):
"""Test command execution with custom offset."""
mock_user1 = MagicMock(id=2, title="User 2", contributions_count=0)
mock_user2 = MagicMock(id=3, title="User 3", contributions_count=0)
@@ -115,11 +129,12 @@ def test_handle_with_custom_offset(self, mock_repository_contributor, mock_user)
assert mock_user.bulk_save.call_count == 2
assert mock_user.bulk_save.call_args_list[-1][0][0] == [mock_user1, mock_user2]
+ @patch("apps.github.management.commands.github_update_users.call_command")
@patch("apps.github.management.commands.github_update_users.User")
@patch("apps.github.management.commands.github_update_users.RepositoryContributor")
@patch("apps.github.management.commands.github_update_users.BATCH_SIZE", 3)
def test_handle_with_users_having_no_contributions(
- self, mock_repository_contributor, mock_user
+ self, mock_repository_contributor, mock_user, mock_call_command
):
"""Test command execution when users have no contributions."""
mock_user1 = MagicMock(id=1, title="User 1", contributions_count=0)
@@ -149,10 +164,13 @@ def test_handle_with_users_having_no_contributions(
assert mock_user.bulk_save.call_count == 1
assert mock_user.bulk_save.call_args_list[-1][0][0] == [mock_user1, mock_user2]
+ @patch("apps.github.management.commands.github_update_users.call_command")
@patch("apps.github.management.commands.github_update_users.User")
@patch("apps.github.management.commands.github_update_users.RepositoryContributor")
@patch("apps.github.management.commands.github_update_users.BATCH_SIZE", 1)
- def test_handle_with_single_user(self, mock_repository_contributor, mock_user):
+ def test_handle_with_single_user(
+ self, mock_repository_contributor, mock_user, mock_call_command
+ ):
"""Test command execution with single user."""
mock_user1 = MagicMock(id=1, title="User 1", contributions_count=0)
@@ -179,10 +197,13 @@ def test_handle_with_single_user(self, mock_repository_contributor, mock_user):
assert mock_user.bulk_save.call_count == 2
assert mock_user.bulk_save.call_args_list[-1][0][0] == [mock_user1]
+ @patch("apps.github.management.commands.github_update_users.call_command")
@patch("apps.github.management.commands.github_update_users.User")
@patch("apps.github.management.commands.github_update_users.RepositoryContributor")
@patch("apps.github.management.commands.github_update_users.BATCH_SIZE", 2)
- def test_handle_with_empty_user_list(self, mock_repository_contributor, mock_user):
+ def test_handle_with_empty_user_list(
+ self, mock_repository_contributor, mock_user, mock_call_command
+ ):
"""Test command execution with no users."""
mock_users_queryset = MagicMock()
mock_users_queryset.count.return_value = 0
@@ -203,10 +224,13 @@ def test_handle_with_empty_user_list(self, mock_repository_contributor, mock_use
assert mock_user.bulk_save.call_count == 1
assert mock_user.bulk_save.call_args_list[-1][0][0] == []
+ @patch("apps.github.management.commands.github_update_users.call_command")
@patch("apps.github.management.commands.github_update_users.User")
@patch("apps.github.management.commands.github_update_users.RepositoryContributor")
@patch("apps.github.management.commands.github_update_users.BATCH_SIZE", 2)
- def test_handle_with_exact_batch_size(self, mock_repository_contributor, mock_user):
+ def test_handle_with_exact_batch_size(
+ self, mock_repository_contributor, mock_user, mock_call_command
+ ):
"""Test command execution when user count equals batch size."""
mock_user1 = MagicMock(id=1, title="User 1", contributions_count=0)
mock_user2 = MagicMock(id=2, title="User 2", contributions_count=0)
@@ -237,3 +261,33 @@ def test_handle_with_exact_batch_size(self, mock_repository_contributor, mock_us
assert mock_user.bulk_save.call_count == 2
assert mock_user.bulk_save.call_args_list[-1][0][0] == [mock_user1, mock_user2]
+
+ @patch("apps.github.management.commands.github_update_users.call_command")
+ @patch("apps.github.management.commands.github_update_users.User")
+ @patch("apps.github.management.commands.github_update_users.RepositoryContributor")
+ def test_badge_sync_commands_are_called(
+ self, mock_repository_contributor, mock_user, mock_call_command
+ ):
+ """Test that badge sync commands run after user update."""
+ mock_users_queryset = MagicMock()
+ mock_users_queryset.count.return_value = 0
+ mock_users_queryset.__getitem__.return_value = []
+
+ mock_user.objects.order_by.return_value = mock_users_queryset
+
+ mock_rc_queryset = MagicMock()
+ mock_rc_queryset.exclude.return_value.values.return_value.annotate.return_value = []
+ mock_repository_contributor.objects = mock_rc_queryset
+
+ command = Command()
+ command.handle(offset=0)
+
+ mock_call_command.assert_has_calls(
+ [
+ call("nest_update_staff_badges", stdout=ANY),
+ call("nest_update_project_leader_badges", stdout=ANY),
+ ],
+ any_order=False,
+ )
+
+ assert mock_call_command.call_count == 2
diff --git a/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py
index 512974ce4b..874282c57d 100644
--- a/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py
+++ b/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py
@@ -14,6 +14,7 @@ class TestProjectLeaderBadgeCommand(SimpleTestCase):
def test_has_correct_metadata(self):
assert Command.badge_name == "OWASP Project Leader"
assert Command.badge_weight == 90
+ assert Command.badge_css_class == "star"
@patch("apps.nest.management.commands.nest_update_project_leader_badges.User")
@patch("apps.nest.management.commands.nest_update_project_leader_badges.EntityMember")
diff --git a/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py
index bc04cbe71a..b63ea697eb 100644
--- a/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py
+++ b/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py
@@ -14,6 +14,7 @@ class TestStaffBadgeCommand(SimpleTestCase):
def test_has_correct_metadata(self):
assert Command.badge_name == "OWASP Staff"
assert Command.badge_weight == 100
+ assert Command.badge_css_class == "ribbon"
@patch("apps.nest.management.commands.nest_update_staff_badges.User")
@patch("apps.nest.management.commands.base_badge_command.UserBadge")
diff --git a/frontend/__tests__/unit/components/Badges.test.tsx b/frontend/__tests__/unit/components/Badges.test.tsx
index 87534d4f24..476af06125 100644
--- a/frontend/__tests__/unit/components/Badges.test.tsx
+++ b/frontend/__tests__/unit/components/Badges.test.tsx
@@ -92,11 +92,11 @@ describe('Badges Component', () => {
{ cssClass: 'ribbon', expectedIcon: 'ribbon' },
{ cssClass: 'star', expectedIcon: 'star' },
{ cssClass: 'certificate', expectedIcon: 'certificate' },
- { cssClass: 'bug_slash', expectedIcon: 'bug' }, // Backend snake_case input
+ { cssClass: 'bugSlash', expectedIcon: 'bug' }, // ✅ direct mapping only
]
for (const backendIcon of backendIcons) {
- it(`renders ${backendIcon.cssClass} icon correctly (transforms snake_case to camelCase)`, () => {
+ it(`renders ${backendIcon.cssClass} icon correctly`, () => {
render()
const icon = screen.getByTestId('badge-icon')
@@ -104,13 +104,5 @@ describe('Badges Component', () => {
expect(icon).toHaveAttribute('data-icon', backendIcon.expectedIcon)
})
}
-
- it('handles camelCase input directly', () => {
- render()
-
- const icon = screen.getByTestId('badge-icon')
- expect(icon).toBeInTheDocument()
- expect(icon).toHaveAttribute('data-icon', 'bug')
- })
})
})
diff --git a/frontend/src/components/Badges.tsx b/frontend/src/components/Badges.tsx
index e87548f038..a61e66761f 100644
--- a/frontend/src/components/Badges.tsx
+++ b/frontend/src/components/Badges.tsx
@@ -10,17 +10,12 @@ type BadgeProps = {
const DEFAULT_ICON = BADGE_CLASS_MAP['medal']
-const normalizeCssClass = (cssClass: string | undefined) => {
- if (!cssClass || cssClass.trim() === '') {
- return ''
+const resolveIcon = (cssClass: string | undefined) => {
+ if (!cssClass) {
+ return DEFAULT_ICON
}
- // Convert backend snake_case format to frontend camelCase format
- return cssClass.trim().replaceAll(/_([a-z])/g, (_, letter) => letter.toUpperCase())
-}
-const resolveIcon = (cssClass: string | undefined) => {
- const normalizedClass = normalizeCssClass(cssClass)
- return BADGE_CLASS_MAP[normalizedClass] ?? DEFAULT_ICON
+ return BADGE_CLASS_MAP[cssClass] ?? DEFAULT_ICON
}
const Badges = ({ name, cssClass, showTooltip = true }: BadgeProps) => {