Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions backend/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -110,6 +110,7 @@ shell-db:
sync-data: \
update-data \
enrich-data \
nest-update-badges \
index-data

test-backend:
Expand Down Expand Up @@ -137,4 +138,5 @@ update-data: \
owasp-update-events \
owasp-sync-posts \
owasp-update-sponsors \
owasp-sync-awards \
slack-sync-data
68 changes: 61 additions & 7 deletions backend/apps/nest/management/commands/nest_update_badges.py
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,7 @@
logger = logging.getLogger(__name__)

OWASP_STAFF_BADGE_NAME = "OWASP Staff"
WASPY_AWARD_BADGE_NAME = "WASPY Award Winner"


class Command(BaseCommand):
Expand All @@ -22,6 +23,7 @@ def handle(self, *args, **options):
"""Execute the command."""
self.stdout.write("Syncing user badges...")
self.update_owasp_staff_badge()
self.update_waspy_award_badge()
self.stdout.write(self.style.SUCCESS("User badges sync completed"))

def update_owasp_staff_badge(self):
Expand All @@ -41,16 +43,14 @@ def update_owasp_staff_badge(self):
self.stdout.write(f"Created badge: {badge.name}")

# Assign badge to employees who don't have it.
employees_without_badge = User.objects.filter(
is_owasp_staff=True,
).exclude(
user_badges__badge=badge,
employees_missing_or_inactive = User.objects.filter(is_owasp_staff=True).exclude(
badges__badge=badge, badges__is_active=True
)
count = employees_without_badge.count()
count = employees_missing_or_inactive.count()

if count:
for user in employees_without_badge:
user_badge, created = UserBadge.objects.get_or_create(user=user, badge=badge)
for user in employees_missing_or_inactive:
user_badge, _ = UserBadge.objects.get_or_create(user=user, badge=badge)
if not user_badge.is_active:
user_badge.is_active = True
user_badge.save(update_fields=["is_active"])
Expand All @@ -73,3 +73,57 @@ def update_owasp_staff_badge(self):

logger.info("Removed '%s' badge from %s users", OWASP_STAFF_BADGE_NAME, removed_count)
self.stdout.write(f"Removed badge from {removed_count} non-employees")

def update_waspy_award_badge(self):
"""Sync WASPY Award Winner badge for users."""
# Get or create the WASPY Award Winner badge
badge, created = Badge.objects.get_or_create(
name=WASPY_AWARD_BADGE_NAME,
defaults={
"description": "WASPY Award Winner",
"css_class": "fa-trophy",
"weight": 90,
},
)

if created:
logger.info("Created '%s' badge", WASPY_AWARD_BADGE_NAME)
self.stdout.write(f"Created badge: {badge.name}")

# Get users with WASPY awards that have been reviewed
waspy_award_users = User.objects.filter(
awards__category="WASPY", awards__is_reviewed=True
).distinct()

# Assign badge to WASPY award winners who don't have it
users_missing_or_inactive = waspy_award_users.exclude(
badges__badge=badge, badges__is_active=True
)
count = users_missing_or_inactive.count()

if count:
for user in users_missing_or_inactive:
user_badge, _ = UserBadge.objects.get_or_create(user=user, badge=badge)
if not user_badge.is_active:
user_badge.is_active = True
user_badge.save(update_fields=["is_active"])

logger.info("Added '%s' badge to %s users", WASPY_AWARD_BADGE_NAME, count)
self.stdout.write(f"Added badge to {count} WASPY award winners")

# Remove badge from users who no longer have reviewed WASPY awards
non_waspy_users = (
User.objects.exclude(awards__category="WASPY", awards__is_reviewed=True)
.filter(badges__badge=badge)
.distinct()
)
removed_count = non_waspy_users.count()

if removed_count:
UserBadge.objects.filter(
user_id__in=non_waspy_users.values_list("id", flat=True),
badge=badge,
).update(is_active=False)

logger.info("Removed '%s' badge from %s users", WASPY_AWARD_BADGE_NAME, removed_count)
self.stdout.write(f"Removed badge from {removed_count} users without WASPY awards")
4 changes: 4 additions & 0 deletions backend/apps/owasp/Makefile
Original file line number Diff line number Diff line change
Expand Up @@ -63,3 +63,7 @@ owasp-update-events:
owasp-update-sponsors:
@echo "Getting OWASP sponsors data"
@CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command

owasp-sync-awards:
@echo "Syncing OWASP awards data"
@CMD="python manage.py owasp_sync_awards" $(MAKE) exec-backend-command
1 change: 1 addition & 0 deletions backend/apps/owasp/admin/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
16 changes: 16 additions & 0 deletions backend/apps/owasp/admin/award.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
"""OWASP app award admin."""

from django.contrib import admin

from apps.owasp.models.award import Award


@admin.register(Award)
class AwardAdmin(admin.ModelAdmin):
"""Award admin."""

list_display = ("name", "category", "year", "user", "is_reviewed", "created_at")
list_filter = ("category", "year", "is_reviewed", "created_at")
search_fields = ("name", "category", "user__name", "user__login")
readonly_fields = ("created_at", "updated_at")
raw_id_fields = ("user",)
148 changes: 148 additions & 0 deletions backend/apps/owasp/management/commands/owasp_sync_awards.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"""A command to sync OWASP awards."""

import logging

import yaml
from django.core.management.base import BaseCommand

from apps.github.models.user import User
from apps.github.utils import get_repository_file_content
from apps.owasp.models.award import Award

logger = logging.getLogger(__name__)

# Year validation constants
MIN_VALID_YEAR = 1900
MAX_VALID_YEAR = 2100


class Command(BaseCommand):
help = "Import awards from the OWASP awards YAML file"

def handle(self, *args, **kwargs) -> None:
"""Handle the command execution."""
self.stdout.write("Syncing OWASP awards...")

url = "https://raw.githubusercontent.com/OWASP/owasp.github.io/main/_data/awards.yml"
raw = get_repository_file_content(url)
if not raw:
self.stderr.write(self.style.WARNING("No awards data fetched; aborting."))
return
try:
data = yaml.safe_load(raw) or []
except yaml.YAMLError as e:
self.stderr.write(self.style.ERROR(f"Failed to parse awards YAML: {e}"))
return
if not isinstance(data, list):
self.stderr.write(
self.style.WARNING("Unexpected awards YAML structure; expected a list.")
)
return

awards_to_save = []
skipped_count = 0
for item in data:
if item.get("type") == "award":
winners = item.get("winners", [])
for winner in winners:
award = self._create_or_update_award(item, winner)
if award:
awards_to_save.append(award)
else:
skipped_count += 1

Award.bulk_save(awards_to_save, fields=("category", "description", "year", "user"))
self.stdout.write(self.style.SUCCESS(f"Successfully synced {len(awards_to_save)} awards"))
if skipped_count:
self.stdout.write(
self.style.WARNING(f"Skipped {skipped_count} awards due to invalid data")
)

def _create_or_update_award(self, award_data, winner_data):
"""Create or update award instance."""
# Safely extract values with defaults
title = award_data.get("title", "")
category = award_data.get("category", "")

# Validate and parse year
try:
year = int(award_data.get("year", 0))
if year <= 0 or year < MIN_VALID_YEAR or year > MAX_VALID_YEAR:
logger.warning("Invalid year %s for award %s, skipping", year, title)
return None
except (ValueError, TypeError):
logger.warning(
"Could not parse year %s for award %s, skipping", award_data.get("year"), title
)
return None

# Handle winner_data being string or dict
if isinstance(winner_data, str):
winner_name = winner_data
winner_info = ""
else:
# Prefer explicit GitHub login over name
login = winner_data.get("login") or winner_data.get("github")
if login:
login = login.lstrip("@")
# Skip bot accounts
if "bot" in login.lower() or login.lower().endswith("[bot]"):
logger.warning("Skipping bot account: %s", login)
return None
winner_name = login
else:
winner_name = winner_data.get("name", "")
winner_info = winner_data.get("info", "")

name = f"{title} - {winner_name} ({year})"

try:
award = Award.objects.get(name=name)
except Award.DoesNotExist:
award = Award(name=name)

award.category = category
award.description = winner_info
award.year = year

# Only set user if not already reviewed
if not (award.user and award.is_reviewed):
user = self._match_user(winner_name)
if user:
award.user = user
else:
logger.warning("Could not match user for award winner: %s", winner_name)

return award

def _match_user(self, winner_name):
"""Try to match award winner with existing user."""
winner_name = winner_name.strip()

# Check if it looks like a GitHub handle
if winner_name.startswith("@") or (" " not in winner_name and winner_name):
# Strip leading @ and try login match first
login_name = winner_name.lstrip("@")
user = User.objects.filter(login__iexact=login_name).first()
if user:
return user

# Try exact name match
user = User.objects.filter(name__iexact=winner_name).first()
if user:
return user

# Try partial name match
name_parts = winner_name.split()
min_name_parts = 2
if len(name_parts) >= min_name_parts:
first_name, last_name = name_parts[0], name_parts[-1]
user = (
User.objects.filter(name__icontains=first_name)
.filter(name__icontains=last_name)
.first()
)
if user:
return user

return None
1 change: 1 addition & 0 deletions backend/apps/owasp/models/__init__.py
Original file line number Diff line number Diff line change
@@ -1,3 +1,4 @@
from .award import Award
from .board_of_directors import BoardOfDirectors
from .chapter import Chapter
from .committee import Committee
Expand Down
65 changes: 65 additions & 0 deletions backend/apps/owasp/models/award.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,65 @@
"""OWASP app award model."""

from __future__ import annotations

from django.db import models

from apps.common.models import BulkSaveModel, TimestampedModel


class Award(BulkSaveModel, TimestampedModel):
"""Award model."""

class Meta:
db_table = "owasp_awards"
indexes = [
models.Index(fields=["-year"], name="award_year_desc_idx"),
]
constraints = [
models.UniqueConstraint(
fields=["category", "name", "year"],
name="uniq_award_cat_name_year",
),
]
verbose_name_plural = "Awards"

category = models.CharField(verbose_name="Category", max_length=100)
name = models.CharField(verbose_name="Name", max_length=255)
description = models.TextField(verbose_name="Description", blank=True, default="")
year = models.IntegerField(verbose_name="Year")
is_reviewed = models.BooleanField(
verbose_name="Is reviewed",
default=False,
help_text="Indicates if the user matching has been reviewed by a human.",
)

# FKs.
user = models.ForeignKey(
"github.User",
on_delete=models.SET_NULL,
blank=True,
null=True,
related_name="awards",
verbose_name="User",
)

def __str__(self) -> str:
"""Award human readable representation."""
return f"{self.name} ({self.year})"

@staticmethod
def bulk_save( # type: ignore[override]
awards: list,
fields: tuple[str, ...] | None = None,
) -> None:
"""Bulk save awards.

Args:
awards (list): A list of Award instances to be saved.
fields (tuple, optional): A tuple of fields to update during the bulk save.

Returns:
None

"""
BulkSaveModel.bulk_save(Award, awards, fields=fields)
Loading