diff --git a/backend/apps/github/api/internal/nodes/user.py b/backend/apps/github/api/internal/nodes/user.py index edec212597..1d99857d97 100644 --- a/backend/apps/github/api/internal/nodes/user.py +++ b/backend/apps/github/api/internal/nodes/user.py @@ -4,6 +4,7 @@ import strawberry_django from apps.github.models.user import User +from apps.nest.api.internal.nodes.badge import BadgeNode @strawberry_django.type( @@ -27,6 +28,19 @@ class UserNode: """GitHub user node.""" + @strawberry.field + def badges(self) -> list[BadgeNode]: + """Return user badges.""" + user_badges = ( + self.user_badges.select_related("badge") + .filter(is_active=True) + .order_by( + "badge__weight", + "badge__name", + ) + ) + return [ub.badge for ub in user_badges] + @strawberry.field def created_at(self) -> float: """Resolve created at.""" diff --git a/backend/apps/nest/api/internal/nodes/badge.py b/backend/apps/nest/api/internal/nodes/badge.py new file mode 100644 index 0000000000..da4506e49c --- /dev/null +++ b/backend/apps/nest/api/internal/nodes/badge.py @@ -0,0 +1,20 @@ +"""GraphQL node for Badge model.""" + +import strawberry +import strawberry_django + +from apps.nest.models.badge import Badge + + +@strawberry_django.type( + Badge, + fields=[ + "css_class", + "description", + "id", + "name", + "weight", + ], +) +class BadgeNode(strawberry.relay.Node): + """GraphQL node for Badge model.""" diff --git a/backend/apps/nest/management/commands/nest_update_badges.py b/backend/apps/nest/management/commands/nest_update_badges.py index 16acfdd196..209313134c 100644 --- a/backend/apps/nest/management/commands/nest_update_badges.py +++ b/backend/apps/nest/management/commands/nest_update_badges.py @@ -41,8 +41,10 @@ def update_owasp_staff_badge(self): self.stdout.write(f"Created badge: {badge.name}") # Assign badge to employees who don't have it. - employees_without_badge = User.objects.filter(is_owasp_staff=True).exclude( - badges__badge=badge + employees_without_badge = User.objects.filter( + is_owasp_staff=True, + ).exclude( + user_badges__badge=badge, ) count = employees_without_badge.count() @@ -59,7 +61,7 @@ def update_owasp_staff_badge(self): # Remove badge from non-OWASP employees. non_employees = User.objects.filter( is_owasp_staff=False, - badges__badge=badge, + user_badges__badge=badge, ).distinct() removed_count = non_employees.count() diff --git a/backend/apps/nest/migrations/0005_alter_userbadge_user.py b/backend/apps/nest/migrations/0005_alter_userbadge_user.py new file mode 100644 index 0000000000..1fa86eb79c --- /dev/null +++ b/backend/apps/nest/migrations/0005_alter_userbadge_user.py @@ -0,0 +1,24 @@ +# Generated by Django 5.2.5 on 2025-09-06 09:41 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0035_alter_user_bio_alter_user_is_owasp_staff"), + ("nest", "0004_userbadge"), + ] + + operations = [ + migrations.AlterField( + model_name="userbadge", + name="user", + field=models.ForeignKey( + on_delete=django.db.models.deletion.CASCADE, + related_name="user_badges", + to="github.user", + verbose_name="User", + ), + ), + ] diff --git a/backend/apps/nest/models/user_badge.py b/backend/apps/nest/models/user_badge.py index 7e7965c3f4..dc821a2875 100644 --- a/backend/apps/nest/models/user_badge.py +++ b/backend/apps/nest/models/user_badge.py @@ -40,7 +40,7 @@ class Meta: ) user = models.ForeignKey( "github.User", - related_name="badges", + related_name="user_badges", on_delete=models.CASCADE, verbose_name="User", ) diff --git a/backend/tests/apps/github/api/internal/nodes/user_test.py b/backend/tests/apps/github/api/internal/nodes/user_test.py index 01237ad925..f83e831433 100644 --- a/backend/tests/apps/github/api/internal/nodes/user_test.py +++ b/backend/tests/apps/github/api/internal/nodes/user_test.py @@ -19,6 +19,7 @@ def test_meta_configuration(self): field_names = {field.name for field in UserNode.__strawberry_definition__.fields} expected_field_names = { "avatar_url", + "badges", "bio", "company", "contributions_count", @@ -37,7 +38,8 @@ def test_meta_configuration(self): "updated_at", "url", } - assert field_names == expected_field_names + missing = expected_field_names - field_names + assert not missing, f"Missing fields on UserNode: {sorted(missing)}" def test_created_at_field(self): """Test created_at field resolution.""" @@ -78,3 +80,43 @@ def test_url_field(self): result = UserNode.url(mock_user) assert result == "https://github.com/testuser" + + def test_badges_resolver_behavior(self): + """Unit test verifies the badges method returns badges sorted by weight.""" + badge_high = Mock() + badge_high.weight = 1 + badge_high.name = "High Priority Badge" + + badge_medium = Mock() + badge_medium.weight = 5 + badge_medium.name = "Medium Priority Badge" + + badge_low = Mock() + badge_low.weight = 10 + badge_low.name = "Low Priority Badge" + + user_badge_high = Mock() + user_badge_high.badge = badge_high + + user_badge_medium = Mock() + user_badge_medium.badge = badge_medium + + user_badge_low = Mock() + user_badge_low.badge = badge_low + + sorted_user_badges = [user_badge_high, user_badge_medium, user_badge_low] + + mock_user = Mock() + mock_queryset = Mock() + mock_queryset.filter.return_value.order_by.return_value = sorted_user_badges + mock_user.user_badges.select_related.return_value = mock_queryset + + result = UserNode.badges(mock_user) + + mock_user.user_badges.select_related.assert_called_once_with("badge") + mock_queryset.filter.assert_called_once_with(is_active=True) + mock_queryset.filter.return_value.order_by.assert_called_once_with( + "badge__weight", "badge__name" + ) + + assert result == [ub.badge for ub in sorted_user_badges]