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) => {