diff --git a/backend/Makefile b/backend/Makefile index e2e88f2415..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: @@ -134,6 +135,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/admin/badge.py b/backend/apps/nest/admin/badge.py index 05828a3853..ca02683e30 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") diff --git a/backend/apps/owasp/Makefile b/backend/apps/owasp/Makefile index 4febcd2572..1621e36806 100644 --- a/backend/apps/owasp/Makefile +++ b/backend/apps/owasp/Makefile @@ -26,6 +26,12 @@ 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 + owasp-update-leaders: @CMD="python manage.py owasp_update_leaders $(MATCH_MODEL)" $(MAKE) exec-backend-command @@ -63,3 +69,9 @@ owasp-update-events: 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/__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..c738715f77 --- /dev/null +++ b/backend/apps/owasp/admin/award.py @@ -0,0 +1,81 @@ +"""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", + "winner_name", + "user", + "is_reviewed", + "nest_created_at", + "nest_updated_at", + ) + list_display_links = ("name", "winner_name") + list_per_page = 50 + list_filter = ( + "category", + "year", + "is_reviewed", + ) + search_fields = ( + "name", + "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 = ( + ( + "Basic Information", + {"fields": ("name", "category", "year", "description")}, + ), + ( + "Winner Information", + { + "fields": ( + "winner_name", + "winner_info", + "winner_image_url", + "user", + "is_reviewed", + ), + "classes": ("collapse",), + }, + ), + ( + "Timestamps", + { + "fields": ("nest_created_at", "nest_updated_at"), + "classes": ("collapse",), + }, + ), + ) + + readonly_fields = ("nest_created_at", "nest_updated_at") + + @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 new file mode 100644 index 0000000000..d5dd8a8c68 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_sync_awards.py @@ -0,0 +1,344 @@ +"""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 + + 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")) + 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() + + # 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}")) + + 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") == "award": + self._process_award(item, dry_run=dry_run) + + 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") + award_description = award_data.get("description", "") or "" + 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 using the model's update_data method + for winner_data in winners: + # 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", ""), + "description": award_description, + } + + 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() + 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_data.get("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: + # 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=unique_name) + 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) + + # 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: + 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/ (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: + candidate = match.group(1) + if candidate.lower() not in excluded: + return candidate + + # Pattern 2: @username mentions (avoid emails/local-parts) + mention_pattern = r"(? 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(" ", "-"), + ] + + # Add variations with different cases + for variation in base_variations: + 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) + + 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!") + + 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 new file mode 100644 index 0000000000..a40efb15f3 --- /dev/null +++ b/backend/apps/owasp/management/commands/owasp_update_badges.py @@ -0,0 +1,46 @@ +"""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() + 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: + 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) + + 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/migrations/0045_award.py b/backend/apps/owasp/migrations/0045_award.py new file mode 100644 index 0000000000..8b91a4916f --- /dev/null +++ b/backend/apps/owasp/migrations/0045_award.py @@ -0,0 +1,120 @@ +# 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( + 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", + ), + ), + ( + "name", + models.CharField( + help_text="Award name/title (e.g., 'Event Person of the Year')", + max_length=200, + unique=True, + verbose_name="Name", + ), + ), + ( + "description", + models.TextField( + blank=True, + default="", + help_text="Award description", + verbose_name="Description", + ), + ), + ( + "year", + models.IntegerField( + help_text="Year the award was given", + 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_url", + models.URLField( + blank=True, + default="", + help_text="URL to winner's image", + verbose_name="Winner Image URL", + ), + ), + ( + "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", + ), + ), + ( + "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", + "verbose_name_plural": "Awards", + "db_table": "owasp_awards", + }, + ), + ] 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/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/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/__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 diff --git a/backend/apps/owasp/models/award.py b/backend/apps/owasp/models/award.py new file mode 100644 index 0000000000..2f31247abd --- /dev/null +++ b/backend/apps/owasp/models/award.py @@ -0,0 +1,190 @@ +"""OWASP app award model.""" + +from __future__ import annotations + +from django.db import models + +from apps.common.models import BulkSaveModel, TimestampedModel + + +class Award(BulkSaveModel, 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 Category(models.TextChoices): + WASPY = "WASPY", "WASPY" + DISTINGUISHED_LIFETIME = ( + "Distinguished Lifetime Memberships", + "Distinguished Lifetime Memberships", + ) + + class Meta: + db_table = "owasp_awards" + verbose_name = "Award" + verbose_name_plural = "Awards" + + # Core fields based on YAML structure + category = models.CharField( + verbose_name="Category", + max_length=100, + choices=Category.choices, + help_text="Award category (e.g., 'WASPY', 'Distinguished Lifetime Memberships')", + ) + 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( + verbose_name="Description", blank=True, default="", help_text="Award description" + ) + year = models.IntegerField( + verbose_name="Year", + help_text="Year the award was given", + ) + + # 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_url = models.URLField( + verbose_name="Winner Image URL", + blank=True, + default="", + help_text="URL to winner's image", + ) + + # 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)", + ) + 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.""" + parts = [self.name] + if self.winner_name: + 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})" if self.year is not None else self.name + + @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=cls.Category.WASPY, awards__is_reviewed=True + ).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=cls.Category.WASPY) + + @staticmethod + 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 single award winner data + save: Whether to save the award instance + + Returns: + Award instance + + """ + # Create unique name for each winner to satisfy unique constraint + 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") or "").strip() + + # Create unique name combining award title, winner, and year + unique_name = f"{award_title} - {winner_name} ({year})" + + try: + award = Award.objects.get(name=unique_name) + except Award.DoesNotExist: + award = Award( + name=unique_name, + category=category, + 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: + award.save() + + return award + + def from_dict(self, data: dict) -> None: + """Update instance based on dict data. + + Args: + 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") 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(): + setattr(self, key, value) 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/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 new file mode 100644 index 0000000000..1d796b7744 --- /dev/null +++ b/backend/tests/apps/owasp/models/award_test.py @@ -0,0 +1,31 @@ +"""Tests for Award model.""" + +from django.test import TestCase + + +class AwardModelTest(TestCase): + """Test cases for Award model.""" + + def test_award_import(self): + """Test that Award model can be imported.""" + 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 import Award + + choices = Award.Category.choices + assert ("WASPY", "WASPY") in choices + assert ( + "Distinguished Lifetime Memberships", + "Distinguished Lifetime Memberships", + ) in choices + + def test_award_meta(self): + """Test Award model meta.""" + from apps.owasp.models import Award + + assert Award._meta.db_table == "owasp_awards" + assert Award._meta.verbose_name == "Award"