diff --git a/backend/apps/github/models/organization.py b/backend/apps/github/models/organization.py index 5e2d37920e..b1fadf90d6 100644 --- a/backend/apps/github/models/organization.py +++ b/backend/apps/github/models/organization.py @@ -86,9 +86,9 @@ def get_logins(): return set(Organization.objects.values_list("login", flat=True)) @staticmethod - def bulk_save(organizations) -> None: # type: ignore[override] + def bulk_save(organizations, fields=None) -> None: # type: ignore[override] """Bulk save organizations.""" - BulkSaveModel.bulk_save(Organization, organizations) + BulkSaveModel.bulk_save(Organization, organizations, fields=fields) @staticmethod def update_data(gh_organization, *, save: bool = True) -> Organization: diff --git a/backend/apps/nest/Makefile b/backend/apps/nest/Makefile index bef915bba0..1469e0ae9a 100644 --- a/backend/apps/nest/Makefile +++ b/backend/apps/nest/Makefile @@ -1,3 +1,11 @@ -nest-update-badges: - @echo "Updating Nest user badges" - @CMD="python manage.py nest_update_badges" $(MAKE) exec-backend-command +nest-update-project-leader-badges: + @echo "Updating OWASP Project Leader badges" + @CMD="python manage.py nest_update_project_leader_badges" $(MAKE) exec-backend-command + +nest-update-staff-badges: + @echo "Updating OWASP Staff badges" + @CMD="python manage.py nest_update_staff_badges" $(MAKE) exec-backend-command + +nest-update-badges: \ + nest-update-project-leader-badges \ + nest-update-staff-badges diff --git a/backend/apps/nest/management/commands/base_badge_command.py b/backend/apps/nest/management/commands/base_badge_command.py new file mode 100644 index 0000000000..cf335697d4 --- /dev/null +++ b/backend/apps/nest/management/commands/base_badge_command.py @@ -0,0 +1,90 @@ +"""Base command for badge management.""" + +import logging +from abc import ABC, abstractmethod + +from django.core.management.base import BaseCommand +from django.db.models import QuerySet +from django.template.defaultfilters import pluralize + +from apps.github.models.user import User +from apps.nest.models.badge import Badge +from apps.nest.models.user_badge import UserBadge + +logger = logging.getLogger(__name__) + + +class BaseBadgeCommand(BaseCommand, ABC): + """Base class for badge sync commands.""" + + badge_css_class: str | None = None + badge_description: str | None = None + badge_name: str | None = None + badge_weight: int | None = None + + @abstractmethod + def get_eligible_users(self) -> QuerySet[User]: + """Return users who should have this badge.""" + + def _log(self, message): + logger.info(message) + self.stdout.write(message) + + def handle(self, *args, **options): + if not self.badge_name: + msg = "Badge name must be set" + raise ValueError(msg) + + self.stdout.write(f"Syncing {self.badge_name}...") + + try: + badge, created = Badge.objects.get_or_create( + name=self.badge_name, + defaults={ + "css_class": self.badge_css_class, + "description": self.badge_description, + "weight": self.badge_weight, + }, + ) + + if created: + self._log(f"Created badge: '{badge.name}'") + else: + badge.css_class = self.badge_css_class + badge.description = self.badge_description + badge.weight = self.badge_weight + badge.save(update_fields=["css_class", "description", "weight"]) + + eligible_users = self.get_eligible_users() + users_to_add_ids = eligible_users.exclude( + user_badges__badge=badge, + user_badges__is_active=True, + ) + + new_badges = [ + UserBadge(user=user, badge=badge, is_active=True) for user in users_to_add_ids + ] + UserBadge.bulk_save(new_badges, fields=["is_active"]) + added_count = len(new_badges) + self._log( + f"Added '{self.badge_name}' badge to {added_count} user{pluralize(added_count)}" + ) + + users_to_remove = UserBadge.objects.filter( + badge=badge, + is_active=True, + ).exclude(user__in=eligible_users) + + removed_count = users_to_remove.count() + if removed_count: + users_to_remove.update(is_active=False) + self._log( + f"Removed '{self.badge_name}' badge from {removed_count} " + f"user{pluralize(removed_count)}" + ) + + self.stdout.write(self.style.SUCCESS(f"{self.badge_name} synced successfully")) + except Exception: + logger.exception("Failed to sync %s", self.badge_name) + self.stdout.write(self.style.ERROR(f"{self.badge_name} sync failed")) + raise diff --git a/backend/apps/nest/management/commands/nest_update_badges.py b/backend/apps/nest/management/commands/nest_update_badges.py deleted file mode 100644 index 485469c90d..0000000000 --- a/backend/apps/nest/management/commands/nest_update_badges.py +++ /dev/null @@ -1,153 +0,0 @@ -"""Management command to sync badges for users based on their roles/attributes.""" - -import logging - -from django.contrib.contenttypes.models import ContentType -from django.core.management.base import BaseCommand - -from apps.github.models.user import User -from apps.nest.models.badge import Badge -from apps.nest.models.user_badge import UserBadge -from apps.owasp.models.entity_member import EntityMember -from apps.owasp.models.project import Project - -logger = logging.getLogger(__name__) - -OWASP_STAFF_BADGE_NAME = "OWASP Staff" -OWASP_PROJECT_LEADER_BADGE_NAME = "OWASP Project Leader" - - -class Command(BaseCommand): - """Sync badges for users based on their roles and attributes.""" - - help = "Sync badges for users based on their roles and attributes" - - def handle(self, *args, **options): - """Execute the command.""" - self.stdout.write("Syncing user badges...") - self.update_owasp_staff_badge() - self.update_owasp_project_leader_badge() - self.stdout.write(self.style.SUCCESS("User badges sync completed")) - - def update_owasp_staff_badge(self): - """Sync OWASP Staff badge for users.""" - # Get or create the OWASP Staff badge - badge, created = Badge.objects.get_or_create( - name=OWASP_STAFF_BADGE_NAME, - defaults={ - "description": "Official OWASP Staff", - "css_class": "fa-user-shield", - "weight": 100, # High weight for importance - }, - ) - - if created: - logger.info("Created '%s' badge", OWASP_STAFF_BADGE_NAME) - 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( - user_badges__badge=badge, - ) - count = employees_without_badge.count() - - if count: - for user in employees_without_badge: - user_badge, created = UserBadge.objects.get_or_create(user=user, badge=badge) - if not user_badge.is_active: - user_badge.is_active = True - user_badge.save(update_fields=["is_active"]) - - logger.info("Added '%s' badge to %s users", OWASP_STAFF_BADGE_NAME, count) - self.stdout.write(f"Added badge to {count} employees") - - # Remove badge from non-OWASP employees. - non_employees = User.objects.filter( - is_owasp_staff=False, - user_badges__badge=badge, - ).distinct() - removed_count = non_employees.count() - - if removed_count: - UserBadge.objects.filter( - user_id__in=non_employees.values_list("id", flat=True), - badge=badge, - ).update(is_active=False) - - logger.info("Removed '%s' badge from %s users", OWASP_STAFF_BADGE_NAME, removed_count) - self.stdout.write(f"Removed badge from {removed_count} non-employees") - - def update_owasp_project_leader_badge(self): - """Sync OWASP Project Leader badge for users.""" - badge, created = Badge.objects.get_or_create( - name=OWASP_PROJECT_LEADER_BADGE_NAME, - defaults={ - "description": "Official OWASP Project Leader", - "css_class": "fa-user-shield", - "weight": 90, - }, - ) - - if created: - logger.info("Created '%s' badge", OWASP_PROJECT_LEADER_BADGE_NAME) - self.stdout.write(f"Created badge: {badge.name}") - - project_leaders_without_badge = ( - User.objects.filter( - id__in=EntityMember.objects.filter( - entity_type=ContentType.objects.get_for_model(Project), - role=EntityMember.Role.LEADER, - is_active=True, - is_reviewed=True, - member__isnull=False, - ).values_list("member_id", flat=True), - ) - .distinct() - .exclude( - user_badges__badge=badge, - ) - ) - - count = project_leaders_without_badge.count() - - if count: - for user in project_leaders_without_badge: - user_badge, created = UserBadge.objects.get_or_create( - user=user, - badge=badge, - ) - if not user_badge.is_active: - user_badge.is_active = True - user_badge.save(update_fields=["is_active"]) - - logger.info("Added '%s' badge to %s users", OWASP_PROJECT_LEADER_BADGE_NAME, count) - self.stdout.write(f"Added badge to {count} project leaders") - - # Remove badge from users who are no longer project leaders. - current_leader_ids = ( - EntityMember.objects.filter( - entity_type=ContentType.objects.get_for_model(Project), - role=EntityMember.Role.LEADER, - is_active=True, - is_reviewed=True, - member__isnull=False, - ) - .values_list("member_id", flat=True) - .distinct() - ) - - non_leaders = UserBadge.objects.filter( - badge=badge, - is_active=True, - ).exclude(user_id__in=current_leader_ids) - removed_count = non_leaders.count() - - if removed_count: - non_leaders.update(is_active=False) - - logger.info( - "Removed '%s' badge from %s users", OWASP_PROJECT_LEADER_BADGE_NAME, removed_count - ) - self.stdout.write(f"Removed badge from {removed_count} non-project leaders") 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 new file mode 100644 index 0000000000..346f277750 --- /dev/null +++ b/backend/apps/nest/management/commands/nest_update_project_leader_badges.py @@ -0,0 +1,29 @@ +"""Sync OWASP Project Leader badges.""" + +from django.contrib.contenttypes.models import ContentType +from django.db.models import QuerySet + +from apps.github.models.user import User +from apps.nest.management.commands.base_badge_command import BaseBadgeCommand +from apps.owasp.models.entity_member import EntityMember +from apps.owasp.models.project import Project + + +class Command(BaseBadgeCommand): + help = "Sync OWASP Project Leader badges" + + badge_css_class = "fa-user-shield" + badge_description = "Official OWASP Project Leader" + badge_name = "OWASP Project Leader" + badge_weight = 90 + + def get_eligible_users(self) -> QuerySet[User]: + return User.objects.filter( + id__in=EntityMember.objects.filter( + entity_type=ContentType.objects.get_for_model(Project), + is_active=True, + is_reviewed=True, + member__isnull=False, + role=EntityMember.Role.LEADER, + ).values_list("member_id", flat=True) + ).distinct() diff --git a/backend/apps/nest/management/commands/nest_update_staff_badges.py b/backend/apps/nest/management/commands/nest_update_staff_badges.py new file mode 100644 index 0000000000..12080c2816 --- /dev/null +++ b/backend/apps/nest/management/commands/nest_update_staff_badges.py @@ -0,0 +1,18 @@ +"""Sync OWASP Staff badges.""" + +from django.db.models import QuerySet + +from apps.github.models.user import User +from apps.nest.management.commands.base_badge_command import BaseBadgeCommand + + +class Command(BaseBadgeCommand): + help = "Sync OWASP Staff badges" + + badge_css_class = "fa-user-shield" + badge_description = "Official OWASP Staff" + badge_name = "OWASP Staff" + badge_weight = 100 + + def get_eligible_users(self) -> QuerySet[User]: + return User.objects.filter(is_owasp_staff=True) diff --git a/backend/apps/nest/models/user_badge.py b/backend/apps/nest/models/user_badge.py index dc821a2875..d034cba97f 100644 --- a/backend/apps/nest/models/user_badge.py +++ b/backend/apps/nest/models/user_badge.py @@ -48,3 +48,8 @@ class Meta: def __str__(self) -> str: """Return a human-readable representation of the user badge.""" return f"{self.user.login} - {self.badge.name}" + + @staticmethod + def bulk_save(user_badges, fields=None) -> None: # type: ignore[override] + """Bulk save user badges.""" + BulkSaveModel.bulk_save(UserBadge, user_badges, fields=fields) diff --git a/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv b/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv new file mode 100644 index 0000000000..e56e51725d Binary files /dev/null and b/backend/generated_videos/community_snapshot_video_2025/2025_snapshot.mkv differ diff --git a/backend/tests/apps/github/models/organization_test.py b/backend/tests/apps/github/models/organization_test.py index a7f6dded08..35983a60a9 100644 --- a/backend/tests/apps/github/models/organization_test.py +++ b/backend/tests/apps/github/models/organization_test.py @@ -12,7 +12,7 @@ def test_bulk_save(self): mock_org = [Mock(id=None), Mock(id=1)] with patch("apps.common.models.BulkSaveModel.bulk_save") as mock_bulk_save: Organization.bulk_save(mock_org) - mock_bulk_save.assert_called_once_with(Organization, mock_org) + mock_bulk_save.assert_called_once_with(Organization, mock_org, fields=None) @patch("apps.github.models.organization.Organization.objects.get") def test_update_data(self, mock_get): diff --git a/backend/tests/apps/nest/management/commands/base_badge_command_test.py b/backend/tests/apps/nest/management/commands/base_badge_command_test.py new file mode 100644 index 0000000000..702b1be905 --- /dev/null +++ b/backend/tests/apps/nest/management/commands/base_badge_command_test.py @@ -0,0 +1,51 @@ +"""Tests for base badge command.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.test import SimpleTestCase + +from apps.nest.management.commands.base_badge_command import BaseBadgeCommand + + +class MockCommand(BaseBadgeCommand): + badge_css_class = "fa-test" + badge_description = "Test" + badge_name = "Test Badge" + badge_weight = 50 + + def get_eligible_users(self): + return MagicMock() + + +class TestBaseBadgeCommand(SimpleTestCase): + def test_requires_badge_name(self): + class NoName(BaseBadgeCommand): + def get_eligible_users(self): + return MagicMock() + + with pytest.raises(ValueError, match="Badge name"): + NoName().handle() + + @patch("apps.nest.management.commands.base_badge_command.UserBadge") + @patch("apps.nest.management.commands.base_badge_command.Badge") + def test_syncs_badge(self, mock_badge, mock_user_badge): + badge = MagicMock() + badge.name = "Test Badge" + mock_badge.objects.get_or_create.return_value = (badge, False) + + qs = MagicMock() + qs.exclude.return_value = [] + MockCommand.get_eligible_users = MagicMock(return_value=qs) + + mock_user_badge.objects.filter.return_value.exclude.return_value.count.return_value = 0 + + out = StringIO() + cmd = MockCommand() + cmd.stdout = out + cmd.handle() + + output = out.getvalue() + assert "Test Badge" in output + assert "synced successfully" in output diff --git a/backend/tests/apps/nest/management/commands/nest_update_badges_test.py b/backend/tests/apps/nest/management/commands/nest_update_badges_test.py deleted file mode 100644 index 56b708e54c..0000000000 --- a/backend/tests/apps/nest/management/commands/nest_update_badges_test.py +++ /dev/null @@ -1,339 +0,0 @@ -"""Tests for the nest_update_badges management command.""" - -from io import StringIO -from unittest.mock import MagicMock, patch - -from django.core.management import call_command - -from apps.nest.management.commands.nest_update_badges import ( - OWASP_PROJECT_LEADER_BADGE_NAME, - OWASP_STAFF_BADGE_NAME, -) - - -def make_mock_employees(mock_employee): - mock_employees = MagicMock() - mock_employees_without_badge = MagicMock() - # This makes iterating over employees_without_badge yield mock_employee (not a list) - mock_employees_without_badge.__iter__.return_value = iter([mock_employee]) - mock_employees_without_badge.count.return_value = 1 - mock_employees_without_badge.values_list.return_value = [mock_employee.id] - mock_employees_without_badge.distinct.return_value = mock_employees_without_badge - mock_employees.exclude.return_value = mock_employees_without_badge - return mock_employees, mock_employees_without_badge - - -def make_mock_former_employees(mock_former_employee): - mock_former_employees = MagicMock() - mock_former_employees.__iter__.return_value = iter([mock_former_employee]) - mock_former_employees.count.return_value = 1 - mock_former_employees.values_list.return_value = [mock_former_employee.id] - mock_former_employees.distinct.return_value = mock_former_employees - return mock_former_employees - - -def make_mock_project_leaders(mock_leader): - """Create mock objects for project leaders query chain.""" - mock_filtered_leaders = MagicMock() - mock_distinct_leaders = MagicMock() - mock_leaders_without_badge = MagicMock() - mock_leaders_without_badge.__iter__.return_value = iter([mock_leader]) - mock_leaders_without_badge.count.return_value = 1 - mock_filtered_leaders.distinct.return_value = mock_distinct_leaders - mock_distinct_leaders.exclude.return_value = mock_leaders_without_badge - return mock_filtered_leaders - - -def extract_is_owasp_staff(arg): - """Extract is_owasp_staff value from Q object, dict, or tuple.""" - if hasattr(arg, "children"): - for key, value in arg.children: - if key == "is_owasp_staff": - return value - if isinstance(arg, dict) and "is_owasp_staff" in arg: - return arg["is_owasp_staff"] - if isinstance(arg, tuple) and len(arg) == 2 and arg[0] == "is_owasp_staff": - return arg[1] - return None - - -def user_filter_side_effect_factory(mock_employees, mock_former_employees): - """Create a side effect function for User.objects.filter.""" - - def get_mock_for_staff_value(value): - if value is True: - return mock_employees - if value is False: - return mock_former_employees - return None - - def user_filter_side_effect(*args, **kwargs): - staff_value = kwargs.get("is_owasp_staff") - if staff_value is not None: - return get_mock_for_staff_value(staff_value) - for arg in args: - staff_value = extract_is_owasp_staff(arg) - result = get_mock_for_staff_value(staff_value) - if result: - return result - return MagicMock() - - return user_filter_side_effect - - -class TestSyncUserBadgesCommand: - """Tests for the nest_update_badges management command.""" - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_sync_owasp_staff_badge( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get, - mock_entity_member_filter, - ): - # Set up badge mock - mock_badge = MagicMock() - mock_badge.name = OWASP_STAFF_BADGE_NAME - mock_badge.id = 1 - mock_badge_get_or_create.return_value = (mock_badge, False) - - # Set up employee mocks - mock_employee = MagicMock() - mock_former_employee = MagicMock() - mock_employee.id = 456 - mock_former_employee.id = 123 - mock_employees, _ = make_mock_employees(mock_employee) - mock_former_employees = make_mock_former_employees(mock_former_employee) - - mock_user_filter.side_effect = user_filter_side_effect_factory( - mock_employees, mock_former_employees - ) - - mock_user_badge_get_or_create.return_value = (MagicMock(), True) - - out = StringIO() - call_command("nest_update_badges", stdout=out) - - # Assert correct badge assignment and removal calls - mock_user_badge_get_or_create.assert_any_call(user=mock_employee, badge=mock_badge) - mock_user_badge_filter.assert_any_call( - user_id__in=mock_former_employees.values_list.return_value, badge=mock_badge - ) - - # Check command output - output = out.getvalue() - assert "User badges sync completed" in output - assert any(s in output for s in ("Added badge to 1 staff", "Added badge to 1 employees")) - assert any( - s in output - for s in ("Removed badge from 1 non-staff", "Removed badge from 1 non-employees") - ) - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_sync_owasp_project_leader_badge( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get_for_model, - mock_entity_member_filter, - ): - mock_badge = MagicMock() - mock_badge.name = OWASP_PROJECT_LEADER_BADGE_NAME - mock_badge_get_or_create.return_value = (mock_badge, False) - mock_leader = MagicMock() - mock_leader.id = 999 - mock_project_leaders = make_mock_project_leaders(mock_leader) - - def user_filter_side_effect(*_args, **kwargs): - if "id__in" in kwargs: - return mock_project_leaders - return MagicMock() - - mock_user_filter.side_effect = user_filter_side_effect - mock_user_badge_get_or_create.return_value = (MagicMock(), True) - mock_entity_qs = MagicMock() - mock_values_qs = MagicMock() - mock_distinct_qs = MagicMock() - mock_entity_member_filter.return_value = mock_entity_qs - mock_entity_qs.values_list.return_value = mock_values_qs - mock_values_qs.distinct.return_value = mock_distinct_qs - mock_distinct_qs.__iter__.return_value = iter([mock_leader.id]) - out = StringIO() - call_command("nest_update_badges", stdout=out) - - mock_badge_get_or_create.assert_any_call( - name=OWASP_PROJECT_LEADER_BADGE_NAME, - defaults={ - "description": "Official OWASP Project Leader", - "css_class": "fa-user-shield", - "weight": 90, - }, - ) - - mock_user_badge_get_or_create.assert_any_call(user=mock_leader, badge=mock_badge) - - output = out.getvalue() - assert "Added badge to 1 project leaders" in output - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_badge_creation( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get, - mock_entity_member_filter, - ): - mock_badge = MagicMock() - mock_badge.name = OWASP_STAFF_BADGE_NAME - mock_badge_get_or_create.return_value = (mock_badge, True) - mock_employees = MagicMock() - mock_employees.__iter__.return_value = iter([]) - mock_employees.count.return_value = 0 - mock_employees.exclude.return_value = mock_employees - mock_employees.values_list.return_value = [] - mock_employees.exclude.return_value.values_list.return_value = [] - mock_employees.exclude.return_value.distinct.return_value = mock_employees - mock_employees.exclude.return_value.distinct.return_value.values_list.return_value = [] - - mock_former_employees = MagicMock() - mock_former_employees.__iter__.return_value = iter([]) - mock_former_employees.count.return_value = 0 - mock_former_employees.values_list.return_value = [] - mock_former_employees.distinct.return_value = mock_former_employees - - mock_user_filter.side_effect = [ - mock_employees, - mock_former_employees, - mock_employees, - ] - mock_entity_qs = MagicMock() - mock_values_qs = MagicMock() - mock_distinct_qs = MagicMock() - mock_entity_member_filter.return_value = mock_entity_qs - mock_entity_qs.values_list.return_value = mock_values_qs - mock_values_qs.distinct.return_value = mock_distinct_qs - mock_distinct_qs.__iter__.return_value = iter([]) - - out = StringIO() - call_command("nest_update_badges", stdout=out) - - mock_badge_get_or_create.assert_any_call( - name=OWASP_STAFF_BADGE_NAME, - defaults={ - "description": "Official OWASP Staff", - "css_class": "fa-user-shield", - "weight": 100, - }, - ) - output = out.getvalue() - assert f"Created badge: {mock_badge.name}" in output - - @patch("apps.nest.management.commands.nest_update_badges.EntityMember.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.ContentType.objects.get_for_model") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.UserBadge.objects.filter") - @patch("apps.nest.management.commands.nest_update_badges.Badge.objects.get_or_create") - @patch("apps.nest.management.commands.nest_update_badges.User.objects.filter") - def test_command_idempotency( - self, - mock_user_filter, - mock_badge_get_or_create, - mock_user_badge_filter, - mock_user_badge_get_or_create, - mock_content_type_get, - mock_entity_member_filter, - ): - """Test that running the command multiple times has the same effect as running it once.""" - # Set up badge mock - mock_badge = MagicMock() - mock_badge.name = OWASP_STAFF_BADGE_NAME - mock_badge.id = 1 - mock_badge_get_or_create.return_value = (mock_badge, False) - - # Set up employee mock that already has the badge - mock_employee_with_badge = MagicMock() - mock_employees = MagicMock() - mock_employees.__iter__.return_value = iter([mock_employee_with_badge]) - mock_employees.exclude.return_value = MagicMock() - mock_employees.exclude.return_value.count.return_value = 0 - mock_employees.exclude.return_value.values_list.return_value = [] - mock_employees.exclude.return_value.distinct.return_value = ( - mock_employees.exclude.return_value - ) - - # No former employees have the badge - mock_non_employees_filter = MagicMock() - mock_non_employees_filter.count.return_value = 0 - mock_non_employees_filter.__iter__.return_value = iter([]) - mock_non_employees_filter.values_list.return_value = [] - mock_non_employees_filter.distinct.return_value = mock_non_employees_filter - - mock_leaders = MagicMock() - mock_leaders.distinct.return_value = mock_leaders - mock_leaders.exclude.return_value = mock_leaders - mock_leaders.count.return_value = 0 - - # Configure filter side effects for two command runs - mock_user_filter.side_effect = [ - mock_employees, - mock_non_employees_filter, - mock_leaders, - mock_employees, - mock_non_employees_filter, - mock_leaders, - ] - mock_entity_qs = MagicMock() - mock_values_qs = MagicMock() - mock_distinct_qs = MagicMock() - mock_entity_member_filter.return_value = mock_entity_qs - mock_entity_qs.values_list.return_value = mock_values_qs - mock_values_qs.distinct.return_value = mock_distinct_qs - mock_distinct_qs.__iter__.return_value = iter([]) - mock_user_badge_filter.return_value.count.return_value = 0 - mock_user_badge_filter.return_value.exclude.return_value.count.return_value = 0 - - # First run - out1 = StringIO() - call_command("nest_update_badges", stdout=out1) - - # Second run - out2 = StringIO() - call_command("nest_update_badges", stdout=out2) - - # Check both outputs contain zero-count messages - assert any( - s in out1.getvalue() for s in ("Added badge to 0 employees", "Added badge to 0 staff") - ) - assert any( - s in out1.getvalue() - for s in ("Removed badge from 0 non-employees", "Removed badge from 0 non-staff") - ) - assert any( - s in out2.getvalue() for s in ("Added badge to 0 employees", "Added badge to 0 staff") - ) - assert any( - s in out2.getvalue() - for s in ("Removed badge from 0 non-employees", "Removed badge from 0 non-staff") - ) 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 new file mode 100644 index 0000000000..512974ce4b --- /dev/null +++ b/backend/tests/apps/nest/management/commands/nest_update_project_leader_badges_test.py @@ -0,0 +1,59 @@ +"""Tests for project leader badge command.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.test import SimpleTestCase + +from apps.nest.management.commands.nest_update_project_leader_badges import Command + + +class TestProjectLeaderBadgeCommand(SimpleTestCase): + def test_has_correct_metadata(self): + assert Command.badge_name == "OWASP Project Leader" + assert Command.badge_weight == 90 + + @patch("apps.nest.management.commands.nest_update_project_leader_badges.User") + @patch("apps.nest.management.commands.nest_update_project_leader_badges.EntityMember") + @patch("apps.nest.management.commands.nest_update_project_leader_badges.Project") + @patch("apps.nest.management.commands.nest_update_project_leader_badges.ContentType") + @patch("apps.nest.management.commands.base_badge_command.UserBadge") + @patch("apps.nest.management.commands.base_badge_command.Badge") + def test_command_runs( + self, mock_badge, mock_user_badge, mock_ct, mock_project, mock_em, mock_user + ): + badge = MagicMock() + badge.name = "OWASP Project Leader" + mock_badge.objects.get_or_create.return_value = (badge, False) + + mock_ct.objects.get_for_model.return_value = MagicMock() + leaders_qs = MagicMock() + mock_em.objects.filter.return_value = leaders_qs + leaders_qs.values_list.return_value = [] + + qs = MagicMock() + qs.exclude.return_value = [] + qs.distinct.return_value = qs + mock_user.objects.filter.return_value = qs + mock_user_badge.objects.filter.return_value.exclude.return_value.count.return_value = 0 + + out = StringIO() + call_command("nest_update_project_leader_badges", stdout=out) + assert "Project Leader" in out.getvalue() + + @patch("apps.nest.management.commands.nest_update_project_leader_badges.ContentType") + @patch("apps.nest.management.commands.base_badge_command.Badge") + @patch( + "apps.nest.management.commands.nest_update_project_leader_badges.EntityMember.objects.filter", + side_effect=Exception("error"), + ) + def test_handles_errors(self, mock_filter, mock_badge, mock_content_type): + badge = MagicMock() + badge.name = "OWASP Project Leader" + mock_badge.objects.get_or_create.return_value = (badge, False) + mock_content_type.objects.get_for_model.return_value = MagicMock() + + with pytest.raises(Exception, match="error"): + call_command("nest_update_project_leader_badges", stdout=StringIO()) 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 new file mode 100644 index 0000000000..bc04cbe71a --- /dev/null +++ b/backend/tests/apps/nest/management/commands/nest_update_staff_badges_test.py @@ -0,0 +1,46 @@ +"""Tests for staff badge command.""" + +from io import StringIO +from unittest.mock import MagicMock, patch + +import pytest +from django.core.management import call_command +from django.test import SimpleTestCase + +from apps.nest.management.commands.nest_update_staff_badges import Command + + +class TestStaffBadgeCommand(SimpleTestCase): + def test_has_correct_metadata(self): + assert Command.badge_name == "OWASP Staff" + assert Command.badge_weight == 100 + + @patch("apps.nest.management.commands.nest_update_staff_badges.User") + @patch("apps.nest.management.commands.base_badge_command.UserBadge") + @patch("apps.nest.management.commands.base_badge_command.Badge") + def test_command_runs(self, mock_badge, mock_user_badge, mock_user): + badge = MagicMock() + badge.name = "OWASP Staff" + mock_badge.objects.get_or_create.return_value = (badge, False) + + qs = MagicMock() + qs.exclude.return_value = [] + mock_user.objects.filter.return_value = qs + mock_user_badge.objects.filter.return_value.exclude.return_value.count.return_value = 0 + + out = StringIO() + call_command("nest_update_staff_badges", stdout=out) + assert "OWASP Staff" in out.getvalue() + + @patch("apps.nest.management.commands.base_badge_command.Badge") + @patch( + "apps.nest.management.commands.nest_update_staff_badges.User.objects.filter", + side_effect=Exception("error"), + ) + def test_handles_errors(self, mock_filter, mock_badge): + badge = MagicMock() + badge.name = "OWASP Staff" + mock_badge.objects.get_or_create.return_value = (badge, False) + + with pytest.raises(Exception, match="error"): + call_command("nest_update_staff_badges", stdout=StringIO())