Skip to content
Open
Show file tree
Hide file tree
Changes from 4 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
24 changes: 24 additions & 0 deletions backend/apps/github/management/commands/github_update_users.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
)
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
26 changes: 26 additions & 0 deletions backend/apps/nest/migrations/0009_rename_bug_slash_css_class.py
Original file line number Diff line number Diff line change
@@ -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"),
]
Comment thread
Isha-upadhyay marked this conversation as resolved.

operations = [
migrations.RunPython(
update_bug_slash_css_class,
reverse_bug_slash_css_class,
),
]
2 changes: 1 addition & 1 deletion backend/apps/nest/models/badge.py
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Comment thread
Isha-upadhyay marked this conversation as resolved.
CERTIFICATE = "certificate", "Certificate"
MEDAL = "medal", "Medal"
RIBBON = "ribbon", "Ribbon"
Expand Down
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -30,18 +30,25 @@ 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)
mock_user3 = MagicMock(id=3, title="User 3", contributions_count=0)

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

Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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)

Expand All @@ -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
Expand All @@ -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)
Expand Down Expand Up @@ -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
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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")
Expand Down
12 changes: 2 additions & 10 deletions frontend/__tests__/unit/components/Badges.test.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -92,25 +92,17 @@ 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(<Badges name={`${backendIcon.cssClass} Badge`} cssClass={backendIcon.cssClass} />)

const icon = screen.getByTestId('badge-icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('data-icon', backendIcon.expectedIcon)
})
}

it('handles camelCase input directly', () => {
render(<Badges name="Bug Slash Badge" cssClass="bugSlash" />)

const icon = screen.getByTestId('badge-icon')
expect(icon).toBeInTheDocument()
expect(icon).toHaveAttribute('data-icon', 'bug')
})
})
})
13 changes: 4 additions & 9 deletions frontend/src/components/Badges.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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) => {
Expand Down