From 9517ffd50090bbe16c9b3d532033070abe8296e4 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Fri, 8 Aug 2025 16:17:40 +0000 Subject: [PATCH 01/12] Sync OWASP Awards data and integrate with user profiles --- backend/Makefile | 3 + backend/apps/nest/Makefile | 3 + backend/apps/nest/admin/badge.py | 52 +-- backend/apps/nest/management/__init__.py | 1 + .../apps/nest/management/commands/__init__.py | 1 + .../management/commands/update_user_badges.py | 241 ++++++++++++ backend/apps/nest/models/badge.py | 2 +- backend/apps/owasp/Makefile | 4 + backend/apps/owasp/admin/__init__.py | 1 + backend/apps/owasp/admin/award.py | 63 ++++ .../management/commands/owasp_sync_awards.py | 354 ++++++++++++++++++ backend/apps/owasp/models/award.py | 246 ++++++++++++ .../tests/apps/nest/management/__init__.py | 1 + .../apps/nest/management/commands/__init__.py | 1 + .../commands/update_user_badges_test.py | 84 +++++ 15 files changed, 1009 insertions(+), 48 deletions(-) create mode 100644 backend/apps/nest/Makefile create mode 100644 backend/apps/nest/management/__init__.py create mode 100644 backend/apps/nest/management/commands/__init__.py create mode 100644 backend/apps/nest/management/commands/update_user_badges.py create mode 100644 backend/apps/owasp/admin/award.py create mode 100644 backend/apps/owasp/management/commands/owasp_sync_awards.py create mode 100644 backend/apps/owasp/models/award.py create mode 100644 backend/tests/apps/nest/management/__init__.py create mode 100644 backend/tests/apps/nest/management/commands/__init__.py create mode 100644 backend/tests/apps/nest/management/commands/update_user_badges_test.py diff --git a/backend/Makefile b/backend/Makefile index e2e88f2415..cdad2ba061 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,5 +1,6 @@ include backend/apps/ai/Makefile include backend/apps/github/Makefile +include backend/apps/nest/Makefile include backend/apps/owasp/Makefile include backend/apps/slack/Makefile @@ -109,6 +110,7 @@ shell-db: sync-data: \ update-data \ enrich-data \ + nest-update-user-badges \ index-data test-backend: @@ -134,6 +136,7 @@ update-data: \ github-update-users \ owasp-aggregate-projects \ owasp-update-events \ + owasp-sync-awards \ owasp-sync-posts \ owasp-update-sponsors \ slack-sync-data diff --git a/backend/apps/nest/Makefile b/backend/apps/nest/Makefile new file mode 100644 index 0000000000..ddd80f8391 --- /dev/null +++ b/backend/apps/nest/Makefile @@ -0,0 +1,3 @@ +nest-update-user-badges: + @echo "Updating user badges" + @CMD="python manage.py update_user_badges" $(MAKE) exec-backend-command diff --git a/backend/apps/nest/admin/badge.py b/backend/apps/nest/admin/badge.py index 05828a3853..f534598fd3 100644 --- a/backend/apps/nest/admin/badge.py +++ b/backend/apps/nest/admin/badge.py @@ -1,57 +1,15 @@ -"""Admin configuration for the Badge model in the OWASP app.""" +"""Badge admin configuration.""" from django.contrib import admin from apps.nest.models.badge import Badge +@admin.register(Badge) class BadgeAdmin(admin.ModelAdmin): """Admin for Badge model.""" - fieldsets = ( - ( - "Basic Information", - { - "fields": ( - "name", - "description", - "weight", - ) - }, - ), - ("Display Settings", {"fields": ("css_class",)}), - ( - "Timestamps", - { - "fields": ( - "nest_created_at", - "nest_updated_at", - ) - }, - ), - ) - list_display = ( - "name", - "description", - "weight", - "css_class", - "nest_created_at", - "nest_updated_at", - ) + list_display = ("name", "description", "weight", "css_class") list_filter = ("weight",) - ordering = ( - "weight", - "name", - ) - readonly_fields = ( - "nest_created_at", - "nest_updated_at", - ) - search_fields = ( - "css_class", - "description", - "name", - ) - - -admin.site.register(Badge, BadgeAdmin) + search_fields = ("name", "description") + ordering = ("weight", "name") \ No newline at end of file diff --git a/backend/apps/nest/management/__init__.py b/backend/apps/nest/management/__init__.py new file mode 100644 index 0000000000..3bf4d72a08 --- /dev/null +++ b/backend/apps/nest/management/__init__.py @@ -0,0 +1 @@ +"""Nest app management commands.""" diff --git a/backend/apps/nest/management/commands/__init__.py b/backend/apps/nest/management/commands/__init__.py new file mode 100644 index 0000000000..3bf4d72a08 --- /dev/null +++ b/backend/apps/nest/management/commands/__init__.py @@ -0,0 +1 @@ +"""Nest app management commands.""" diff --git a/backend/apps/nest/management/commands/update_user_badges.py b/backend/apps/nest/management/commands/update_user_badges.py new file mode 100644 index 0000000000..9f0e00a0f2 --- /dev/null +++ b/backend/apps/nest/management/commands/update_user_badges.py @@ -0,0 +1,241 @@ +"""A command to update user badges based on awards and other achievements.""" + +import logging + +from django.core.management.base import BaseCommand + +from apps.github.models.user import User +from apps.nest.models.badge import BadgeType, UserBadge +from apps.owasp.models.award import Award + +logger = logging.getLogger(__name__) + + +class Command(BaseCommand): + """Update user badges based on awards and achievements.""" + + help = "Update user badges based on OWASP awards and other achievements" + + def __init__(self, *args, **kwargs): + """Initialize the command with counters for tracking changes.""" + super().__init__(*args, **kwargs) + self.badges_created = 0 + self.badges_removed = 0 + self.users_processed = 0 + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--dry-run", + action="store_true", + help="Run without making changes to the database", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Enable verbose logging", + ) + parser.add_argument( + "--user-login", + type=str, + help="Process badges for a specific user (by GitHub login)", + ) + + def handle(self, *args, **options): + """Handle the command execution.""" + if options["verbose"]: + logger.setLevel(logging.DEBUG) + + self.stdout.write("Starting user badges update...") + + try: + # Ensure badge types exist + self._ensure_badge_types_exist(dry_run=options["dry_run"]) + + # Process users + if options["user_login"]: + self._process_single_user(options["user_login"], dry_run=options["dry_run"]) + else: + self._process_all_users(dry_run=options["dry_run"]) + + # Print summary + self._print_summary() + + except Exception as e: + logger.exception("Error updating user badges") + self.stdout.write(self.style.ERROR(f"Error updating user badges: {e!s}")) + + def _ensure_badge_types_exist(self, *, dry_run: bool = False): + """Ensure required badge types exist in the database.""" + badge_types = [ + { + "name": "WASPY Award Winner", + "description": "Awarded to users who have received any WASPY award from OWASP", + "icon": "🏆", + "color": "#FFD700", + }, + # Add more badge types here as needed + ] + + for badge_data in badge_types: + if not dry_run: + badge_type, created = BadgeType.objects.get_or_create( + name=badge_data["name"], + defaults={ + "description": badge_data["description"], + "icon": badge_data["icon"], + "color": badge_data["color"], + "is_active": True, + }, + ) + if created: + logger.debug("Created badge type: %s", badge_type.name) + else: + # Update existing badge type + badge_type.description = badge_data["description"] + badge_type.icon = badge_data["icon"] + badge_type.color = badge_data["color"] + badge_type.save( + update_fields=["description", "icon", "color", "nest_updated_at"] + ) + logger.debug("Updated badge type: %s", badge_type.name) + else: + logger.debug("[DRY RUN] Would ensure badge type exists: %s", badge_data["name"]) + + def _process_all_users(self, *, dry_run: bool = False): + """Process badges for all users.""" + # Get all users who have awards or existing badges + users_with_awards = set( + Award.objects.filter(user__isnull=False).values_list("user_id", flat=True).distinct() + ) + + users_with_badges = set(UserBadge.objects.values_list("user_id", flat=True).distinct()) + + all_user_ids = users_with_awards | users_with_badges + + if not all_user_ids: + self.stdout.write("No users found with awards or badges to process.") + return + + users = User.objects.filter(id__in=all_user_ids).prefetch_related("awards", "badges") + + for user in users: + self._process_user_badges(user, dry_run=dry_run) + self.users_processed += 1 + + def _process_single_user(self, user_login: str, *, dry_run: bool = False): + """Process badges for a single user.""" + try: + user = User.objects.prefetch_related("awards", "badges").get(login=user_login) + self._process_user_badges(user, dry_run=dry_run) + self.users_processed += 1 + except User.DoesNotExist: + self.stdout.write(self.style.ERROR(f"User with login '{user_login}' not found")) + + def _process_user_badges(self, user: User, *, dry_run: bool = False): + """Process badges for a specific user.""" + logger.debug("Processing badges for user: %s", user.login) + + # Process WASPY Award Winner badge + self._process_waspy_award_badge(user, dry_run=dry_run) + + # Add more badge processing logic here as needed + + def _process_waspy_award_badge(self, user: User, *, dry_run: bool = False): + """Process WASPY Award Winner badge for a user.""" + badge_name = "WASPY Award Winner" + + # Check if user has any WASPY awards + waspy_awards = user.awards.filter(category="WASPY", award_type="award") + + has_waspy_award = waspy_awards.exists() + + if not dry_run: + try: + badge_type = BadgeType.objects.get(name=badge_name) + except BadgeType.DoesNotExist: + logger.exception("Badge type '%s' not found", badge_name) + return + + # Check if user already has this badge + existing_badge = UserBadge.objects.filter(user=user, badge_type=badge_type).first() + + if has_waspy_award and not existing_badge: + # Award the badge + award_details = [ + { + "award_name": award.name, + "year": award.year, + "winner_name": award.winner_name, + } + for award in waspy_awards + ] + + award_names_str = ", ".join( + [f"{a['award_name']} ({a['year']})" for a in award_details] + ) + UserBadge.objects.create( + user=user, + badge_type=badge_type, + reason=f"Received WASPY award(s): {award_names_str}", + metadata={ + "awards": award_details, + "award_count": len(award_details), + }, + ) + self.badges_created += 1 + logger.debug("Awarded '%s' badge to %s", badge_name, user.login) + + elif not has_waspy_award and existing_badge: + # Remove the badge (award no longer associated) + existing_badge.delete() + self.badges_removed += 1 + logger.debug("Removed '%s' badge from %s", badge_name, user.login) + + elif has_waspy_award and existing_badge: + # Update badge metadata if needed + award_details = [] + for award in waspy_awards: + award_details.append( + { + "award_name": award.name, + "year": award.year, + "winner_name": award.winner_name, + } + ) + + new_metadata = { + "awards": award_details, + "award_count": len(award_details), + } + + if existing_badge.metadata != new_metadata: + award_names_str = ", ".join( + [f"{a['award_name']} ({a['year']})" for a in award_details] + ) + existing_badge.metadata = new_metadata + existing_badge.reason = f"Received WASPY award(s): {award_names_str}" + existing_badge.save(update_fields=["metadata", "reason", "nest_updated_at"]) + logger.debug("Updated '%s' badge metadata for %s", badge_name, user.login) + + # Dry run logging + elif has_waspy_award: + award_names = list(waspy_awards.values_list("name", "year")) + logger.debug( + "[DRY RUN] Would award/update '%s' badge to %s for awards: %s", + badge_name, + user.login, + award_names, + ) + else: + logger.debug("[DRY RUN] Would check for badge removal for %s", user.login) + + def _print_summary(self): + """Print command execution summary.""" + self.stdout.write("\n" + "=" * 50) + self.stdout.write("User Badges Update Summary") + self.stdout.write("=" * 50) + self.stdout.write(f"Users processed: {self.users_processed}") + self.stdout.write(f"Badges created: {self.badges_created}") + self.stdout.write(f"Badges removed: {self.badges_removed}") + self.stdout.write("\nBadge update completed successfully!") diff --git a/backend/apps/nest/models/badge.py b/backend/apps/nest/models/badge.py index 1378dda041..f20aaa17a4 100644 --- a/backend/apps/nest/models/badge.py +++ b/backend/apps/nest/models/badge.py @@ -38,4 +38,4 @@ class Meta: def __str__(self) -> str: """Return the badge string representation.""" - return self.name + return self.name \ No newline at end of file diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 4febcd2572..9a28e7e28c 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -26,6 +26,10 @@ owasp-process-snapshots: @echo "Processing OWASP snapshots" @CMD="python manage.py owasp_process_snapshots" $(MAKE) exec-backend-command +owasp-sync-awards: + @echo "Syncing OWASP awards data" + @CMD="python manage.py owasp_sync_awards" $(MAKE) exec-backend-command + owasp-update-leaders: @CMD="python manage.py owasp_update_leaders $(MATCH_MODEL)" $(MAKE) exec-backend-command 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..f164315b80 --- /dev/null +++ b/backend/apps/owasp/admin/award.py @@ -0,0 +1,63 @@ +"""Award admin configuration.""" + +from django.contrib import admin + +from apps.owasp.models.award import Award + + +@admin.register(Award) +class AwardAdmin(admin.ModelAdmin): + """Admin for Award model.""" + + list_display = ( + "name", + "category", + "year", + "award_type", + "winner_name", + "user", + "nest_created_at", + "nest_updated_at", + ) + list_filter = ( + "award_type", + "category", + "year", + ) + search_fields = ( + "name", + "category", + "winner_name", + "description", + "winner_info", + ) + ordering = ("-year", "category", "name") + + autocomplete_fields = ("user",) + + fieldsets = ( + ( + "Basic Information", + {"fields": ("name", "category", "award_type", "year", "description")}, + ), + ( + "Winner Information", + { + "fields": ("winner_name", "winner_info", "winner_image", "user"), + "classes": ("collapse",), + }, + ), + ( + "Timestamps", + { + "fields": ("nest_created_at", "nest_updated_at"), + "classes": ("collapse",), + }, + ), + ) + + readonly_fields = ("nest_created_at", "nest_updated_at") + + def get_queryset(self, request): + """Optimize queryset with select_related.""" + return super().get_queryset(request).select_related("user") 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..2350dc5f1f --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -0,0 +1,354 @@ +"""A command to sync OWASP awards from the canonical YAML source.""" + +import logging +import re + +import yaml +from django.core.management.base import BaseCommand +from django.db import transaction + +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): + """Sync OWASP awards from the canonical YAML source.""" + + help = "Sync OWASP awards from https://github.com/OWASP/owasp.github.io/blob/main/_data/awards.yml" + + def __init__(self, *args, **kwargs): + """Initialize the command with counters for tracking sync progress.""" + super().__init__(*args, **kwargs) + self.awards_created = 0 + self.awards_updated = 0 + self.users_matched = 0 + self.users_unmatched = 0 + self.unmatched_names = [] + + def add_arguments(self, parser): + """Add command arguments.""" + parser.add_argument( + "--dry-run", + action="store_true", + help="Run without making changes to the database", + ) + parser.add_argument( + "--verbose", + action="store_true", + help="Enable verbose logging", + ) + + def handle(self, *args, **options): + """Handle the command execution.""" + if options["verbose"]: + logger.setLevel(logging.DEBUG) + + self.stdout.write("Starting OWASP awards sync...") + + try: + # Download and parse the awards YAML + yaml_content = get_repository_file_content( + "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/awards.yml" + ) + + if not yaml_content: + self.stdout.write(self.style.ERROR("Failed to download awards.yml from GitHub")) + return + + awards_data = yaml.safe_load(yaml_content) + + if not awards_data: + self.stdout.write(self.style.ERROR("Failed to parse awards.yml content")) + return + + # Process awards data + if options["dry_run"]: + self.stdout.write(self.style.WARNING("DRY RUN MODE - No changes will be made")) + self._process_awards_data(awards_data, dry_run=True) + else: + with transaction.atomic(): + self._process_awards_data(awards_data, dry_run=False) + + # Print summary + self._print_summary() + + except Exception as e: + logger.exception("Error syncing awards") + self.stdout.write(self.style.ERROR(f"Error syncing awards: {e!s}")) + + def _process_awards_data(self, awards_data: list[dict], *, dry_run: bool = False): + """Process the awards data from YAML.""" + for item in awards_data: + if item.get("type") == "category": + self._process_category(item, dry_run=dry_run) + elif item.get("type") == "award": + self._process_award(item, dry_run=dry_run) + + def _process_category(self, category_data: dict, *, dry_run: bool = False): + """Process a category definition.""" + category_name = category_data.get("title", "") + description = category_data.get("description", "") + + if not category_name: + logger.warning("Skipping category with no title") + return + + # Create or update category record + if not dry_run: + award, created = Award.objects.get_or_create( + name=category_name, + award_type="category", + defaults={ + "category": category_name, + "description": description, + }, + ) + + if created: + self.awards_created += 1 + logger.debug("Created category: %s", category_name) + else: + # Update existing category + award.description = description + award.save(update_fields=["description", "nest_updated_at"]) + self.awards_updated += 1 + logger.debug("Updated category: %s", category_name) + else: + logger.debug("[DRY RUN] Would process category: %s", category_name) + + def _process_award(self, award_data: dict, *, dry_run: bool = False): + """Process an individual award.""" + award_name = award_data.get("title", "") + category = award_data.get("category", "") + year = award_data.get("year") + winners = award_data.get("winners", []) + + if not award_name or not category or not year: + logger.warning("Skipping incomplete award: %s", award_data) + return + + # Process each winner + for winner_data in winners: + self._process_winner(award_name, category, year, winner_data, dry_run=dry_run) + + def _process_winner( + self, + award_name: str, + category: str, + year: int, + winner_data: dict, + *, + dry_run: bool = False, + ): + """Process an individual award winner.""" + winner_name = winner_data.get("name", "").strip() + winner_info = winner_data.get("info", "") + winner_image = winner_data.get("image", "") + + if not winner_name: + logger.warning("Skipping winner with no name for award: %s", award_name) + return + + # Try to match winner with existing user + matched_user = self._match_user(winner_name, winner_info) + + if matched_user: + self.users_matched += 1 + logger.debug("Matched user: %s -> %s", winner_name, matched_user.login) + else: + self.users_unmatched += 1 + self.unmatched_names.append(winner_name) + logger.warning("Could not match user: %s", winner_name) + + if not dry_run: + # Create or update award record + award, created = Award.objects.get_or_create( + name=award_name, + category=category, + year=year, + winner_name=winner_name, + defaults={ + "award_type": "award", + "description": "", + "winner_info": winner_info, + "winner_image": winner_image, + "user": matched_user, + }, + ) + + if created: + self.awards_created += 1 + logger.debug("Created award: %s for %s", award_name, winner_name) + else: + # Update existing award + updated_fields = [] + if award.winner_info != winner_info: + award.winner_info = winner_info + updated_fields.append("winner_info") + if award.winner_image != winner_image: + award.winner_image = winner_image + updated_fields.append("winner_image") + if award.user != matched_user: + award.user = matched_user + updated_fields.append("user") + + if updated_fields: + updated_fields.append("nest_updated_at") + award.save(update_fields=updated_fields) + self.awards_updated += 1 + logger.debug("Updated award: %s for %s", award_name, winner_name) + else: + logger.debug("[DRY RUN] Would process winner: %s for %s", winner_name, award_name) + + def _match_user(self, winner_name: str, winner_info: str = "") -> User | None: + """Attempt to match a winner name with an existing GitHub user.""" + if not winner_name: + return None + + # Constants for magic numbers + min_name_parts = 2 + min_login_length = 2 + + # Clean the winner name + clean_name = winner_name.strip() + + # Try different matching strategies in order + user = self._try_exact_name_match(clean_name) + if user: + return user + + user = self._try_github_username_match(winner_info) + if user: + return user + + user = self._try_fuzzy_name_match(clean_name, min_name_parts) + if user: + return user + + user = self._try_login_variations(clean_name, min_login_length) + if user: + return user + + return None + + def _try_exact_name_match(self, clean_name: str) -> User | None: + """Try exact name matching.""" + try: + return User.objects.get(name__iexact=clean_name) + except User.DoesNotExist: + return None + except User.MultipleObjectsReturned: + # If multiple users have the same name, try to find the most relevant one + users = User.objects.filter(name__iexact=clean_name) + # Prefer users with more contributions or followers + return users.order_by("-contributions_count", "-followers_count").first() + + def _try_github_username_match(self, winner_info: str) -> User | None: + """Try to extract GitHub username from winner_info.""" + github_username = self._extract_github_username(winner_info) + if github_username: + try: + return User.objects.get(login__iexact=github_username) + except User.DoesNotExist: + pass + return None + + def _try_fuzzy_name_match(self, clean_name: str, min_name_parts: int) -> User | None: + """Try fuzzy name matching with partial matches.""" + name_parts = clean_name.split() + if len(name_parts) >= min_name_parts: + # Try "FirstName LastName" variations + for i in range(len(name_parts)): + for j in range(i + 1, len(name_parts) + 1): + partial_name = " ".join(name_parts[i:j]) + try: + return User.objects.get(name__icontains=partial_name) + except (User.DoesNotExist, User.MultipleObjectsReturned): + continue + return None + + def _try_login_variations(self, clean_name: str, min_login_length: int) -> User | None: + """Try login field with name variations.""" + potential_logins = self._generate_potential_logins(clean_name, min_login_length) + for login in potential_logins: + try: + return User.objects.get(login__iexact=login) + except User.DoesNotExist: + continue + return None + + def _extract_github_username(self, text: str) -> str | None: + """Extract GitHub username from text using various patterns.""" + if not text: + return None + + # Pattern 1: github.com/username + github_url_pattern = r"github\.com/([a-zA-Z0-9\-_]+)" + match = re.search(github_url_pattern, text, re.IGNORECASE) + if match: + return match.group(1) + + # Pattern 2: @username mentions + mention_pattern = r"@([a-zA-Z0-9\-_]+)" + match = re.search(mention_pattern, text) + if match: + return match.group(1) + + return None + + def _generate_potential_logins(self, name: str, min_login_length: int = 2) -> list[str]: + """Generate potential GitHub login variations from a name.""" + if not name: + return [] + + potential_logins = [] + clean_name = re.sub(r"[^a-zA-Z0-9\s\-_]", "", name).strip() + + # Convert to lowercase and replace spaces + base_variations = [ + clean_name.lower().replace(" ", ""), + clean_name.lower().replace(" ", "-"), + clean_name.lower().replace(" ", "_"), + ] + + # Add variations with different cases + for variation in base_variations: + potential_logins.extend( + [ + variation, + variation.replace("-", ""), + variation.replace("_", ""), + variation.replace("-", "_"), + variation.replace("_", "-"), + ] + ) + + # Remove duplicates while preserving order + seen = set() + unique_logins = [] + for login in potential_logins: + if login and login not in seen and len(login) >= min_login_length: + seen.add(login) + unique_logins.append(login) + + return unique_logins[:10] # Limit to avoid too many queries + + def _print_summary(self): + """Print command execution summary.""" + self.stdout.write("\n" + "=" * 50) + self.stdout.write("OWASP Awards Sync Summary") + self.stdout.write("=" * 50) + self.stdout.write(f"Awards created: {self.awards_created}") + self.stdout.write(f"Awards updated: {self.awards_updated}") + self.stdout.write(f"Users matched: {self.users_matched}") + self.stdout.write(f"Users unmatched: {self.users_unmatched}") + + if self.unmatched_names: + self.stdout.write(f"\nUnmatched winners ({len(self.unmatched_names)}):") + for name in sorted(set(self.unmatched_names)): + self.stdout.write(f" - {name}") + + self.stdout.write("\nSync completed successfully!") diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py new file mode 100644 index 0000000000..27e966a665 --- /dev/null +++ b/backend/apps/owasp/models/award.py @@ -0,0 +1,246 @@ +"""OWASP app award model.""" + +from __future__ import annotations + +from django.db import models + +from apps.common.models import TimestampedModel + + +class Award(TimestampedModel): + """OWASP Award model. + + Represents OWASP awards based on the canonical source at: + https://github.com/OWASP/owasp.github.io/blob/main/_data/awards.yml + """ + + class Meta: + db_table = "owasp_awards" + indexes = [ + models.Index(fields=["category", "year"], name="owasp_award_category_year"), + models.Index(fields=["-year"], name="owasp_award_year_desc"), + models.Index(fields=["name"], name="owasp_award_name"), + ] + verbose_name = "Award" + verbose_name_plural = "Awards" + constraints = [ + models.UniqueConstraint( + fields=["name", "year", "category"], name="unique_award_name_year_category" + ) + ] + + # Core fields based on YAML structure + category = models.CharField( + verbose_name="Category", + max_length=100, + help_text="Award category (e.g., 'WASPY', 'Lifetime Achievement')", + ) + name = models.CharField( + verbose_name="Name", + max_length=200, + help_text="Award name/title (e.g., 'Event Person of the Year')", + ) + description = models.TextField( + verbose_name="Description", blank=True, default="", help_text="Award description" + ) + year = models.IntegerField( + verbose_name="Year", + null=True, + blank=True, + help_text="Year the award was given (null for category definitions)", + ) + + # Winner information + winner_name = models.CharField( + verbose_name="Winner Name", + max_length=200, + blank=True, + default="", + help_text="Name of the award recipient", + ) + winner_info = models.TextField( + verbose_name="Winner Information", + blank=True, + default="", + help_text="Detailed information about the winner", + ) + winner_image = models.CharField( + verbose_name="Winner Image", + max_length=500, + blank=True, + default="", + help_text="Path to winner's image", + ) + + # Award type from YAML (category or award) + award_type = models.CharField( + verbose_name="Award Type", + max_length=20, + choices=[ + ("category", "Category"), + ("award", "Award"), + ], + default="award", + help_text="Type of entry: category definition or individual award", + ) + + # Optional foreign key to User model + user = models.ForeignKey( + "github.User", + on_delete=models.SET_NULL, + null=True, + blank=True, + related_name="awards", + verbose_name="User", + help_text="Associated GitHub user (if matched)", + ) + + def __str__(self) -> str: + """Return string representation of the award.""" + if self.award_type == "category": + return f"{self.name} (Category)" + + if self.winner_name: + return f"{self.name} - {self.winner_name} ({self.year})" + + return f"{self.name} ({self.year})" + + @property + def is_category(self) -> bool: + """Check if this is a category definition.""" + return self.award_type == "category" + + @property + def is_award(self) -> bool: + """Check if this is an individual award.""" + return self.award_type == "award" + + @property + def display_name(self) -> str: + """Get display name for the award.""" + if self.is_category: + return self.name + return f"{self.name} ({self.year})" + + @classmethod + def get_waspy_award_winners(cls): + """Get all users who have won WASPY awards. + + Returns: + QuerySet of GitHub users who have won WASPY awards + + """ + from apps.github.models.user import User + + return User.objects.filter(awards__category="WASPY", awards__award_type="award").distinct() + + @classmethod + def get_user_waspy_awards(cls, user): + """Get all WASPY awards for a specific user. + + Args: + user: GitHub User instance + + Returns: + QuerySet of WASPY awards for the user + + """ + return cls.objects.filter(user=user, category="WASPY", award_type="award") + + @staticmethod + def update_data(award_data: dict, *, save: bool = True): + """Update award data from YAML structure. + + Args: + award_data: Dictionary containing award data from YAML + save: Whether to save the award instance + + Returns: + Award instance or list of Award instances + + """ + if award_data.get("type") == "category": + return Award._create_category(award_data, save=save) + if award_data.get("type") == "award": + return Award._create_awards_from_winners(award_data, save=save) + return None + + @staticmethod + def _create_category(category_data: dict, *, save: bool = True): + """Create or update a category award.""" + name = category_data.get("title", "") + description = category_data.get("description", "") + + award, created = ( + Award.objects.get_or_create( + name=name, + award_type="category", + defaults={ + "category": name, + "description": description, + }, + ) + if save + else ( + Award( + name=name, + category=name, + award_type="category", + description=description, + ), + True, + ) + ) + + if not created and save: + award.description = description + award.save(update_fields=["description", "nest_updated_at"]) + + return award + + @staticmethod + def _create_awards_from_winners(award_data: dict, *, save: bool = True): + """Create award instances for each winner.""" + awards = [] + award_name = award_data.get("title", "") + category = award_data.get("category", "") + year = award_data.get("year") + winners = award_data.get("winners", []) + + for winner_data in winners: + winner_name = winner_data.get("name", "").strip() + if not winner_name: + continue + + award_defaults = { + "award_type": "award", + "description": "", + "winner_info": winner_data.get("info", ""), + "winner_image": winner_data.get("image", ""), + } + + if save: + award, created = Award.objects.get_or_create( + name=award_name, + category=category, + year=year, + winner_name=winner_name, + defaults=award_defaults, + ) + if not created: + # Update existing award + for field, value in award_defaults.items(): + setattr(award, field, value) + award.save() + else: + award = Award( + name=award_name, + category=category, + year=year, + winner_name=winner_name, + **award_defaults, + ) + + awards.append(award) + + return awards diff --git a/backend/tests/apps/nest/management/__init__.py b/backend/tests/apps/nest/management/__init__.py new file mode 100644 index 0000000000..a5b840f3d1 --- /dev/null +++ b/backend/tests/apps/nest/management/__init__.py @@ -0,0 +1 @@ +"""Tests for nest management.""" diff --git a/backend/tests/apps/nest/management/commands/__init__.py b/backend/tests/apps/nest/management/commands/__init__.py new file mode 100644 index 0000000000..c938ee68c0 --- /dev/null +++ b/backend/tests/apps/nest/management/commands/__init__.py @@ -0,0 +1 @@ +"""Tests for nest management commands.""" diff --git a/backend/tests/apps/nest/management/commands/update_user_badges_test.py b/backend/tests/apps/nest/management/commands/update_user_badges_test.py new file mode 100644 index 0000000000..4b2e715150 --- /dev/null +++ b/backend/tests/apps/nest/management/commands/update_user_badges_test.py @@ -0,0 +1,84 @@ +"""Tests for update_user_badges management command.""" + +from django.core.management import call_command +from django.test import TestCase + +from apps.github.models.user import User +from apps.nest.models.badge import BadgeType, UserBadge +from apps.owasp.models.award import Award + + +class UpdateUserBadgesCommandTest(TestCase): + """Test cases for update_user_badges command.""" + + def setUp(self): + """Set up test data.""" + # Create a test user + self.user = User.objects.create( + login="testuser", + name="Test User", + email="test@example.com", + created_at="2020-01-01T00:00:00Z", + updated_at="2020-01-01T00:00:00Z", + ) + + # Create a WASPY award for the user + self.award = Award.objects.create( + name="Test Person of the Year", + category="WASPY", + year=2024, + award_type="award", + winner_name="Test User", + user=self.user, + ) + + def test_creates_waspy_badge_for_award_winner(self): + """Test that WASPY badge is created for award winners.""" + # Run the command + call_command("update_user_badges", verbosity=0) + + # Check that badge type was created + badge_type = BadgeType.objects.get(name="WASPY Award Winner") + assert badge_type.name == "WASPY Award Winner" + + # Check that user badge was created + user_badge = UserBadge.objects.get(user=self.user, badge_type=badge_type) + assert user_badge.user == self.user + assert "Test Person of the Year" in user_badge.reason + + def test_removes_badge_when_award_removed(self): + """Test that badge is removed when award is no longer associated.""" + # First run to create the badge + call_command("update_user_badges", verbosity=0) + + badge_type = BadgeType.objects.get(name="WASPY Award Winner") + assert UserBadge.objects.filter(user=self.user, badge_type=badge_type).exists() + + # Remove the award association + self.award.user = None + self.award.save() + + # Run command again + call_command("update_user_badges", verbosity=0) + + # Check that badge was removed + assert not UserBadge.objects.filter(user=self.user, badge_type=badge_type).exists() + + def test_dry_run_mode(self): + """Test that dry run mode doesn't make changes.""" + # Run in dry run mode + call_command("update_user_badges", dry_run=True, verbosity=0) + + # Check that no badge type or user badge was created + assert not BadgeType.objects.filter(name="WASPY Award Winner").exists() + assert not UserBadge.objects.filter(user=self.user).exists() + + def test_single_user_processing(self): + """Test processing a single user by login.""" + # Run command for specific user + call_command("update_user_badges", user_login="testuser", verbosity=0) + + # Check that badge was created + badge_type = BadgeType.objects.get(name="WASPY Award Winner") + user_badge = UserBadge.objects.get(user=self.user, badge_type=badge_type) + assert user_badge.user == self.user From d6a12a1a98636759725d3d3e331875b44085e61c Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 9 Aug 2025 19:25:35 +0000 Subject: [PATCH 02/12] Add Award import --- backend/apps/owasp/models/__init__.py | 1 + 1 file changed, 1 insertion(+) diff --git a/backend/apps/owasp/models/__init__.py b/backend/apps/owasp/models/__init__.py index 5ce17c615d..9a75dcf206 100644 --- a/backend/apps/owasp/models/__init__.py +++ b/backend/apps/owasp/models/__init__.py @@ -1 +1,2 @@ +from .award import Award from .project import Project From c8f22eb8c6d8c6344017e1b70ea11eff0396bd11 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 9 Aug 2025 19:28:57 +0000 Subject: [PATCH 03/12] Add Django migrations for Award andbadge models --- .../migrations/0003_badgetype_userbadge.py | 154 ++++++++++++++++++ backend/apps/owasp/migrations/0045_award.py | 127 +++++++++++++++ 2 files changed, 281 insertions(+) create mode 100644 backend/apps/nest/migrations/0003_badgetype_userbadge.py create mode 100644 backend/apps/owasp/migrations/0045_award.py diff --git a/backend/apps/nest/migrations/0003_badgetype_userbadge.py b/backend/apps/nest/migrations/0003_badgetype_userbadge.py new file mode 100644 index 0000000000..b53919beff --- /dev/null +++ b/backend/apps/nest/migrations/0003_badgetype_userbadge.py @@ -0,0 +1,154 @@ +# Generated by Django 5.2.5 on 2025-08-09 15:57 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0033_alter_release_published_at"), + ("nest", "0002_apikey"), + ] + + operations = [ + migrations.CreateModel( + name="BadgeType", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "name", + models.CharField( + help_text="Badge type name (e.g., 'WASPY Award Winner')", + max_length=100, + unique=True, + verbose_name="Name", + ), + ), + ( + "description", + models.TextField( + blank=True, + default="", + help_text="Description of what this badge represents", + verbose_name="Description", + ), + ), + ( + "icon", + models.CharField( + blank=True, + default="🏆", + help_text="Icon class or emoji for the badge", + max_length=50, + verbose_name="Icon", + ), + ), + ( + "color", + models.CharField( + blank=True, + default="#FFD700", + help_text="Badge color (hex code or CSS color name)", + max_length=20, + verbose_name="Color", + ), + ), + ( + "is_active", + models.BooleanField( + default=True, + help_text="Whether this badge type is currently active", + verbose_name="Is Active", + ), + ), + ], + options={ + "verbose_name": "Badge Type", + "verbose_name_plural": "Badge Types", + "db_table": "nest_badge_types", + "ordering": ["name"], + }, + ), + migrations.CreateModel( + name="UserBadge", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "earned_at", + models.DateTimeField( + auto_now_add=True, + help_text="When the badge was earned", + verbose_name="Earned At", + ), + ), + ( + "reason", + models.TextField( + blank=True, + default="", + help_text="Reason or context for earning this badge", + verbose_name="Reason", + ), + ), + ( + "metadata", + models.JSONField( + blank=True, + default=dict, + help_text="Additional metadata about the badge (e.g., award details)", + verbose_name="Metadata", + ), + ), + ( + "badge_type", + models.ForeignKey( + help_text="The type of badge earned", + on_delete=django.db.models.deletion.CASCADE, + related_name="user_badges", + to="nest.badgetype", + verbose_name="Badge Type", + ), + ), + ( + "user", + models.ForeignKey( + help_text="The user who earned this badge", + on_delete=django.db.models.deletion.CASCADE, + related_name="badges", + to="github.user", + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "User Badge", + "verbose_name_plural": "User Badges", + "db_table": "nest_user_badges", + "ordering": ["-earned_at"], + "indexes": [ + models.Index(fields=["user"], name="nest_user_badge_user_idx"), + models.Index(fields=["badge_type"], name="nest_user_badge_type_idx"), + models.Index(fields=["earned_at"], name="nest_user_badge_earned_at_idx"), + ], + "constraints": [ + models.UniqueConstraint( + fields=("user", "badge_type"), name="unique_user_badge_type" + ) + ], + }, + ), + ] diff --git a/backend/apps/owasp/migrations/0045_award.py b/backend/apps/owasp/migrations/0045_award.py new file mode 100644 index 0000000000..e53b068244 --- /dev/null +++ b/backend/apps/owasp/migrations/0045_award.py @@ -0,0 +1,127 @@ +# Generated by Django 5.2.5 on 2025-08-09 15:56 + +import django.db.models.deletion +from django.db import migrations, models + + +class Migration(migrations.Migration): + dependencies = [ + ("github", "0033_alter_release_published_at"), + ("owasp", "0044_chapter_chapter_updated_at_desc_idx_and_more"), + ] + + operations = [ + migrations.CreateModel( + name="Award", + fields=[ + ( + "id", + models.BigAutoField( + auto_created=True, primary_key=True, serialize=False, verbose_name="ID" + ), + ), + ("nest_created_at", models.DateTimeField(auto_now_add=True)), + ("nest_updated_at", models.DateTimeField(auto_now=True)), + ( + "category", + models.CharField( + help_text="Award category (e.g., 'WASPY', 'Lifetime Achievement')", + max_length=100, + verbose_name="Category", + ), + ), + ( + "name", + models.CharField( + help_text="Award name/title (e.g., 'Event Person of the Year')", + max_length=200, + verbose_name="Name", + ), + ), + ( + "description", + models.TextField( + blank=True, + default="", + help_text="Award description", + verbose_name="Description", + ), + ), + ( + "year", + models.IntegerField( + blank=True, + help_text="Year the award was given (null for category definitions)", + null=True, + verbose_name="Year", + ), + ), + ( + "winner_name", + models.CharField( + blank=True, + default="", + help_text="Name of the award recipient", + max_length=200, + verbose_name="Winner Name", + ), + ), + ( + "winner_info", + models.TextField( + blank=True, + default="", + help_text="Detailed information about the winner", + verbose_name="Winner Information", + ), + ), + ( + "winner_image", + models.CharField( + blank=True, + default="", + help_text="Path to winner's image", + max_length=500, + verbose_name="Winner Image", + ), + ), + ( + "award_type", + models.CharField( + choices=[("category", "Category"), ("award", "Award")], + default="award", + help_text="Type of entry: category definition or individual award", + max_length=20, + verbose_name="Award Type", + ), + ), + ( + "user", + models.ForeignKey( + blank=True, + help_text="Associated GitHub user (if matched)", + null=True, + on_delete=django.db.models.deletion.SET_NULL, + related_name="awards", + to="github.user", + verbose_name="User", + ), + ), + ], + options={ + "verbose_name": "Award", + "verbose_name_plural": "Awards", + "db_table": "owasp_awards", + "indexes": [ + models.Index(fields=["category", "year"], name="owasp_award_category_year"), + models.Index(fields=["-year"], name="owasp_award_year_desc"), + models.Index(fields=["name"], name="owasp_award_name"), + ], + "constraints": [ + models.UniqueConstraint( + fields=("name", "year", "category"), name="unique_award_name_year_category" + ) + ], + }, + ), + ] From 9a15aa53af6c4d4c96195b104d5453fa2ebfc82c Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Tue, 12 Aug 2025 15:08:21 +0000 Subject: [PATCH 04/12] Refactor: Remove badge-related code implement awards syncing --- backend/Makefile | 2 - backend/apps/nest/Makefile | 3 - backend/apps/nest/management/__init__.py | 1 - .../apps/nest/management/commands/__init__.py | 1 - .../management/commands/update_user_badges.py | 241 ------------------ .../migrations/0003_badgetype_userbadge.py | 154 ----------- backend/apps/owasp/migrations/0045_badge.py | 42 --- backend/apps/owasp/models/award.py | 82 +----- .../commands/update_user_badges_test.py | 84 ------ backend/tests/apps/owasp/models/award_test.py | 65 +++++ 10 files changed, 77 insertions(+), 598 deletions(-) delete mode 100644 backend/apps/nest/Makefile delete mode 100644 backend/apps/nest/management/__init__.py delete mode 100644 backend/apps/nest/management/commands/__init__.py delete mode 100644 backend/apps/nest/management/commands/update_user_badges.py delete mode 100644 backend/apps/nest/migrations/0003_badgetype_userbadge.py delete mode 100644 backend/apps/owasp/migrations/0045_badge.py delete mode 100644 backend/tests/apps/nest/management/commands/update_user_badges_test.py create mode 100644 backend/tests/apps/owasp/models/award_test.py diff --git a/backend/Makefile b/backend/Makefile index cdad2ba061..174b85858c 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -1,6 +1,5 @@ include backend/apps/ai/Makefile include backend/apps/github/Makefile -include backend/apps/nest/Makefile include backend/apps/owasp/Makefile include backend/apps/slack/Makefile @@ -110,7 +109,6 @@ shell-db: sync-data: \ update-data \ enrich-data \ - nest-update-user-badges \ index-data test-backend: diff --git a/backend/apps/nest/Makefile b/backend/apps/nest/Makefile deleted file mode 100644 index ddd80f8391..0000000000 --- a/backend/apps/nest/Makefile +++ /dev/null @@ -1,3 +0,0 @@ -nest-update-user-badges: - @echo "Updating user badges" - @CMD="python manage.py update_user_badges" $(MAKE) exec-backend-command diff --git a/backend/apps/nest/management/__init__.py b/backend/apps/nest/management/__init__.py deleted file mode 100644 index 3bf4d72a08..0000000000 --- a/backend/apps/nest/management/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Nest app management commands.""" diff --git a/backend/apps/nest/management/commands/__init__.py b/backend/apps/nest/management/commands/__init__.py deleted file mode 100644 index 3bf4d72a08..0000000000 --- a/backend/apps/nest/management/commands/__init__.py +++ /dev/null @@ -1 +0,0 @@ -"""Nest app management commands.""" diff --git a/backend/apps/nest/management/commands/update_user_badges.py b/backend/apps/nest/management/commands/update_user_badges.py deleted file mode 100644 index 9f0e00a0f2..0000000000 --- a/backend/apps/nest/management/commands/update_user_badges.py +++ /dev/null @@ -1,241 +0,0 @@ -"""A command to update user badges based on awards and other achievements.""" - -import logging - -from django.core.management.base import BaseCommand - -from apps.github.models.user import User -from apps.nest.models.badge import BadgeType, UserBadge -from apps.owasp.models.award import Award - -logger = logging.getLogger(__name__) - - -class Command(BaseCommand): - """Update user badges based on awards and achievements.""" - - help = "Update user badges based on OWASP awards and other achievements" - - def __init__(self, *args, **kwargs): - """Initialize the command with counters for tracking changes.""" - super().__init__(*args, **kwargs) - self.badges_created = 0 - self.badges_removed = 0 - self.users_processed = 0 - - def add_arguments(self, parser): - """Add command arguments.""" - parser.add_argument( - "--dry-run", - action="store_true", - help="Run without making changes to the database", - ) - parser.add_argument( - "--verbose", - action="store_true", - help="Enable verbose logging", - ) - parser.add_argument( - "--user-login", - type=str, - help="Process badges for a specific user (by GitHub login)", - ) - - def handle(self, *args, **options): - """Handle the command execution.""" - if options["verbose"]: - logger.setLevel(logging.DEBUG) - - self.stdout.write("Starting user badges update...") - - try: - # Ensure badge types exist - self._ensure_badge_types_exist(dry_run=options["dry_run"]) - - # Process users - if options["user_login"]: - self._process_single_user(options["user_login"], dry_run=options["dry_run"]) - else: - self._process_all_users(dry_run=options["dry_run"]) - - # Print summary - self._print_summary() - - except Exception as e: - logger.exception("Error updating user badges") - self.stdout.write(self.style.ERROR(f"Error updating user badges: {e!s}")) - - def _ensure_badge_types_exist(self, *, dry_run: bool = False): - """Ensure required badge types exist in the database.""" - badge_types = [ - { - "name": "WASPY Award Winner", - "description": "Awarded to users who have received any WASPY award from OWASP", - "icon": "🏆", - "color": "#FFD700", - }, - # Add more badge types here as needed - ] - - for badge_data in badge_types: - if not dry_run: - badge_type, created = BadgeType.objects.get_or_create( - name=badge_data["name"], - defaults={ - "description": badge_data["description"], - "icon": badge_data["icon"], - "color": badge_data["color"], - "is_active": True, - }, - ) - if created: - logger.debug("Created badge type: %s", badge_type.name) - else: - # Update existing badge type - badge_type.description = badge_data["description"] - badge_type.icon = badge_data["icon"] - badge_type.color = badge_data["color"] - badge_type.save( - update_fields=["description", "icon", "color", "nest_updated_at"] - ) - logger.debug("Updated badge type: %s", badge_type.name) - else: - logger.debug("[DRY RUN] Would ensure badge type exists: %s", badge_data["name"]) - - def _process_all_users(self, *, dry_run: bool = False): - """Process badges for all users.""" - # Get all users who have awards or existing badges - users_with_awards = set( - Award.objects.filter(user__isnull=False).values_list("user_id", flat=True).distinct() - ) - - users_with_badges = set(UserBadge.objects.values_list("user_id", flat=True).distinct()) - - all_user_ids = users_with_awards | users_with_badges - - if not all_user_ids: - self.stdout.write("No users found with awards or badges to process.") - return - - users = User.objects.filter(id__in=all_user_ids).prefetch_related("awards", "badges") - - for user in users: - self._process_user_badges(user, dry_run=dry_run) - self.users_processed += 1 - - def _process_single_user(self, user_login: str, *, dry_run: bool = False): - """Process badges for a single user.""" - try: - user = User.objects.prefetch_related("awards", "badges").get(login=user_login) - self._process_user_badges(user, dry_run=dry_run) - self.users_processed += 1 - except User.DoesNotExist: - self.stdout.write(self.style.ERROR(f"User with login '{user_login}' not found")) - - def _process_user_badges(self, user: User, *, dry_run: bool = False): - """Process badges for a specific user.""" - logger.debug("Processing badges for user: %s", user.login) - - # Process WASPY Award Winner badge - self._process_waspy_award_badge(user, dry_run=dry_run) - - # Add more badge processing logic here as needed - - def _process_waspy_award_badge(self, user: User, *, dry_run: bool = False): - """Process WASPY Award Winner badge for a user.""" - badge_name = "WASPY Award Winner" - - # Check if user has any WASPY awards - waspy_awards = user.awards.filter(category="WASPY", award_type="award") - - has_waspy_award = waspy_awards.exists() - - if not dry_run: - try: - badge_type = BadgeType.objects.get(name=badge_name) - except BadgeType.DoesNotExist: - logger.exception("Badge type '%s' not found", badge_name) - return - - # Check if user already has this badge - existing_badge = UserBadge.objects.filter(user=user, badge_type=badge_type).first() - - if has_waspy_award and not existing_badge: - # Award the badge - award_details = [ - { - "award_name": award.name, - "year": award.year, - "winner_name": award.winner_name, - } - for award in waspy_awards - ] - - award_names_str = ", ".join( - [f"{a['award_name']} ({a['year']})" for a in award_details] - ) - UserBadge.objects.create( - user=user, - badge_type=badge_type, - reason=f"Received WASPY award(s): {award_names_str}", - metadata={ - "awards": award_details, - "award_count": len(award_details), - }, - ) - self.badges_created += 1 - logger.debug("Awarded '%s' badge to %s", badge_name, user.login) - - elif not has_waspy_award and existing_badge: - # Remove the badge (award no longer associated) - existing_badge.delete() - self.badges_removed += 1 - logger.debug("Removed '%s' badge from %s", badge_name, user.login) - - elif has_waspy_award and existing_badge: - # Update badge metadata if needed - award_details = [] - for award in waspy_awards: - award_details.append( - { - "award_name": award.name, - "year": award.year, - "winner_name": award.winner_name, - } - ) - - new_metadata = { - "awards": award_details, - "award_count": len(award_details), - } - - if existing_badge.metadata != new_metadata: - award_names_str = ", ".join( - [f"{a['award_name']} ({a['year']})" for a in award_details] - ) - existing_badge.metadata = new_metadata - existing_badge.reason = f"Received WASPY award(s): {award_names_str}" - existing_badge.save(update_fields=["metadata", "reason", "nest_updated_at"]) - logger.debug("Updated '%s' badge metadata for %s", badge_name, user.login) - - # Dry run logging - elif has_waspy_award: - award_names = list(waspy_awards.values_list("name", "year")) - logger.debug( - "[DRY RUN] Would award/update '%s' badge to %s for awards: %s", - badge_name, - user.login, - award_names, - ) - else: - logger.debug("[DRY RUN] Would check for badge removal for %s", user.login) - - def _print_summary(self): - """Print command execution summary.""" - self.stdout.write("\n" + "=" * 50) - self.stdout.write("User Badges Update Summary") - self.stdout.write("=" * 50) - self.stdout.write(f"Users processed: {self.users_processed}") - self.stdout.write(f"Badges created: {self.badges_created}") - self.stdout.write(f"Badges removed: {self.badges_removed}") - self.stdout.write("\nBadge update completed successfully!") diff --git a/backend/apps/nest/migrations/0003_badgetype_userbadge.py b/backend/apps/nest/migrations/0003_badgetype_userbadge.py deleted file mode 100644 index b53919beff..0000000000 --- a/backend/apps/nest/migrations/0003_badgetype_userbadge.py +++ /dev/null @@ -1,154 +0,0 @@ -# Generated by Django 5.2.5 on 2025-08-09 15:57 - -import django.db.models.deletion -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("github", "0033_alter_release_published_at"), - ("nest", "0002_apikey"), - ] - - operations = [ - migrations.CreateModel( - name="BadgeType", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ("nest_created_at", models.DateTimeField(auto_now_add=True)), - ("nest_updated_at", models.DateTimeField(auto_now=True)), - ( - "name", - models.CharField( - help_text="Badge type name (e.g., 'WASPY Award Winner')", - max_length=100, - unique=True, - verbose_name="Name", - ), - ), - ( - "description", - models.TextField( - blank=True, - default="", - help_text="Description of what this badge represents", - verbose_name="Description", - ), - ), - ( - "icon", - models.CharField( - blank=True, - default="🏆", - help_text="Icon class or emoji for the badge", - max_length=50, - verbose_name="Icon", - ), - ), - ( - "color", - models.CharField( - blank=True, - default="#FFD700", - help_text="Badge color (hex code or CSS color name)", - max_length=20, - verbose_name="Color", - ), - ), - ( - "is_active", - models.BooleanField( - default=True, - help_text="Whether this badge type is currently active", - verbose_name="Is Active", - ), - ), - ], - options={ - "verbose_name": "Badge Type", - "verbose_name_plural": "Badge Types", - "db_table": "nest_badge_types", - "ordering": ["name"], - }, - ), - migrations.CreateModel( - name="UserBadge", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ("nest_created_at", models.DateTimeField(auto_now_add=True)), - ("nest_updated_at", models.DateTimeField(auto_now=True)), - ( - "earned_at", - models.DateTimeField( - auto_now_add=True, - help_text="When the badge was earned", - verbose_name="Earned At", - ), - ), - ( - "reason", - models.TextField( - blank=True, - default="", - help_text="Reason or context for earning this badge", - verbose_name="Reason", - ), - ), - ( - "metadata", - models.JSONField( - blank=True, - default=dict, - help_text="Additional metadata about the badge (e.g., award details)", - verbose_name="Metadata", - ), - ), - ( - "badge_type", - models.ForeignKey( - help_text="The type of badge earned", - on_delete=django.db.models.deletion.CASCADE, - related_name="user_badges", - to="nest.badgetype", - verbose_name="Badge Type", - ), - ), - ( - "user", - models.ForeignKey( - help_text="The user who earned this badge", - on_delete=django.db.models.deletion.CASCADE, - related_name="badges", - to="github.user", - verbose_name="User", - ), - ), - ], - options={ - "verbose_name": "User Badge", - "verbose_name_plural": "User Badges", - "db_table": "nest_user_badges", - "ordering": ["-earned_at"], - "indexes": [ - models.Index(fields=["user"], name="nest_user_badge_user_idx"), - models.Index(fields=["badge_type"], name="nest_user_badge_type_idx"), - models.Index(fields=["earned_at"], name="nest_user_badge_earned_at_idx"), - ], - "constraints": [ - models.UniqueConstraint( - fields=("user", "badge_type"), name="unique_user_badge_type" - ) - ], - }, - ), - ] diff --git a/backend/apps/owasp/migrations/0045_badge.py b/backend/apps/owasp/migrations/0045_badge.py deleted file mode 100644 index 53c1e5bc5e..0000000000 --- a/backend/apps/owasp/migrations/0045_badge.py +++ /dev/null @@ -1,42 +0,0 @@ -# Generated by Django 5.2.4 on 2025-08-09 19:54 - -from django.db import migrations, models - - -class Migration(migrations.Migration): - dependencies = [ - ("owasp", "0044_chapter_chapter_updated_at_desc_idx_and_more"), - ] - - operations = [ - migrations.CreateModel( - name="Badge", - fields=[ - ( - "id", - models.BigAutoField( - auto_created=True, primary_key=True, serialize=False, verbose_name="ID" - ), - ), - ("nest_created_at", models.DateTimeField(auto_now_add=True)), - ("nest_updated_at", models.DateTimeField(auto_now=True)), - ( - "css_class", - models.CharField(default="", max_length=255, verbose_name="CSS Class"), - ), - ( - "description", - models.CharField( - blank=True, default="", max_length=255, verbose_name="Description" - ), - ), - ("name", models.CharField(max_length=255, unique=True, verbose_name="Name")), - ("weight", models.PositiveSmallIntegerField(default=0, verbose_name="Weight")), - ], - options={ - "verbose_name_plural": "Badges", - "db_table": "owasp_badges", - "ordering": ["weight", "name"], - }, - ), - ] diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 27e966a665..957accfd12 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -14,6 +14,13 @@ class Award(TimestampedModel): https://github.com/OWASP/owasp.github.io/blob/main/_data/awards.yml """ + class Category(models.TextChoices): + WASPY = "WASPY", "WASPY" + DISTINGUISHED_LIFETIME = ( + "Distinguished Lifetime Memberships", + "Distinguished Lifetime Memberships", + ) + class Meta: db_table = "owasp_awards" indexes = [ @@ -33,7 +40,8 @@ class Meta: category = models.CharField( verbose_name="Category", max_length=100, - help_text="Award category (e.g., 'WASPY', 'Lifetime Achievement')", + choices=Category.choices, + help_text="Award category (e.g., 'WASPY', 'Distinguished Lifetime Memberships')", ) name = models.CharField( verbose_name="Name", @@ -45,9 +53,7 @@ class Meta: ) year = models.IntegerField( verbose_name="Year", - null=True, - blank=True, - help_text="Year the award was given (null for category definitions)", + help_text="Year the award was given", ) # Winner information @@ -72,18 +78,6 @@ class Meta: help_text="Path to winner's image", ) - # Award type from YAML (category or award) - award_type = models.CharField( - verbose_name="Award Type", - max_length=20, - choices=[ - ("category", "Category"), - ("award", "Award"), - ], - default="award", - help_text="Type of entry: category definition or individual award", - ) - # Optional foreign key to User model user = models.ForeignKey( "github.User", @@ -97,29 +91,13 @@ class Meta: def __str__(self) -> str: """Return string representation of the award.""" - if self.award_type == "category": - return f"{self.name} (Category)" - if self.winner_name: return f"{self.name} - {self.winner_name} ({self.year})" - return f"{self.name} ({self.year})" - @property - def is_category(self) -> bool: - """Check if this is a category definition.""" - return self.award_type == "category" - - @property - def is_award(self) -> bool: - """Check if this is an individual award.""" - return self.award_type == "award" - @property def display_name(self) -> str: """Get display name for the award.""" - if self.is_category: - return self.name return f"{self.name} ({self.year})" @classmethod @@ -132,7 +110,7 @@ def get_waspy_award_winners(cls): """ from apps.github.models.user import User - return User.objects.filter(awards__category="WASPY", awards__award_type="award").distinct() + return User.objects.filter(awards__category=cls.Category.WASPY).distinct() @classmethod def get_user_waspy_awards(cls, user): @@ -145,7 +123,7 @@ def get_user_waspy_awards(cls, user): QuerySet of WASPY awards for the user """ - return cls.objects.filter(user=user, category="WASPY", award_type="award") + return cls.objects.filter(user=user, category=cls.Category.WASPY) @staticmethod def update_data(award_data: dict, *, save: bool = True): @@ -159,45 +137,10 @@ def update_data(award_data: dict, *, save: bool = True): Award instance or list of Award instances """ - if award_data.get("type") == "category": - return Award._create_category(award_data, save=save) if award_data.get("type") == "award": return Award._create_awards_from_winners(award_data, save=save) return None - @staticmethod - def _create_category(category_data: dict, *, save: bool = True): - """Create or update a category award.""" - name = category_data.get("title", "") - description = category_data.get("description", "") - - award, created = ( - Award.objects.get_or_create( - name=name, - award_type="category", - defaults={ - "category": name, - "description": description, - }, - ) - if save - else ( - Award( - name=name, - category=name, - award_type="category", - description=description, - ), - True, - ) - ) - - if not created and save: - award.description = description - award.save(update_fields=["description", "nest_updated_at"]) - - return award - @staticmethod def _create_awards_from_winners(award_data: dict, *, save: bool = True): """Create award instances for each winner.""" @@ -213,7 +156,6 @@ def _create_awards_from_winners(award_data: dict, *, save: bool = True): continue award_defaults = { - "award_type": "award", "description": "", "winner_info": winner_data.get("info", ""), "winner_image": winner_data.get("image", ""), diff --git a/backend/tests/apps/nest/management/commands/update_user_badges_test.py b/backend/tests/apps/nest/management/commands/update_user_badges_test.py deleted file mode 100644 index 4b2e715150..0000000000 --- a/backend/tests/apps/nest/management/commands/update_user_badges_test.py +++ /dev/null @@ -1,84 +0,0 @@ -"""Tests for update_user_badges management command.""" - -from django.core.management import call_command -from django.test import TestCase - -from apps.github.models.user import User -from apps.nest.models.badge import BadgeType, UserBadge -from apps.owasp.models.award import Award - - -class UpdateUserBadgesCommandTest(TestCase): - """Test cases for update_user_badges command.""" - - def setUp(self): - """Set up test data.""" - # Create a test user - self.user = User.objects.create( - login="testuser", - name="Test User", - email="test@example.com", - created_at="2020-01-01T00:00:00Z", - updated_at="2020-01-01T00:00:00Z", - ) - - # Create a WASPY award for the user - self.award = Award.objects.create( - name="Test Person of the Year", - category="WASPY", - year=2024, - award_type="award", - winner_name="Test User", - user=self.user, - ) - - def test_creates_waspy_badge_for_award_winner(self): - """Test that WASPY badge is created for award winners.""" - # Run the command - call_command("update_user_badges", verbosity=0) - - # Check that badge type was created - badge_type = BadgeType.objects.get(name="WASPY Award Winner") - assert badge_type.name == "WASPY Award Winner" - - # Check that user badge was created - user_badge = UserBadge.objects.get(user=self.user, badge_type=badge_type) - assert user_badge.user == self.user - assert "Test Person of the Year" in user_badge.reason - - def test_removes_badge_when_award_removed(self): - """Test that badge is removed when award is no longer associated.""" - # First run to create the badge - call_command("update_user_badges", verbosity=0) - - badge_type = BadgeType.objects.get(name="WASPY Award Winner") - assert UserBadge.objects.filter(user=self.user, badge_type=badge_type).exists() - - # Remove the award association - self.award.user = None - self.award.save() - - # Run command again - call_command("update_user_badges", verbosity=0) - - # Check that badge was removed - assert not UserBadge.objects.filter(user=self.user, badge_type=badge_type).exists() - - def test_dry_run_mode(self): - """Test that dry run mode doesn't make changes.""" - # Run in dry run mode - call_command("update_user_badges", dry_run=True, verbosity=0) - - # Check that no badge type or user badge was created - assert not BadgeType.objects.filter(name="WASPY Award Winner").exists() - assert not UserBadge.objects.filter(user=self.user).exists() - - def test_single_user_processing(self): - """Test processing a single user by login.""" - # Run command for specific user - call_command("update_user_badges", user_login="testuser", verbosity=0) - - # Check that badge was created - badge_type = BadgeType.objects.get(name="WASPY Award Winner") - user_badge = UserBadge.objects.get(user=self.user, badge_type=badge_type) - assert user_badge.user == self.user diff --git a/backend/tests/apps/owasp/models/award_test.py b/backend/tests/apps/owasp/models/award_test.py new file mode 100644 index 0000000000..cee3f2c890 --- /dev/null +++ b/backend/tests/apps/owasp/models/award_test.py @@ -0,0 +1,65 @@ +"""Tests for Award model.""" + +from django.test import TestCase + +from apps.github.models.user import User +from apps.owasp.models.award import Award + + +class AwardModelTest(TestCase): + """Test cases for Award model.""" + + def setUp(self): + """Set up test data.""" + self.user = User.objects.create( + login="testuser", + name="Test User", + email="test@example.com", + created_at="2020-01-01T00:00:00Z", + updated_at="2020-01-01T00:00:00Z", + ) + + def test_create_award(self): + """Test creating an award.""" + award = Award.objects.create( + name="Test Award", + category="WASPY", + year=2024, + award_type="award", + winner_name="Test User", + user=self.user, + ) + + assert award.name == "Test Award" + assert award.category == "WASPY" + assert award.year == 2024 + assert award.winner_name == "Test User" + assert award.user == self.user + + def test_get_waspy_award_winners(self): + """Test getting WASPY award winners.""" + Award.objects.create( + name="Test Award", + category="WASPY", + year=2024, + award_type="award", + winner_name="Test User", + user=self.user, + ) + + winners = Award.get_waspy_award_winners() + assert self.user in winners + + def test_get_user_waspy_awards(self): + """Test getting WASPY awards for a user.""" + award = Award.objects.create( + name="Test Award", + category="WASPY", + year=2024, + award_type="award", + winner_name="Test User", + user=self.user, + ) + + user_awards = Award.get_user_waspy_awards(self.user) + assert award in user_awards From 9cab822bc91f112bd351a899b5204829842ded59 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Wed, 13 Aug 2025 04:34:34 +0000 Subject: [PATCH 05/12] clean up Award implementation and fix migration conflicts --- backend/Makefile | 1 + backend/apps/nest/admin/badge.py | 2 +- backend/apps/nest/models/badge.py | 2 +- backend/apps/owasp/Makefile | 4 ++ backend/apps/owasp/admin/award.py | 4 +- .../commands/owasp_update_badges.py | 43 ++++++++++++ backend/apps/owasp/migrations/0045_award.py | 28 +++----- ..._merge_0045_badge_0045_project_audience.py | 2 +- backend/apps/owasp/models/award.py | 5 -- backend/tests/apps/owasp/models/award_test.py | 70 +++++-------------- 10 files changed, 79 insertions(+), 82 deletions(-) create mode 100644 backend/apps/owasp/management/commands/owasp_update_badges.py diff --git a/backend/Makefile b/backend/Makefile index 174b85858c..240e8e77ca 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -109,6 +109,7 @@ shell-db: sync-data: \ update-data \ enrich-data \ + owasp-update-badges \ index-data test-backend: diff --git a/backend/apps/nest/admin/badge.py b/backend/apps/nest/admin/badge.py index f534598fd3..ca02683e30 100644 --- a/backend/apps/nest/admin/badge.py +++ b/backend/apps/nest/admin/badge.py @@ -12,4 +12,4 @@ class BadgeAdmin(admin.ModelAdmin): list_display = ("name", "description", "weight", "css_class") list_filter = ("weight",) search_fields = ("name", "description") - ordering = ("weight", "name") \ No newline at end of file + ordering = ("weight", "name") diff --git a/backend/apps/nest/models/badge.py b/backend/apps/nest/models/badge.py index f20aaa17a4..1378dda041 100644 --- a/backend/apps/nest/models/badge.py +++ b/backend/apps/nest/models/badge.py @@ -38,4 +38,4 @@ class Meta: def __str__(self) -> str: """Return the badge string representation.""" - return self.name \ No newline at end of file + return self.name diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 9a28e7e28c..38dea86892 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -67,3 +67,7 @@ owasp-update-events: owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command + +owasp-update-badges: + @echo "Updating OWASP user badges" + @CMD="python manage.py owasp_update_badges" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin/award.py b/backend/apps/owasp/admin/award.py index f164315b80..61da8690ec 100644 --- a/backend/apps/owasp/admin/award.py +++ b/backend/apps/owasp/admin/award.py @@ -13,14 +13,12 @@ class AwardAdmin(admin.ModelAdmin): "name", "category", "year", - "award_type", "winner_name", "user", "nest_created_at", "nest_updated_at", ) list_filter = ( - "award_type", "category", "year", ) @@ -38,7 +36,7 @@ class AwardAdmin(admin.ModelAdmin): fieldsets = ( ( "Basic Information", - {"fields": ("name", "category", "award_type", "year", "description")}, + {"fields": ("name", "category", "year", "description")}, ), ( "Winner Information", diff --git a/backend/apps/owasp/management/commands/owasp_update_badges.py b/backend/apps/owasp/management/commands/owasp_update_badges.py new file mode 100644 index 0000000000..8c0ae6fd9d --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_update_badges.py @@ -0,0 +1,43 @@ +"""Update user badges based on OWASP awards.""" + +from django.core.management.base import BaseCommand + +from apps.github.models.user import User +from apps.nest.models.badge import Badge +from apps.owasp.models.award import Award + + +class Command(BaseCommand): + """Update user badges based on OWASP awards.""" + + help = "Update user badges based on OWASP awards" + + def handle(self, *args, **options): + """Handle the command execution.""" + # Get or create WASPY badge + waspy_badge, created = Badge.objects.get_or_create( + name="WASPY Award Winner", + defaults={ + "description": "Recipient of WASPY award from OWASP", + "css_class": "badge-waspy", + "weight": 10, + }, + ) + + if created: + self.stdout.write(f"Created badge: {waspy_badge.name}") + + # Get users with WASPY awards + waspy_winners = Award.get_waspy_award_winners() + + # Add badge to WASPY winners + for user in waspy_winners: + user.badges.add(waspy_badge) + + # Remove badge from users without WASPY awards + users_with_badge = User.objects.filter(badges=waspy_badge) + for user in users_with_badge: + if not Award.get_user_waspy_awards(user).exists(): + user.badges.remove(waspy_badge) + + self.stdout.write(f"Updated badges for {waspy_winners.count()} WASPY winners") diff --git a/backend/apps/owasp/migrations/0045_award.py b/backend/apps/owasp/migrations/0045_award.py index e53b068244..c0b0e4f0b9 100644 --- a/backend/apps/owasp/migrations/0045_award.py +++ b/backend/apps/owasp/migrations/0045_award.py @@ -25,7 +25,14 @@ class Migration(migrations.Migration): ( "category", models.CharField( - help_text="Award category (e.g., 'WASPY', 'Lifetime Achievement')", + choices=[ + ("WASPY", "WASPY"), + ( + "Distinguished Lifetime Memberships", + "Distinguished Lifetime Memberships", + ), + ], + help_text="Award category (e.g., 'WASPY', 'Distinguished Lifetime Memberships')", max_length=100, verbose_name="Category", ), @@ -50,9 +57,7 @@ class Migration(migrations.Migration): ( "year", models.IntegerField( - blank=True, - help_text="Year the award was given (null for category definitions)", - null=True, + help_text="Year the award was given", verbose_name="Year", ), ), @@ -85,16 +90,6 @@ class Migration(migrations.Migration): verbose_name="Winner Image", ), ), - ( - "award_type", - models.CharField( - choices=[("category", "Category"), ("award", "Award")], - default="award", - help_text="Type of entry: category definition or individual award", - max_length=20, - verbose_name="Award Type", - ), - ), ( "user", models.ForeignKey( @@ -117,11 +112,6 @@ class Migration(migrations.Migration): models.Index(fields=["-year"], name="owasp_award_year_desc"), models.Index(fields=["name"], name="owasp_award_name"), ], - "constraints": [ - models.UniqueConstraint( - fields=("name", "year", "category"), name="unique_award_name_year_category" - ) - ], }, ), ] diff --git a/backend/apps/owasp/migrations/0046_merge_0045_badge_0045_project_audience.py b/backend/apps/owasp/migrations/0046_merge_0045_badge_0045_project_audience.py index ed1bb15962..3bf9b0ccaf 100644 --- a/backend/apps/owasp/migrations/0046_merge_0045_badge_0045_project_audience.py +++ b/backend/apps/owasp/migrations/0046_merge_0045_badge_0045_project_audience.py @@ -5,7 +5,7 @@ class Migration(migrations.Migration): dependencies = [ - ("owasp", "0045_badge"), + ("owasp", "0045_award"), ("owasp", "0045_project_audience"), ] diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 957accfd12..696183829e 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -30,11 +30,6 @@ class Meta: ] verbose_name = "Award" verbose_name_plural = "Awards" - constraints = [ - models.UniqueConstraint( - fields=["name", "year", "category"], name="unique_award_name_year_category" - ) - ] # Core fields based on YAML structure category = models.CharField( diff --git a/backend/tests/apps/owasp/models/award_test.py b/backend/tests/apps/owasp/models/award_test.py index cee3f2c890..eeb8230ff8 100644 --- a/backend/tests/apps/owasp/models/award_test.py +++ b/backend/tests/apps/owasp/models/award_test.py @@ -2,64 +2,30 @@ from django.test import TestCase -from apps.github.models.user import User -from apps.owasp.models.award import Award - class AwardModelTest(TestCase): """Test cases for Award model.""" - def setUp(self): - """Set up test data.""" - self.user = User.objects.create( - login="testuser", - name="Test User", - email="test@example.com", - created_at="2020-01-01T00:00:00Z", - updated_at="2020-01-01T00:00:00Z", - ) - - def test_create_award(self): - """Test creating an award.""" - award = Award.objects.create( - name="Test Award", - category="WASPY", - year=2024, - award_type="award", - winner_name="Test User", - user=self.user, - ) + def test_award_import(self): + """Test that Award model can be imported.""" + from apps.owasp.models.award import Award - assert award.name == "Test Award" - assert award.category == "WASPY" - assert award.year == 2024 - assert award.winner_name == "Test User" - assert award.user == self.user + assert Award is not None - def test_get_waspy_award_winners(self): - """Test getting WASPY award winners.""" - Award.objects.create( - name="Test Award", - category="WASPY", - year=2024, - award_type="award", - winner_name="Test User", - user=self.user, - ) + def test_award_category_choices(self): + """Test Award category choices.""" + from apps.owasp.models.award import Award - winners = Award.get_waspy_award_winners() - assert self.user in winners + choices = Award.Category.choices + assert ("WASPY", "WASPY") in choices + assert ( + "Distinguished Lifetime Memberships", + "Distinguished Lifetime Memberships", + ) in choices - def test_get_user_waspy_awards(self): - """Test getting WASPY awards for a user.""" - award = Award.objects.create( - name="Test Award", - category="WASPY", - year=2024, - award_type="award", - winner_name="Test User", - user=self.user, - ) + def test_award_meta(self): + """Test Award model meta.""" + from apps.owasp.models.award import Award - user_awards = Award.get_user_waspy_awards(self.user) - assert award in user_awards + assert Award._meta.db_table == "owasp_awards" + assert Award._meta.verbose_name == "Award" From 89edc9637a05227ae5535c4af0253879a36d9b8b Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 16 Aug 2025 04:57:57 +0000 Subject: [PATCH 06/12] remove indexes from Award model meta class and update migration --- backend/apps/owasp/migrations/0045_award.py | 5 ----- backend/apps/owasp/models/award.py | 5 ----- 2 files changed, 10 deletions(-) diff --git a/backend/apps/owasp/migrations/0045_award.py b/backend/apps/owasp/migrations/0045_award.py index c0b0e4f0b9..4e6bd33dc9 100644 --- a/backend/apps/owasp/migrations/0045_award.py +++ b/backend/apps/owasp/migrations/0045_award.py @@ -107,11 +107,6 @@ class Migration(migrations.Migration): "verbose_name": "Award", "verbose_name_plural": "Awards", "db_table": "owasp_awards", - "indexes": [ - models.Index(fields=["category", "year"], name="owasp_award_category_year"), - models.Index(fields=["-year"], name="owasp_award_year_desc"), - models.Index(fields=["name"], name="owasp_award_name"), - ], }, ), ] diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 696183829e..039833f784 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -23,11 +23,6 @@ class Category(models.TextChoices): class Meta: db_table = "owasp_awards" - indexes = [ - models.Index(fields=["category", "year"], name="owasp_award_category_year"), - models.Index(fields=["-year"], name="owasp_award_year_desc"), - models.Index(fields=["name"], name="owasp_award_name"), - ] verbose_name = "Award" verbose_name_plural = "Awards" From b3e323d1023d20c1d8c8985523cef2db859ac842 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 16 Aug 2025 05:12:37 +0000 Subject: [PATCH 07/12] Rename winner_image to winner_image_url and fix --- backend/apps/owasp/models/award.py | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 039833f784..4284a0dd3a 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -60,12 +60,11 @@ class Meta: default="", help_text="Detailed information about the winner", ) - winner_image = models.CharField( - verbose_name="Winner Image", - max_length=500, + winner_image_url = models.URLField( + verbose_name="Winner Image URL", blank=True, default="", - help_text="Path to winner's image", + help_text="URL to winner's image", ) # Optional foreign key to User model @@ -148,7 +147,7 @@ def _create_awards_from_winners(award_data: dict, *, save: bool = True): award_defaults = { "description": "", "winner_info": winner_data.get("info", ""), - "winner_image": winner_data.get("image", ""), + "winner_image_url": winner_data.get("image", ""), } if save: From 45524b201bc6c80240780bebb3d52d9fa477db92 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 16 Aug 2025 06:52:16 +0000 Subject: [PATCH 08/12] Added is_reviewed field to Award model and update admin and badge logic --- backend/apps/owasp/admin/award.py | 10 +++++++++- .../management/commands/owasp_update_badges.py | 12 ++++++++---- backend/apps/owasp/migrations/0045_award.py | 17 ++++++++++++----- backend/apps/owasp/models/award.py | 5 +++++ 4 files changed, 34 insertions(+), 10 deletions(-) diff --git a/backend/apps/owasp/admin/award.py b/backend/apps/owasp/admin/award.py index 61da8690ec..c08c9ef544 100644 --- a/backend/apps/owasp/admin/award.py +++ b/backend/apps/owasp/admin/award.py @@ -15,12 +15,14 @@ class AwardAdmin(admin.ModelAdmin): "year", "winner_name", "user", + "is_reviewed", "nest_created_at", "nest_updated_at", ) list_filter = ( "category", "year", + "is_reviewed", ) search_fields = ( "name", @@ -41,7 +43,13 @@ class AwardAdmin(admin.ModelAdmin): ( "Winner Information", { - "fields": ("winner_name", "winner_info", "winner_image", "user"), + "fields": ( + "winner_name", + "winner_info", + "winner_image_url", + "user", + "is_reviewed", + ), "classes": ("collapse",), }, ), diff --git a/backend/apps/owasp/management/commands/owasp_update_badges.py b/backend/apps/owasp/management/commands/owasp_update_badges.py index 8c0ae6fd9d..58c822e8d7 100644 --- a/backend/apps/owasp/management/commands/owasp_update_badges.py +++ b/backend/apps/owasp/management/commands/owasp_update_badges.py @@ -27,17 +27,21 @@ def handle(self, *args, **options): if created: self.stdout.write(f"Created badge: {waspy_badge.name}") - # Get users with WASPY awards - waspy_winners = Award.get_waspy_award_winners() + # Get users with reviewed WASPY awards only + waspy_winners = User.objects.filter( + awards__category=Award.Category.WASPY, awards__is_reviewed=True + ).distinct() # Add badge to WASPY winners for user in waspy_winners: user.badges.add(waspy_badge) - # Remove badge from users without WASPY awards + # Remove badge from users without reviewed WASPY awards users_with_badge = User.objects.filter(badges=waspy_badge) for user in users_with_badge: - if not Award.get_user_waspy_awards(user).exists(): + if not Award.objects.filter( + user=user, category=Award.Category.WASPY, is_reviewed=True + ).exists(): user.badges.remove(waspy_badge) self.stdout.write(f"Updated badges for {waspy_winners.count()} WASPY winners") diff --git a/backend/apps/owasp/migrations/0045_award.py b/backend/apps/owasp/migrations/0045_award.py index 4e6bd33dc9..1ee6e5a348 100644 --- a/backend/apps/owasp/migrations/0045_award.py +++ b/backend/apps/owasp/migrations/0045_award.py @@ -81,13 +81,12 @@ class Migration(migrations.Migration): ), ), ( - "winner_image", - models.CharField( + "winner_image_url", + models.URLField( blank=True, default="", - help_text="Path to winner's image", - max_length=500, - verbose_name="Winner Image", + help_text="URL to winner's image", + verbose_name="Winner Image URL", ), ), ( @@ -102,6 +101,14 @@ class Migration(migrations.Migration): verbose_name="User", ), ), + ( + "is_reviewed", + models.BooleanField( + default=False, + help_text="Whether the user matching has been verified by a human", + verbose_name="Is Reviewed", + ), + ), ], options={ "verbose_name": "Award", diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 4284a0dd3a..399bf2772e 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -77,6 +77,11 @@ class Meta: verbose_name="User", help_text="Associated GitHub user (if matched)", ) + is_reviewed = models.BooleanField( + verbose_name="Is Reviewed", + default=False, + help_text="Whether the user matching has been verified by a human", + ) def __str__(self) -> str: """Return string representation of the award.""" From 14df37dec5cfe81d6a1f61397e1aec1ab03e84e5 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 16 Aug 2025 09:24:50 +0000 Subject: [PATCH 09/12] Implement Award model with bulk operations --- backend/Makefile | 1 - backend/apps/owasp/Makefile | 4 - .../management/commands/owasp_sync_awards.py | 124 ++++++------------ .../commands/owasp_update_badges.py | 47 ------- backend/apps/owasp/models/award.py | 104 +++++++-------- 5 files changed, 92 insertions(+), 188 deletions(-) delete mode 100644 backend/apps/owasp/management/commands/owasp_update_badges.py diff --git a/backend/Makefile b/backend/Makefile index 240e8e77ca..174b85858c 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -109,7 +109,6 @@ shell-db: sync-data: \ update-data \ enrich-data \ - owasp-update-badges \ index-data test-backend: diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 38dea86892..9a28e7e28c 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -67,7 +67,3 @@ owasp-update-events: owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command - -owasp-update-badges: - @echo "Updating OWASP user badges" - @CMD="python manage.py owasp_update_badges" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/management/commands/owasp_sync_awards.py b/backend/apps/owasp/management/commands/owasp_sync_awards.py index 2350dc5f1f..2d7410d256 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_awards.py +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -82,43 +82,9 @@ def handle(self, *args, **options): def _process_awards_data(self, awards_data: list[dict], *, dry_run: bool = False): """Process the awards data from YAML.""" for item in awards_data: - if item.get("type") == "category": - self._process_category(item, dry_run=dry_run) - elif item.get("type") == "award": + if item.get("type") == "award": self._process_award(item, dry_run=dry_run) - def _process_category(self, category_data: dict, *, dry_run: bool = False): - """Process a category definition.""" - category_name = category_data.get("title", "") - description = category_data.get("description", "") - - if not category_name: - logger.warning("Skipping category with no title") - return - - # Create or update category record - if not dry_run: - award, created = Award.objects.get_or_create( - name=category_name, - award_type="category", - defaults={ - "category": category_name, - "description": description, - }, - ) - - if created: - self.awards_created += 1 - logger.debug("Created category: %s", category_name) - else: - # Update existing category - award.description = description - award.save(update_fields=["description", "nest_updated_at"]) - self.awards_updated += 1 - logger.debug("Updated category: %s", category_name) - else: - logger.debug("[DRY RUN] Would process category: %s", category_name) - def _process_award(self, award_data: dict, *, dry_run: bool = False): """Process an individual award.""" award_name = award_data.get("title", "") @@ -130,30 +96,31 @@ def _process_award(self, award_data: dict, *, dry_run: bool = False): logger.warning("Skipping incomplete award: %s", award_data) return - # Process each winner + # Process each winner using the model's update_data method for winner_data in winners: - self._process_winner(award_name, category, year, winner_data, dry_run=dry_run) - - def _process_winner( - self, - award_name: str, - category: str, - year: int, - winner_data: dict, - *, - dry_run: bool = False, - ): + # Prepare winner data with award context + winner_with_context = { + "title": award_name, + "category": category, + "year": year, + "name": winner_data.get("name", ""), + "info": winner_data.get("info", ""), + "image": winner_data.get("image", ""), + } + + self._process_winner(winner_with_context, dry_run=dry_run) + + def _process_winner(self, winner_data: dict, *, dry_run: bool = False): """Process an individual award winner.""" winner_name = winner_data.get("name", "").strip() - winner_info = winner_data.get("info", "") - winner_image = winner_data.get("image", "") + award_name = winner_data.get("title", "") if not winner_name: logger.warning("Skipping winner with no name for award: %s", award_name) return # Try to match winner with existing user - matched_user = self._match_user(winner_name, winner_info) + matched_user = self._match_user(winner_name, winner_data.get("info", "")) if matched_user: self.users_matched += 1 @@ -164,42 +131,33 @@ def _process_winner( logger.warning("Could not match user: %s", winner_name) if not dry_run: - # Create or update award record - award, created = Award.objects.get_or_create( - name=award_name, - category=category, - year=year, - winner_name=winner_name, - defaults={ - "award_type": "award", - "description": "", - "winner_info": winner_info, - "winner_image": winner_image, - "user": matched_user, - }, - ) - - if created: + # Check if award exists before update + try: + Award.objects.get( + name=award_name, + category=winner_data.get("category", ""), + year=winner_data.get("year"), + winner_name=winner_name, + ) + is_new = False + except Award.DoesNotExist: + is_new = True + + # Use the model's update_data method + award = Award.update_data(winner_data, save=True) + + # Update user association if matched + if matched_user and award.user != matched_user: + award.user = matched_user + award.save(update_fields=["user", "nest_updated_at"]) + + # Track creation/update stats + if is_new: self.awards_created += 1 logger.debug("Created award: %s for %s", award_name, winner_name) else: - # Update existing award - updated_fields = [] - if award.winner_info != winner_info: - award.winner_info = winner_info - updated_fields.append("winner_info") - if award.winner_image != winner_image: - award.winner_image = winner_image - updated_fields.append("winner_image") - if award.user != matched_user: - award.user = matched_user - updated_fields.append("user") - - if updated_fields: - updated_fields.append("nest_updated_at") - award.save(update_fields=updated_fields) - self.awards_updated += 1 - logger.debug("Updated award: %s for %s", award_name, winner_name) + self.awards_updated += 1 + logger.debug("Updated award: %s for %s", award_name, winner_name) else: logger.debug("[DRY RUN] Would process winner: %s for %s", winner_name, award_name) diff --git a/backend/apps/owasp/management/commands/owasp_update_badges.py b/backend/apps/owasp/management/commands/owasp_update_badges.py deleted file mode 100644 index 58c822e8d7..0000000000 --- a/backend/apps/owasp/management/commands/owasp_update_badges.py +++ /dev/null @@ -1,47 +0,0 @@ -"""Update user badges based on OWASP awards.""" - -from django.core.management.base import BaseCommand - -from apps.github.models.user import User -from apps.nest.models.badge import Badge -from apps.owasp.models.award import Award - - -class Command(BaseCommand): - """Update user badges based on OWASP awards.""" - - help = "Update user badges based on OWASP awards" - - def handle(self, *args, **options): - """Handle the command execution.""" - # Get or create WASPY badge - waspy_badge, created = Badge.objects.get_or_create( - name="WASPY Award Winner", - defaults={ - "description": "Recipient of WASPY award from OWASP", - "css_class": "badge-waspy", - "weight": 10, - }, - ) - - if created: - self.stdout.write(f"Created badge: {waspy_badge.name}") - - # Get users with reviewed WASPY awards only - waspy_winners = User.objects.filter( - awards__category=Award.Category.WASPY, awards__is_reviewed=True - ).distinct() - - # Add badge to WASPY winners - for user in waspy_winners: - user.badges.add(waspy_badge) - - # Remove badge from users without reviewed WASPY awards - users_with_badge = User.objects.filter(badges=waspy_badge) - for user in users_with_badge: - if not Award.objects.filter( - user=user, category=Award.Category.WASPY, is_reviewed=True - ).exists(): - user.badges.remove(waspy_badge) - - self.stdout.write(f"Updated badges for {waspy_winners.count()} WASPY winners") diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 399bf2772e..1f18c473b6 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -4,10 +4,10 @@ from django.db import models -from apps.common.models import TimestampedModel +from apps.common.models import BulkSaveModel, TimestampedModel -class Award(TimestampedModel): +class Award(BulkSaveModel, TimestampedModel): """OWASP Award model. Represents OWASP awards based on the canonical source at: @@ -120,63 +120,61 @@ def get_user_waspy_awards(cls, user): return cls.objects.filter(user=user, category=cls.Category.WASPY) @staticmethod - def update_data(award_data: dict, *, save: bool = True): - """Update award data from YAML structure. + def bulk_save(awards, fields=None) -> None: # type: ignore[override] + """Bulk save awards.""" + BulkSaveModel.bulk_save(Award, awards, fields=fields) + + @staticmethod + def update_data(award_data: dict, *, save: bool = True) -> Award: + """Update award data. Args: - award_data: Dictionary containing award data from YAML + award_data: Dictionary containing single award winner data save: Whether to save the award instance Returns: - Award instance or list of Award instances + Award instance """ - if award_data.get("type") == "award": - return Award._create_awards_from_winners(award_data, save=save) - return None - - @staticmethod - def _create_awards_from_winners(award_data: dict, *, save: bool = True): - """Create award instances for each winner.""" - awards = [] - award_name = award_data.get("title", "") + # Create unique identifier for get_or_create + name = award_data.get("title", "") category = award_data.get("category", "") year = award_data.get("year") - winners = award_data.get("winners", []) - - for winner_data in winners: - winner_name = winner_data.get("name", "").strip() - if not winner_name: - continue - - award_defaults = { - "description": "", - "winner_info": winner_data.get("info", ""), - "winner_image_url": winner_data.get("image", ""), - } - - if save: - award, created = Award.objects.get_or_create( - name=award_name, - category=category, - year=year, - winner_name=winner_name, - defaults=award_defaults, - ) - if not created: - # Update existing award - for field, value in award_defaults.items(): - setattr(award, field, value) - award.save() - else: - award = Award( - name=award_name, - category=category, - year=year, - winner_name=winner_name, - **award_defaults, - ) - - awards.append(award) - - return awards + winner_name = award_data.get("name", "").strip() + + try: + award = Award.objects.get( + name=name, + category=category, + year=year, + winner_name=winner_name, + ) + except Award.DoesNotExist: + award = Award( + name=name, + category=category, + year=year, + winner_name=winner_name, + ) + + award.from_dict(award_data) + if save: + award.save() + + return award + + def from_dict(self, data: dict) -> None: + """Update instance based on dict data. + + Args: + data: Dictionary containing award data + + """ + fields = { + "description": data.get("description", ""), + "winner_info": data.get("info", ""), + "winner_image_url": data.get("image", ""), + } + + for key, value in fields.items(): + setattr(self, key, value) From 644c5e71fdf1f4fdc477f985c401edeff6a25dbc Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:20:11 +0000 Subject: [PATCH 10/12] fix Award model name field to be unique --- .../management/commands/owasp_sync_awards.py | 10 +++------- backend/apps/owasp/migrations/0045_award.py | 1 + backend/apps/owasp/models/award.py | 17 ++++++++--------- 3 files changed, 12 insertions(+), 16 deletions(-) diff --git a/backend/apps/owasp/management/commands/owasp_sync_awards.py b/backend/apps/owasp/management/commands/owasp_sync_awards.py index 2d7410d256..07a5755795 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_awards.py +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -131,14 +131,10 @@ def _process_winner(self, winner_data: dict, *, dry_run: bool = False): logger.warning("Could not match user: %s", winner_name) if not dry_run: - # Check if award exists before update + # Check if award exists before update using unique name + unique_name = f"{award_name} - {winner_name} ({winner_data.get('year')})" try: - Award.objects.get( - name=award_name, - category=winner_data.get("category", ""), - year=winner_data.get("year"), - winner_name=winner_name, - ) + Award.objects.get(name=unique_name) is_new = False except Award.DoesNotExist: is_new = True diff --git a/backend/apps/owasp/migrations/0045_award.py b/backend/apps/owasp/migrations/0045_award.py index 1ee6e5a348..8b91a4916f 100644 --- a/backend/apps/owasp/migrations/0045_award.py +++ b/backend/apps/owasp/migrations/0045_award.py @@ -42,6 +42,7 @@ class Migration(migrations.Migration): models.CharField( help_text="Award name/title (e.g., 'Event Person of the Year')", max_length=200, + unique=True, verbose_name="Name", ), ), diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 1f18c473b6..16535253e1 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -36,6 +36,7 @@ class Meta: name = models.CharField( verbose_name="Name", max_length=200, + unique=True, help_text="Award name/title (e.g., 'Event Person of the Year')", ) description = models.TextField( @@ -136,22 +137,20 @@ def update_data(award_data: dict, *, save: bool = True) -> Award: Award instance """ - # Create unique identifier for get_or_create - name = award_data.get("title", "") + # Create unique name for each winner to satisfy unique constraint + award_title = award_data.get("title", "") category = award_data.get("category", "") year = award_data.get("year") winner_name = award_data.get("name", "").strip() + # Create unique name combining award title, winner, and year + unique_name = f"{award_title} - {winner_name} ({year})" + try: - award = Award.objects.get( - name=name, - category=category, - year=year, - winner_name=winner_name, - ) + award = Award.objects.get(name=unique_name) except Award.DoesNotExist: award = Award( - name=name, + name=unique_name, category=category, year=year, winner_name=winner_name, From 94ed30c3de3b29c75ab380caaa1dea89058d518b Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:34:44 +0000 Subject: [PATCH 11/12] Waspy Award Winner badge integration --- backend/Makefile | 1 + backend/apps/owasp/Makefile | 4 ++ .../commands/owasp_update_badges.py | 45 +++++++++++++++++++ backend/apps/owasp/models/award.py | 4 +- 4 files changed, 53 insertions(+), 1 deletion(-) create mode 100644 backend/apps/owasp/management/commands/owasp_update_badges.py diff --git a/backend/Makefile b/backend/Makefile index 174b85858c..240e8e77ca 100644 --- a/backend/Makefile +++ b/backend/Makefile @@ -109,6 +109,7 @@ shell-db: sync-data: \ update-data \ enrich-data \ + owasp-update-badges \ index-data test-backend: diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 9a28e7e28c..38dea86892 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -67,3 +67,7 @@ owasp-update-events: owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command + +owasp-update-badges: + @echo "Updating OWASP user badges" + @CMD="python manage.py owasp_update_badges" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/management/commands/owasp_update_badges.py b/backend/apps/owasp/management/commands/owasp_update_badges.py new file mode 100644 index 0000000000..3ae3510fa7 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_update_badges.py @@ -0,0 +1,45 @@ +"""Update user badges based on OWASP awards.""" + +from django.core.management.base import BaseCommand + +from apps.github.models.user import User +from apps.nest.models.badge import Badge +from apps.owasp.models.award import Award + + +class Command(BaseCommand): + """Update user badges based on OWASP awards.""" + + help = "Update user badges based on OWASP awards" + + def handle(self, *args, **options): + """Handle the command execution.""" + # Get or create WASPY badge + waspy_badge, created = Badge.objects.get_or_create( + name="WASPY Award Winner", + defaults={ + "description": "Recipient of WASPY award from OWASP", + "css_class": "badge-waspy", + "weight": 10, + }, + ) + + if created: + self.stdout.write(f"Created badge: {waspy_badge.name}") + + # Get users with WASPY awards using the model method + waspy_winners = Award.get_waspy_award_winners() + + # Add badge to WASPY winners + for user in waspy_winners: + user.badges.add(waspy_badge) + + # Remove badge from users no longer on the WASPY winners list + users_with_badge = User.objects.filter(badges=waspy_badge) + waspy_winner_ids = set(waspy_winners.values_list("id", flat=True)) + + for user in users_with_badge: + if user.id not in waspy_winner_ids: + user.badges.remove(waspy_badge) + + self.stdout.write(f"Updated badges for {waspy_winners.count()} WASPY winners") diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 16535253e1..99bd3098c7 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -105,7 +105,9 @@ def get_waspy_award_winners(cls): """ from apps.github.models.user import User - return User.objects.filter(awards__category=cls.Category.WASPY).distinct() + return User.objects.filter( + awards__category=cls.Category.WASPY, awards__is_reviewed=True + ).distinct() @classmethod def get_user_waspy_awards(cls, user): From 84f3b1f204e6b5ed76e0b597ab79f669e71a82e4 Mon Sep 17 00:00:00 2001 From: trucodd <135946016+trucodd@users.noreply.github.com> Date: Sat, 23 Aug 2025 05:45:13 +0000 Subject: [PATCH 12/12] implement OWASP awards system improvements --- backend/apps/owasp/Makefile | 4 + backend/apps/owasp/admin/award.py | 20 ++++- .../management/commands/owasp_sync_awards.py | 72 +++++++++++---- .../commands/owasp_update_badges.py | 5 +- .../0049_award_unique_constraint.py | 12 +++ backend/apps/owasp/models/award.py | 27 ++++-- .../tests/apps/owasp/management/__init__.py | 1 + .../owasp/management/commands/__init__.py | 1 + .../commands/owasp_update_badges_test.py | 90 +++++++++++++++++++ .../models/award_duplicate_handling_test.py | 38 ++++++++ backend/tests/apps/owasp/models/award_test.py | 6 +- 11 files changed, 240 insertions(+), 36 deletions(-) create mode 100644 backend/apps/owasp/migrations/0049_award_unique_constraint.py create mode 100644 backend/tests/apps/owasp/management/commands/owasp_update_badges_test.py create mode 100644 backend/tests/apps/owasp/models/award_duplicate_handling_test.py diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 38dea86892..1621e36806 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -26,6 +26,8 @@ owasp-process-snapshots: @echo "Processing OWASP snapshots" @CMD="python manage.py owasp_process_snapshots" $(MAKE) exec-backend-command +.PHONY: owasp-sync-awards + owasp-sync-awards: @echo "Syncing OWASP awards data" @CMD="python manage.py owasp_sync_awards" $(MAKE) exec-backend-command @@ -68,6 +70,8 @@ owasp-update-sponsors: @echo "Getting OWASP sponsors data" @CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command +.PHONY: owasp-update-badges + owasp-update-badges: @echo "Updating OWASP user badges" @CMD="python manage.py owasp_update_badges" $(MAKE) exec-backend-command diff --git a/backend/apps/owasp/admin/award.py b/backend/apps/owasp/admin/award.py index c08c9ef544..c738715f77 100644 --- a/backend/apps/owasp/admin/award.py +++ b/backend/apps/owasp/admin/award.py @@ -19,6 +19,8 @@ class AwardAdmin(admin.ModelAdmin): "nest_created_at", "nest_updated_at", ) + list_display_links = ("name", "winner_name") + list_per_page = 50 list_filter = ( "category", "year", @@ -26,14 +28,16 @@ class AwardAdmin(admin.ModelAdmin): ) search_fields = ( "name", - "category", "winner_name", "description", "winner_info", + "user__login", ) ordering = ("-year", "category", "name") autocomplete_fields = ("user",) + actions = ("mark_reviewed", "mark_not_reviewed") + list_select_related = ("user",) fieldsets = ( ( @@ -64,6 +68,14 @@ class AwardAdmin(admin.ModelAdmin): readonly_fields = ("nest_created_at", "nest_updated_at") - def get_queryset(self, request): - """Optimize queryset with select_related.""" - return super().get_queryset(request).select_related("user") + @admin.action(description="Mark selected awards as reviewed") + def mark_reviewed(self, request, queryset): + """Mark selected awards as reviewed.""" + updated = queryset.update(is_reviewed=True) + self.message_user(request, f"Marked {updated} award(s) as reviewed.") + + @admin.action(description="Mark selected awards as not reviewed") + def mark_not_reviewed(self, request, queryset): + """Mark selected awards as not reviewed.""" + updated = queryset.update(is_reviewed=False) + self.message_user(request, f"Marked {updated} award(s) as not reviewed.") diff --git a/backend/apps/owasp/management/commands/owasp_sync_awards.py b/backend/apps/owasp/management/commands/owasp_sync_awards.py index 07a5755795..d5dd8a8c68 100644 --- a/backend/apps/owasp/management/commands/owasp_sync_awards.py +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -58,12 +58,20 @@ def handle(self, *args, **options): self.stdout.write(self.style.ERROR("Failed to download awards.yml from GitHub")) return - awards_data = yaml.safe_load(yaml_content) + try: + awards_data = yaml.safe_load(yaml_content) + except yaml.YAMLError as e: + self.stdout.write(self.style.ERROR(f"Failed to parse awards.yml: {e}")) + return if not awards_data: self.stdout.write(self.style.ERROR("Failed to parse awards.yml content")) return + if not isinstance(awards_data, list): + self.stdout.write(self.style.ERROR("awards.yml root must be a list of entries")) + return + # Process awards data if options["dry_run"]: self.stdout.write(self.style.WARNING("DRY RUN MODE - No changes will be made")) @@ -75,6 +83,10 @@ def handle(self, *args, **options): # Print summary self._print_summary() + # Update badges after successful sync + if not options["dry_run"]: + self._update_badges() + except Exception as e: logger.exception("Error syncing awards") self.stdout.write(self.style.ERROR(f"Error syncing awards: {e!s}")) @@ -90,6 +102,7 @@ def _process_award(self, award_data: dict, *, dry_run: bool = False): award_name = award_data.get("title", "") category = award_data.get("category", "") year = award_data.get("year") + award_description = award_data.get("description", "") or "" winners = award_data.get("winners", []) if not award_name or not category or not year: @@ -106,6 +119,7 @@ def _process_award(self, award_data: dict, *, dry_run: bool = False): "name": winner_data.get("name", ""), "info": winner_data.get("info", ""), "image": winner_data.get("image", ""), + "description": award_description, } self._process_winner(winner_with_context, dry_run=dry_run) @@ -138,6 +152,8 @@ def _process_winner(self, winner_data: dict, *, dry_run: bool = False): is_new = False except Award.DoesNotExist: is_new = True + except Award.MultipleObjectsReturned: + is_new = False # Use the model's update_data method award = Award.update_data(winner_data, save=True) @@ -239,15 +255,29 @@ def _extract_github_username(self, text: str) -> str | None: if not text: return None - # Pattern 1: github.com/username - github_url_pattern = r"github\.com/([a-zA-Z0-9\-_]+)" + # Pattern 1: github.com/ (exclude known non-user segments) + excluded = { + "orgs", + "organizations", + "topics", + "enterprise", + "marketplace", + "settings", + "apps", + "features", + "pricing", + "sponsors", + } + github_url_pattern = r"(?:https?://)?(?:www\.)?github\.com/([A-Za-z0-9-]+)(?=[/\s]|$)" match = re.search(github_url_pattern, text, re.IGNORECASE) if match: - return match.group(1) + candidate = match.group(1) + if candidate.lower() not in excluded: + return candidate - # Pattern 2: @username mentions - mention_pattern = r"@([a-zA-Z0-9\-_]+)" - match = re.search(mention_pattern, text) + # Pattern 2: @username mentions (avoid emails/local-parts) + mention_pattern = r"(? li return [] potential_logins = [] - clean_name = re.sub(r"[^a-zA-Z0-9\s\-_]", "", name).strip() + clean_name = re.sub(r"[^a-zA-Z0-9\s\-]", "", name).strip() # Convert to lowercase and replace spaces base_variations = [ clean_name.lower().replace(" ", ""), clean_name.lower().replace(" ", "-"), - clean_name.lower().replace(" ", "_"), ] # Add variations with different cases for variation in base_variations: - potential_logins.extend( - [ - variation, - variation.replace("-", ""), - variation.replace("_", ""), - variation.replace("-", "_"), - variation.replace("_", "-"), - ] - ) + potential_logins.extend([variation, variation.replace("-", "")]) # Remove duplicates while preserving order seen = set() unique_logins = [] for login in potential_logins: + # Skip invalid characters for GitHub logins + if "_" in login: + continue if login and login not in seen and len(login) >= min_login_length: seen.add(login) unique_logins.append(login) @@ -306,3 +330,15 @@ def _print_summary(self): self.stdout.write(f" - {name}") self.stdout.write("\nSync completed successfully!") + + def _update_badges(self): + """Update user badges based on synced awards.""" + from django.core.management import call_command + + self.stdout.write("Updating user badges...") + try: + call_command("owasp_update_badges") + self.stdout.write("Badge update completed successfully!") + except Exception as e: + logger.exception("Error updating badges") + self.stdout.write(self.style.ERROR(f"Error updating badges: {e!s}")) diff --git a/backend/apps/owasp/management/commands/owasp_update_badges.py b/backend/apps/owasp/management/commands/owasp_update_badges.py index 3ae3510fa7..a40efb15f3 100644 --- a/backend/apps/owasp/management/commands/owasp_update_badges.py +++ b/backend/apps/owasp/management/commands/owasp_update_badges.py @@ -29,6 +29,8 @@ def handle(self, *args, **options): # Get users with WASPY awards using the model method waspy_winners = Award.get_waspy_award_winners() + waspy_winner_ids = set(waspy_winners.values_list("id", flat=True)) + waspy_winners_count = len(waspy_winner_ids) # Add badge to WASPY winners for user in waspy_winners: @@ -36,10 +38,9 @@ def handle(self, *args, **options): # Remove badge from users no longer on the WASPY winners list users_with_badge = User.objects.filter(badges=waspy_badge) - waspy_winner_ids = set(waspy_winners.values_list("id", flat=True)) for user in users_with_badge: if user.id not in waspy_winner_ids: user.badges.remove(waspy_badge) - self.stdout.write(f"Updated badges for {waspy_winners.count()} WASPY winners") + self.stdout.write(f"Updated badges for {waspy_winners_count} WASPY winners") diff --git a/backend/apps/owasp/migrations/0049_award_unique_constraint.py b/backend/apps/owasp/migrations/0049_award_unique_constraint.py new file mode 100644 index 0000000000..4dedd609aa --- /dev/null +++ b/backend/apps/owasp/migrations/0049_award_unique_constraint.py @@ -0,0 +1,12 @@ +# No-op migration: Award.name field is already unique + +from django.db import migrations + + +class Migration(migrations.Migration): + dependencies = [ + ("owasp", "0048_entitymember"), + ] + + # No operations: Award.name field is already unique and sufficient + operations = [] diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py index 99bd3098c7..2f31247abd 100644 --- a/backend/apps/owasp/models/award.py +++ b/backend/apps/owasp/models/award.py @@ -86,14 +86,17 @@ class Meta: def __str__(self) -> str: """Return string representation of the award.""" + parts = [self.name] if self.winner_name: - return f"{self.name} - {self.winner_name} ({self.year})" - return f"{self.name} ({self.year})" + parts.append(f"- {self.winner_name}") + if self.year is not None: + parts.append(f"({self.year})") + return " ".join(parts) @property def display_name(self) -> str: """Get display name for the award.""" - return f"{self.name} ({self.year})" + return f"{self.name} ({self.year})" if self.year is not None else self.name @classmethod def get_waspy_award_winners(cls): @@ -140,10 +143,10 @@ def update_data(award_data: dict, *, save: bool = True) -> Award: """ # Create unique name for each winner to satisfy unique constraint - award_title = award_data.get("title", "") - category = award_data.get("category", "") + award_title = (award_data.get("title") or "").strip() + category = (award_data.get("category") or "").strip() year = award_data.get("year") - winner_name = award_data.get("name", "").strip() + winner_name = (award_data.get("name") or "").strip() # Create unique name combining award title, winner, and year unique_name = f"{award_title} - {winner_name} ({year})" @@ -157,6 +160,8 @@ def update_data(award_data: dict, *, save: bool = True) -> Award: year=year, winner_name=winner_name, ) + except Award.MultipleObjectsReturned: + award = Award.objects.filter(name=unique_name).order_by("id").first() award.from_dict(award_data) if save: @@ -171,10 +176,14 @@ def from_dict(self, data: dict) -> None: data: Dictionary containing award data """ + raw_image = (data.get("image") or data.get("winner_image_url") or "").strip() + if raw_image and raw_image.startswith("/"): + # Convert to absolute URL for admin/form validation + raw_image = f"https://owasp.org{raw_image}" fields = { - "description": data.get("description", ""), - "winner_info": data.get("info", ""), - "winner_image_url": data.get("image", ""), + "description": (data.get("description") or "").strip(), + "winner_info": (data.get("info") or data.get("winner_info") or "").strip(), + "winner_image_url": raw_image, } for key, value in fields.items(): diff --git a/backend/tests/apps/owasp/management/__init__.py b/backend/tests/apps/owasp/management/__init__.py index e69de29bb2..2ebdf83d39 100644 --- a/backend/tests/apps/owasp/management/__init__.py +++ b/backend/tests/apps/owasp/management/__init__.py @@ -0,0 +1 @@ +"""OWASP management tests module.""" diff --git a/backend/tests/apps/owasp/management/commands/__init__.py b/backend/tests/apps/owasp/management/commands/__init__.py index e69de29bb2..617bc67421 100644 --- a/backend/tests/apps/owasp/management/commands/__init__.py +++ b/backend/tests/apps/owasp/management/commands/__init__.py @@ -0,0 +1 @@ +"""OWASP management commands tests module.""" diff --git a/backend/tests/apps/owasp/management/commands/owasp_update_badges_test.py b/backend/tests/apps/owasp/management/commands/owasp_update_badges_test.py new file mode 100644 index 0000000000..310b04249c --- /dev/null +++ b/backend/tests/apps/owasp/management/commands/owasp_update_badges_test.py @@ -0,0 +1,90 @@ +"""Tests for owasp_update_badges management command.""" + +from django.core.management import call_command +from django.test import TestCase + +from apps.github.models.user import User +from apps.nest.models.badge import Badge +from apps.owasp.models.award import Award + +WASPY_BADGE_NAME = "WASPY Award Winner" + + +class TestOwaspUpdateBadges(TestCase): + """Test cases for owasp_update_badges command.""" + + def setUp(self): + """Set up test data.""" + self.user1 = User.objects.create(login="winner1", name="Winner One") + self.user2 = User.objects.create(login="not_winner", name="Not Winner") + + # Reviewed WASPY award -> should receive badge + Award.objects.create( + category=Award.Category.WASPY, + name="Event Person of the Year - Winner One (2024)", + description="", + year=2024, + winner_name="Winner One", + user=self.user1, + is_reviewed=True, + ) + + def test_award_badge_add_and_remove(self): + """Test badge assignment and removal based on award status.""" + # Run command - should assign badge to user1 + call_command("owasp_update_badges") + + waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME) + assert self.user1.badges.filter(pk=waspy_badge.pk).exists() + assert not self.user2.badges.filter(pk=waspy_badge.pk).exists() + + # Make user1 no longer eligible by marking award as not reviewed + Award.objects.filter(user=self.user1).update(is_reviewed=False) + call_command("owasp_update_badges") + + # Refresh and check badge was removed + self.user1.refresh_from_db() + assert not self.user1.badges.filter(pk=waspy_badge.pk).exists() + + def test_repeated_runs(self): + """Test that running command twice doesn't create duplicates.""" + call_command("owasp_update_badges") + call_command("owasp_update_badges") # Run twice + + waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME) + # Should still have exactly one badge association + assert self.user1.badges.filter(pk=waspy_badge.pk).count() == 1 + + def test_badge_creation(self): + """Test that badge is created if it doesn't exist.""" + # Ensure badge doesn't exist + Badge.objects.filter(name=WASPY_BADGE_NAME).delete() + + call_command("owasp_update_badges") + + # Badge should be created + assert Badge.objects.filter(name=WASPY_BADGE_NAME).exists() + + # User should have the badge + waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME) + assert self.user1.badges.filter(pk=waspy_badge.pk).exists() + + def test_only_reviewed_awards_get_badges(self): + """Test that only reviewed awards result in badge assignment.""" + # Create not reviewed award + Award.objects.create( + category=Award.Category.WASPY, + name="Another Award - Not Winner (2024)", + description="", + year=2024, + winner_name="Not Winner", + user=self.user2, + is_reviewed=False, # Not reviewed + ) + + call_command("owasp_update_badges") + + waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME) + # Only user1 (reviewed award) should have badge + assert self.user1.badges.filter(pk=waspy_badge.pk).exists() + assert not self.user2.badges.filter(pk=waspy_badge.pk).exists() diff --git a/backend/tests/apps/owasp/models/award_duplicate_handling_test.py b/backend/tests/apps/owasp/models/award_duplicate_handling_test.py new file mode 100644 index 0000000000..9a4a445b9a --- /dev/null +++ b/backend/tests/apps/owasp/models/award_duplicate_handling_test.py @@ -0,0 +1,38 @@ +"""Tests for Award model duplicate handling.""" + +from unittest.mock import patch + +from django.test import TestCase + +from apps.owasp.models import Award + + +class TestAwardDuplicateHandling(TestCase): + """Test cases for Award model duplicate handling.""" + + def test_update_data_handles_multiple_objects_returned(self): + """Test that update_data gracefully handles MultipleObjectsReturned.""" + award_data = { + "title": "Test Award", + "category": Award.Category.WASPY, + "year": 2024, + "name": "Test Winner", + "info": "Test info", + } + + # Create the first award normally + award1 = Award.update_data(award_data, save=True) + + # Keep a single real row; simulate duplicates at the ORM level by + # forcing get() to raise MultipleObjectsReturned. + + # Now call update_data again - should handle MultipleObjectsReturned gracefully + with patch( + "apps.owasp.models.award.Award.objects.get", side_effect=Award.MultipleObjectsReturned + ): + result_award = Award.update_data(award_data, save=False) + + # Should return the first award (ordered by id) + assert result_award.id == award1.id + + # No cleanup needed; no duplicate row was created diff --git a/backend/tests/apps/owasp/models/award_test.py b/backend/tests/apps/owasp/models/award_test.py index eeb8230ff8..1d796b7744 100644 --- a/backend/tests/apps/owasp/models/award_test.py +++ b/backend/tests/apps/owasp/models/award_test.py @@ -8,13 +8,13 @@ class AwardModelTest(TestCase): def test_award_import(self): """Test that Award model can be imported.""" - from apps.owasp.models.award import Award + from apps.owasp.models import Award assert Award is not None def test_award_category_choices(self): """Test Award category choices.""" - from apps.owasp.models.award import Award + from apps.owasp.models import Award choices = Award.Category.choices assert ("WASPY", "WASPY") in choices @@ -25,7 +25,7 @@ def test_award_category_choices(self): def test_award_meta(self): """Test Award model meta.""" - from apps.owasp.models.award import Award + from apps.owasp.models import Award assert Award._meta.db_table == "owasp_awards" assert Award._meta.verbose_name == "Award"