From ed40e15effd3e68440739730f507219b1e03ca61 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 6 Sep 2025 08:45:46 +0000 Subject: [PATCH 1/6] Add OWASP Award model --- backend/apps/owasp/admin/__init__.py | 1 + backend/apps/owasp/admin/award.py | 16 ++++++++ backend/apps/owasp/models/__init__.py | 1 + backend/apps/owasp/models/award.py | 59 +++++++++++++++++++++++++++ 4 files changed, 77 insertions(+) create mode 100644 backend/apps/owasp/admin/award.py create mode 100644 backend/apps/owasp/models/award.py diff --git a/backend/apps/owasp/admin/__init__.py b/backend/apps/owasp/admin/__init__.py index f8d37e846e..aa06cc5559 100644 --- a/backend/apps/owasp/admin/__init__.py +++ b/backend/apps/owasp/admin/__init__.py @@ -4,6 +4,7 @@ from apps.owasp.models.project_health_requirements import ProjectHealthRequirements +from .award import AwardAdmin from .chapter import ChapterAdmin from .committee import CommitteeAdmin from .entity_member import EntityMemberAdmin diff --git a/backend/apps/owasp/admin/award.py b/backend/apps/owasp/admin/award.py new file mode 100644 index 0000000000..86fe372123 --- /dev/null +++ b/backend/apps/owasp/admin/award.py @@ -0,0 +1,16 @@ +"""OWASP app award admin.""" + +from django.contrib import admin + +from apps.owasp.models.award import Award + + +@admin.register(Award) +class AwardAdmin(admin.ModelAdmin): + """Award admin.""" + + list_display = ("name", "category", "year", "user", "is_reviewed", "created_at") + list_filter = ("category", "year", "is_reviewed", "created_at") + search_fields = ("name", "category", "user__name", "user__login") + readonly_fields = ("created_at", "updated_at") + raw_id_fields = ("user",) diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index 6382d30f0d..b1375cbb7b 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -1,3 +1,4 @@ +from .award import Award from .board_of_directors import BoardOfDirectors from .chapter import Chapter from .committee import Committee diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py new file mode 100644 index 0000000000..5415d2e887 --- /dev/null +++ b/backend/apps/owasp/models/award.py @@ -0,0 +1,59 @@ +"""OWASP app award model.""" + +from __future__ import annotations + +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel + + +class Award(BulkSaveModel, TimestampedModel): + """Award model.""" + + class Meta: + db_table = "owasp_awards" + indexes = [ + models.Index(fields=["-year"], name="award_year_desc_idx"), + ] + verbose_name_plural = "Awards" + + category = models.CharField(verbose_name="Category", max_length=100) + name = models.CharField(verbose_name="Name", max_length=255, unique=True) + description = models.TextField(verbose_name="Description", blank=True, default="") + year = models.IntegerField(verbose_name="Year") + is_reviewed = models.BooleanField( + verbose_name="Is reviewed", + default=False, + help_text="Indicates if the user matching has been reviewed by a human.", + ) + + # FKs. + user = models.ForeignKey( + "github.User", + on_delete=models.SET_NULL, + blank=True, + null=True, + related_name="awards", + verbose_name="User", + ) + + def __str__(self) -> str: + """Award human readable representation.""" + return f"{self.name} ({self.year})" + + @staticmethod + def bulk_save( # type: ignore[override] + awards: list, + fields: tuple[str, ...] | None = None, + ) -> None: + """Bulk save awards. + + Args: + awards (list): A list of Award instances to be saved. + fields (tuple, optional): A tuple of fields to update during the bulk save. + + Returns: + None + + """ + BulkSaveModel.bulk_save(Award, awards, fields=fields) From e2e9a3fcf3dce3096965f42e058b2b237df033a2 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 6 Sep 2025 08:59:23 +0000 Subject: [PATCH 2/6] Add owasp_sync_awards management command --- .../management/commands/owasp_sync_awards.py | 87 +++++++++++++++++++ 1 file changed, 87 insertions(+) create mode 100644 backend/apps/owasp/management/commands/owasp_sync_awards.py diff --git a/backend/apps/owasp/management/commands/owasp_sync_awards.py b/backend/apps/owasp/management/commands/owasp_sync_awards.py new file mode 100644 index 0000000000..3060f46423 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -0,0 +1,87 @@ +"""A command to sync OWASP awards.""" + +import logging + +import yaml +from django.core.management.base import BaseCommand + +from apps.github.models.user import User +from apps.github.utils import get_repository_file_content +from apps.owasp.models.award import Award + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + help = "Import awards from the OWASP awards YAML file" + + def handle(self, *args, **kwargs) -> None: + """Handle the command execution.""" + self.stdout.write("Syncing OWASP awards...") + + data = yaml.safe_load( + get_repository_file_content( + "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/awards.yml" + ) + ) + + awards_to_save = [] + for item in data: + if item.get("type") == "award": + winners = item.get("winners", []) + for winner in winners: + award = self._create_or_update_award(item, winner) + if award: + awards_to_save.append(award) + + Award.bulk_save(awards_to_save) + self.stdout.write(self.style.SUCCESS(f"Successfully synced {len(awards_to_save)} awards")) + + def _create_or_update_award(self, award_data, winner_data): + """Create or update award instance.""" + name = f"{award_data['title']} - {winner_data['name']} ({award_data['year']})" + + try: + award = Award.objects.get(name=name) + except Award.DoesNotExist: + award = Award(name=name) + + award.category = award_data.get("category", "") + award.description = winner_data.get("info", "") + award.year = award_data.get("year", 0) + + # Try to match user by name + user = self._match_user(winner_data["name"]) + if user: + award.user = user + else: + logger.warning("Could not match user for award winner: %s", winner_data["name"]) + + return award + + def _match_user(self, winner_name): + """Try to match award winner with existing user.""" + # Try exact name match first + user = User.objects.filter(name__iexact=winner_name).first() + if user: + return user + + # Try login match (GitHub username) + user = User.objects.filter(login__iexact=winner_name).first() + if user: + return user + + # Try partial name match + name_parts = winner_name.split() + min_name_parts = 2 + if len(name_parts) >= min_name_parts: + first_name, last_name = name_parts[0], name_parts[-1] + user = ( + User.objects.filter(name__icontains=first_name) + .filter(name__icontains=last_name) + .first() + ) + if user: + return user + + return None From 76eb1ba7c9280d5151fb37e60b0cd4aae658c41b Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 6 Sep 2025 09:02:19 +0000 Subject: [PATCH 3/6] Add WASPY Award Winner badge with human review --- .../management/commands/nest_update_badges.py | 54 +++++++++++++++++++ 1 file changed, 54 insertions(+) diff --git a/backend/apps/nest/management/commands/nest_update_badges.py b/backend/apps/nest/management/commands/nest_update_badges.py index 209313134c..cbaadb8e7d 100644 --- a/backend/apps/nest/management/commands/nest_update_badges.py +++ b/backend/apps/nest/management/commands/nest_update_badges.py @@ -11,6 +11,7 @@ logger = logging.getLogger(__name__) OWASP_STAFF_BADGE_NAME = "OWASP Staff" +WASPY_AWARD_BADGE_NAME = "WASPY Award Winner" class Command(BaseCommand): @@ -22,6 +23,7 @@ def handle(self, *args, **options): """Execute the command.""" self.stdout.write("Syncing user badges...") self.update_owasp_staff_badge() + self.update_waspy_award_badge() self.stdout.write(self.style.SUCCESS("User badges sync completed")) def update_owasp_staff_badge(self): @@ -73,3 +75,55 @@ def update_owasp_staff_badge(self): 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_waspy_award_badge(self): + """Sync WASPY Award Winner badge for users.""" + # Get or create the WASPY Award Winner badge + badge, created = Badge.objects.get_or_create( + name=WASPY_AWARD_BADGE_NAME, + defaults={ + "description": "WASPY Award Winner", + "css_class": "fa-trophy", + "weight": 90, + }, + ) + + if created: + logger.info("Created '%s' badge", WASPY_AWARD_BADGE_NAME) + self.stdout.write(f"Created badge: {badge.name}") + + # Get users with WASPY awards that have been reviewed + waspy_award_users = User.objects.filter( + awards__category="WASPY", awards__is_reviewed=True + ).distinct() + + # Assign badge to WASPY award winners who don't have it + users_without_badge = waspy_award_users.exclude(badges__badge=badge) + count = users_without_badge.count() + + if count: + for user in users_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", WASPY_AWARD_BADGE_NAME, count) + self.stdout.write(f"Added badge to {count} WASPY award winners") + + # Remove badge from users who no longer have reviewed WASPY awards + non_waspy_users = ( + User.objects.exclude(awards__category="WASPY", awards__is_reviewed=True) + .filter(badges__badge=badge) + .distinct() + ) + removed_count = non_waspy_users.count() + + if removed_count: + UserBadge.objects.filter( + user_id__in=non_waspy_users.values_list("id", flat=True), + badge=badge, + ).update(is_active=False) + + logger.info("Removed '%s' badge from %s users", WASPY_AWARD_BADGE_NAME, removed_count) + self.stdout.write(f"Removed badge from {removed_count} users without WASPY awards") From a107a50e6d7fe8a0a5ed4a3a4f814a3750215cc7 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 6 Sep 2025 09:03:46 +0000 Subject: [PATCH 4/6] Integrate award sync into build and data pipeline --- backend/Makefile | 2 ++ backend/apps/owasp/Makefile | 4 ++++ 2 files changed, 6 insertions(+) diff --git a/backend/Makefile b/backend/Makefile index 2b2d68c877..720a5588b1 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -110,6 +110,7 @@ shell-db: sync-data: \ update-data \ enrich-data \ + nest-update-badges \ index-data test-backend: @@ -137,4 +138,5 @@ update-data: \ owasp-update-events \ owasp-sync-posts \ owasp-update-sponsors \ + owasp-sync-awards \ slack-sync-data diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 4febcd2572..ab3d525232 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -63,3 +63,7 @@ owasp-update-events: owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command + +owasp-sync-awards: + @echo "Syncing OWASP awards data" + @CMD="python manage.py owasp_sync_awards" $(MAKE) exec-backend-command From a34befa68a36ea01d39fa1099dd7b18971a2b1a8 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 6 Sep 2025 09:10:41 +0000 Subject: [PATCH 5/6] Add tests for OWASP awards sync command --- .../commands/owasp_sync_awards_test.py | 37 +++++++++++++++++++ 1 file changed, 37 insertions(+) create mode 100644 backend/tests/apps/owasp/management/commands/owasp_sync_awards_test.py diff --git a/backend/tests/apps/owasp/management/commands/owasp_sync_awards_test.py b/backend/tests/apps/owasp/management/commands/owasp_sync_awards_test.py new file mode 100644 index 0000000000..04508c7e6f --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_sync_awards_test.py @@ -0,0 +1,37 @@ +"""Tests for owasp_sync_awards management command.""" + +from unittest.mock import patch + +from django.test import TestCase + +from apps.owasp.management.commands.owasp_sync_awards import Command +from apps.owasp.models.award import Award + + +class OwaspSyncAwardsTest(TestCase): + """Test owasp_sync_awards command.""" + + @patch("apps.owasp.management.commands.owasp_sync_awards.get_repository_file_content") + def test_sync_awards(self, mock_get_content): + """Test syncing awards from YAML data.""" + mock_get_content.return_value = """ +- title: WASPY + type: category + description: Test category +- title: Project Person of the Year + type: award + category: WASPY + year: 2024 + winners: + - name: Test Winner + info: Test winner info +""" + + command = Command() + command.handle() + + # Check that award was created + award = Award.objects.get(name="Project Person of the Year - Test Winner (2024)") + assert award.category == "WASPY" + assert award.year == 2024 + assert award.description == "Test winner info" From 6513f7b088d27074236a760cda5a7b2abbb9195a Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 6 Sep 2025 09:57:39 +0000 Subject: [PATCH 6/6] Fix code review issues and improve robustness --- .../management/commands/nest_update_badges.py | 22 ++-- .../management/commands/owasp_sync_awards.py | 103 ++++++++++++++---- backend/apps/owasp/models/award.py | 8 +- 3 files changed, 100 insertions(+), 33 deletions(-) diff --git a/backend/apps/nest/management/commands/nest_update_badges.py b/backend/apps/nest/management/commands/nest_update_badges.py index cbaadb8e7d..e3ecb47c66 100644 --- a/backend/apps/nest/management/commands/nest_update_badges.py +++ b/backend/apps/nest/management/commands/nest_update_badges.py @@ -43,16 +43,14 @@ 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( - user_badges__badge=badge, + employees_missing_or_inactive = User.objects.filter(is_owasp_staff=True).exclude( + badges__badge=badge, badges__is_active=True ) - count = employees_without_badge.count() + count = employees_missing_or_inactive.count() if count: - for user in employees_without_badge: - user_badge, created = UserBadge.objects.get_or_create(user=user, badge=badge) + for user in employees_missing_or_inactive: + user_badge, _ = 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"]) @@ -98,12 +96,14 @@ def update_waspy_award_badge(self): ).distinct() # Assign badge to WASPY award winners who don't have it - users_without_badge = waspy_award_users.exclude(badges__badge=badge) - count = users_without_badge.count() + users_missing_or_inactive = waspy_award_users.exclude( + badges__badge=badge, badges__is_active=True + ) + count = users_missing_or_inactive.count() if count: - for user in users_without_badge: - user_badge, created = UserBadge.objects.get_or_create(user=user, badge=badge) + for user in users_missing_or_inactive: + user_badge, _ = 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"]) diff --git a/backend/apps/owasp/management/commands/owasp_sync_awards.py b/backend/apps/owasp/management/commands/owasp_sync_awards.py index 3060f46423..9f11abae1e 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_awards.py +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -11,6 +11,10 @@ logger = logging.getLogger(__name__) +# Year validation constants +MIN_VALID_YEAR = 1900 +MAX_VALID_YEAR = 2100 + class Command(BaseCommand): help = "Import awards from the OWASP awards YAML file" @@ -19,13 +23,24 @@ def handle(self, *args, **kwargs) -> None: """Handle the command execution.""" self.stdout.write("Syncing OWASP awards...") - data = yaml.safe_load( - get_repository_file_content( - "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/awards.yml" + url = "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/awards.yml" + raw = get_repository_file_content(url) + if not raw: + self.stderr.write(self.style.WARNING("No awards data fetched; aborting.")) + return + try: + data = yaml.safe_load(raw) or [] + except yaml.YAMLError as e: + self.stderr.write(self.style.ERROR(f"Failed to parse awards YAML: {e}")) + return + if not isinstance(data, list): + self.stderr.write( + self.style.WARNING("Unexpected awards YAML structure; expected a list.") ) - ) + return awards_to_save = [] + skipped_count = 0 for item in data: if item.get("type") == "award": winners = item.get("winners", []) @@ -33,41 +48,87 @@ def handle(self, *args, **kwargs) -> None: award = self._create_or_update_award(item, winner) if award: awards_to_save.append(award) + else: + skipped_count += 1 - Award.bulk_save(awards_to_save) + Award.bulk_save(awards_to_save, fields=("category", "description", "year", "user")) self.stdout.write(self.style.SUCCESS(f"Successfully synced {len(awards_to_save)} awards")) + if skipped_count: + self.stdout.write( + self.style.WARNING(f"Skipped {skipped_count} awards due to invalid data") + ) def _create_or_update_award(self, award_data, winner_data): """Create or update award instance.""" - name = f"{award_data['title']} - {winner_data['name']} ({award_data['year']})" + # Safely extract values with defaults + title = award_data.get("title", "") + category = award_data.get("category", "") + + # Validate and parse year + try: + year = int(award_data.get("year", 0)) + if year <= 0 or year < MIN_VALID_YEAR or year > MAX_VALID_YEAR: + logger.warning("Invalid year %s for award %s, skipping", year, title) + return None + except (ValueError, TypeError): + logger.warning( + "Could not parse year %s for award %s, skipping", award_data.get("year"), title + ) + return None + + # Handle winner_data being string or dict + if isinstance(winner_data, str): + winner_name = winner_data + winner_info = "" + else: + # Prefer explicit GitHub login over name + login = winner_data.get("login") or winner_data.get("github") + if login: + login = login.lstrip("@") + # Skip bot accounts + if "bot" in login.lower() or login.lower().endswith("[bot]"): + logger.warning("Skipping bot account: %s", login) + return None + winner_name = login + else: + winner_name = winner_data.get("name", "") + winner_info = winner_data.get("info", "") + + name = f"{title} - {winner_name} ({year})" try: award = Award.objects.get(name=name) except Award.DoesNotExist: award = Award(name=name) - award.category = award_data.get("category", "") - award.description = winner_data.get("info", "") - award.year = award_data.get("year", 0) + award.category = category + award.description = winner_info + award.year = year - # Try to match user by name - user = self._match_user(winner_data["name"]) - if user: - award.user = user - else: - logger.warning("Could not match user for award winner: %s", winner_data["name"]) + # Only set user if not already reviewed + if not (award.user and award.is_reviewed): + user = self._match_user(winner_name) + if user: + award.user = user + else: + logger.warning("Could not match user for award winner: %s", winner_name) return award def _match_user(self, winner_name): """Try to match award winner with existing user.""" - # Try exact name match first - user = User.objects.filter(name__iexact=winner_name).first() - if user: - return user + winner_name = winner_name.strip() - # Try login match (GitHub username) - user = User.objects.filter(login__iexact=winner_name).first() + # Check if it looks like a GitHub handle + if winner_name.startswith("@") or (" " not in winner_name and winner_name): + # Strip leading @ and try login match first + login_name = winner_name.lstrip("@") + user = User.objects.filter(login__iexact=login_name).first() + if user: + return user + + # Try exact name match + user = User.objects.filter(name__iexact=winner_name).first() if user: return user diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 5415d2e887..f81af60f1b 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -15,10 +15,16 @@ class Meta: indexes = [ models.Index(fields=["-year"], name="award_year_desc_idx"), ] + constraints = [ + models.UniqueConstraint( + fields=["category", "name", "year"], + name="uniq_award_cat_name_year", + ), + ] verbose_name_plural = "Awards" category = models.CharField(verbose_name="Category", max_length=100) - name = models.CharField(verbose_name="Name", max_length=255, unique=True) + name = models.CharField(verbose_name="Name", max_length=255) description = models.TextField(verbose_name="Description", blank=True, default="") year = models.IntegerField(verbose_name="Year") is_reviewed = models.BooleanField(