Skip to content

Commit 3decd96

Browse files
committed
implement OWASP awards system improvements
1 parent 94ed30c commit 3decd96

File tree

11 files changed

+228
-33
lines changed

11 files changed

+228
-33
lines changed

backend/apps/owasp/Makefile

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,8 @@ owasp-process-snapshots:
2626
@echo "Processing OWASP snapshots"
2727
@CMD="python manage.py owasp_process_snapshots" $(MAKE) exec-backend-command
2828

29+
.PHONY: owasp-sync-awards
30+
2931
owasp-sync-awards:
3032
@echo "Syncing OWASP awards data"
3133
@CMD="python manage.py owasp_sync_awards" $(MAKE) exec-backend-command
@@ -68,6 +70,8 @@ owasp-update-sponsors:
6870
@echo "Getting OWASP sponsors data"
6971
@CMD="python manage.py owasp_update_sponsors" $(MAKE) exec-backend-command
7072

73+
.PHONY: owasp-update-badges
74+
7175
owasp-update-badges:
7276
@echo "Updating OWASP user badges"
7377
@CMD="python manage.py owasp_update_badges" $(MAKE) exec-backend-command

backend/apps/owasp/admin/award.py

Lines changed: 14 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,16 @@ class AwardAdmin(admin.ModelAdmin):
2626
)
2727
search_fields = (
2828
"name",
29-
"category",
3029
"winner_name",
3130
"description",
3231
"winner_info",
32+
"user__login",
3333
)
3434
ordering = ("-year", "category", "name")
3535

3636
autocomplete_fields = ("user",)
37+
actions = ("mark_reviewed", "mark_not_reviewed")
38+
list_select_related = ("user",)
3739

3840
fieldsets = (
3941
(
@@ -64,6 +66,14 @@ class AwardAdmin(admin.ModelAdmin):
6466

6567
readonly_fields = ("nest_created_at", "nest_updated_at")
6668

67-
def get_queryset(self, request):
68-
"""Optimize queryset with select_related."""
69-
return super().get_queryset(request).select_related("user")
69+
@admin.action(description="Mark selected awards as reviewed")
70+
def mark_reviewed(self, request, queryset):
71+
"""Mark selected awards as reviewed."""
72+
updated = queryset.update(is_reviewed=True)
73+
self.message_user(request, f"Marked {updated} award(s) as reviewed.")
74+
75+
@admin.action(description="Mark selected awards as not reviewed")
76+
def mark_not_reviewed(self, request, queryset):
77+
"""Mark selected awards as not reviewed."""
78+
updated = queryset.update(is_reviewed=False)
79+
self.message_user(request, f"Marked {updated} award(s) as not reviewed.")

backend/apps/owasp/management/commands/owasp_sync_awards.py

Lines changed: 54 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,12 +58,20 @@ def handle(self, *args, **options):
5858
self.stdout.write(self.style.ERROR("Failed to download awards.yml from GitHub"))
5959
return
6060

61-
awards_data = yaml.safe_load(yaml_content)
61+
try:
62+
awards_data = yaml.safe_load(yaml_content)
63+
except yaml.YAMLError as e:
64+
self.stdout.write(self.style.ERROR(f"Failed to parse awards.yml: {e}"))
65+
return
6266

6367
if not awards_data:
6468
self.stdout.write(self.style.ERROR("Failed to parse awards.yml content"))
6569
return
6670

71+
if not isinstance(awards_data, list):
72+
self.stdout.write(self.style.ERROR("awards.yml root must be a list of entries"))
73+
return
74+
6775
# Process awards data
6876
if options["dry_run"]:
6977
self.stdout.write(self.style.WARNING("DRY RUN MODE - No changes will be made"))
@@ -75,6 +83,10 @@ def handle(self, *args, **options):
7583
# Print summary
7684
self._print_summary()
7785

86+
# Update badges after successful sync
87+
if not options["dry_run"]:
88+
self._update_badges()
89+
7890
except Exception as e:
7991
logger.exception("Error syncing awards")
8092
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):
90102
award_name = award_data.get("title", "")
91103
category = award_data.get("category", "")
92104
year = award_data.get("year")
105+
award_description = award_data.get("description", "") or ""
93106
winners = award_data.get("winners", [])
94107

95108
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):
106119
"name": winner_data.get("name", ""),
107120
"info": winner_data.get("info", ""),
108121
"image": winner_data.get("image", ""),
122+
"description": award_description,
109123
}
110124

111125
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):
138152
is_new = False
139153
except Award.DoesNotExist:
140154
is_new = True
155+
except Award.MultipleObjectsReturned:
156+
is_new = False
141157

142158
# Use the model's update_data method
143159
award = Award.update_data(winner_data, save=True)
@@ -239,15 +255,29 @@ def _extract_github_username(self, text: str) -> str | None:
239255
if not text:
240256
return None
241257

242-
# Pattern 1: github.com/username
243-
github_url_pattern = r"github\.com/([a-zA-Z0-9\-_]+)"
258+
# Pattern 1: github.com/<username> (exclude known non-user segments)
259+
excluded = {
260+
"orgs",
261+
"organizations",
262+
"topics",
263+
"enterprise",
264+
"marketplace",
265+
"settings",
266+
"apps",
267+
"features",
268+
"pricing",
269+
"sponsors",
270+
}
271+
github_url_pattern = r"(?:https?://)?(?:www\.)?github\.com/([A-Za-z0-9-]+)(?=[/\s]|$)"
244272
match = re.search(github_url_pattern, text, re.IGNORECASE)
245273
if match:
246-
return match.group(1)
274+
candidate = match.group(1)
275+
if candidate.lower() not in excluded:
276+
return candidate
247277

248-
# Pattern 2: @username mentions
249-
mention_pattern = r"@([a-zA-Z0-9\-_]+)"
250-
match = re.search(mention_pattern, text)
278+
# Pattern 2: @username mentions (avoid emails/local-parts)
279+
mention_pattern = r"(?<![A-Za-z0-9._%+-])@([A-Za-z0-9-]+)\b"
280+
match = re.search(mention_pattern, text, re.IGNORECASE)
251281
if match:
252282
return match.group(1)
253283

@@ -259,31 +289,25 @@ def _generate_potential_logins(self, name: str, min_login_length: int = 2) -> li
259289
return []
260290

261291
potential_logins = []
262-
clean_name = re.sub(r"[^a-zA-Z0-9\s\-_]", "", name).strip()
292+
clean_name = re.sub(r"[^a-zA-Z0-9\s\-]", "", name).strip()
263293

264294
# Convert to lowercase and replace spaces
265295
base_variations = [
266296
clean_name.lower().replace(" ", ""),
267297
clean_name.lower().replace(" ", "-"),
268-
clean_name.lower().replace(" ", "_"),
269298
]
270299

271300
# Add variations with different cases
272301
for variation in base_variations:
273-
potential_logins.extend(
274-
[
275-
variation,
276-
variation.replace("-", ""),
277-
variation.replace("_", ""),
278-
variation.replace("-", "_"),
279-
variation.replace("_", "-"),
280-
]
281-
)
302+
potential_logins.extend([variation, variation.replace("-", "")])
282303

283304
# Remove duplicates while preserving order
284305
seen = set()
285306
unique_logins = []
286307
for login in potential_logins:
308+
# Skip invalid characters for GitHub logins
309+
if "_" in login:
310+
continue
287311
if login and login not in seen and len(login) >= min_login_length:
288312
seen.add(login)
289313
unique_logins.append(login)
@@ -306,3 +330,15 @@ def _print_summary(self):
306330
self.stdout.write(f" - {name}")
307331

308332
self.stdout.write("\nSync completed successfully!")
333+
334+
def _update_badges(self):
335+
"""Update user badges based on synced awards."""
336+
from django.core.management import call_command
337+
338+
self.stdout.write("Updating user badges...")
339+
try:
340+
call_command("owasp_update_badges")
341+
self.stdout.write("Badge update completed successfully!")
342+
except Exception as e:
343+
logger.exception("Error updating badges")
344+
self.stdout.write(self.style.ERROR(f"Error updating badges: {e!s}"))

backend/apps/owasp/management/commands/owasp_update_badges.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -29,17 +29,18 @@ def handle(self, *args, **options):
2929

3030
# Get users with WASPY awards using the model method
3131
waspy_winners = Award.get_waspy_award_winners()
32+
waspy_winner_ids = set(waspy_winners.values_list("id", flat=True))
33+
waspy_winners_count = len(waspy_winner_ids)
3234

3335
# Add badge to WASPY winners
3436
for user in waspy_winners:
3537
user.badges.add(waspy_badge)
3638

3739
# Remove badge from users no longer on the WASPY winners list
3840
users_with_badge = User.objects.filter(badges=waspy_badge)
39-
waspy_winner_ids = set(waspy_winners.values_list("id", flat=True))
4041

4142
for user in users_with_badge:
4243
if user.id not in waspy_winner_ids:
4344
user.badges.remove(waspy_badge)
4445

45-
self.stdout.write(f"Updated badges for {waspy_winners.count()} WASPY winners")
46+
self.stdout.write(f"Updated badges for {waspy_winners_count} WASPY winners")
Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
# No-op migration: Award.name field is already unique
2+
3+
from django.db import migrations
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("owasp", "0048_entitymember"),
9+
]
10+
11+
# No operations: Award.name field is already unique and sufficient
12+
operations = []

backend/apps/owasp/models/award.py

Lines changed: 8 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -140,10 +140,10 @@ def update_data(award_data: dict, *, save: bool = True) -> Award:
140140
141141
"""
142142
# Create unique name for each winner to satisfy unique constraint
143-
award_title = award_data.get("title", "")
144-
category = award_data.get("category", "")
143+
award_title = (award_data.get("title") or "").strip()
144+
category = (award_data.get("category") or "").strip()
145145
year = award_data.get("year")
146-
winner_name = award_data.get("name", "").strip()
146+
winner_name = (award_data.get("name") or "").strip()
147147

148148
# Create unique name combining award title, winner, and year
149149
unique_name = f"{award_title} - {winner_name} ({year})"
@@ -157,6 +157,8 @@ def update_data(award_data: dict, *, save: bool = True) -> Award:
157157
year=year,
158158
winner_name=winner_name,
159159
)
160+
except Award.MultipleObjectsReturned:
161+
award = Award.objects.filter(name=unique_name).order_by("id").first()
160162

161163
award.from_dict(award_data)
162164
if save:
@@ -172,9 +174,9 @@ def from_dict(self, data: dict) -> None:
172174
173175
"""
174176
fields = {
175-
"description": data.get("description", ""),
176-
"winner_info": data.get("info", ""),
177-
"winner_image_url": data.get("image", ""),
177+
"description": (data.get("description") or "").strip(),
178+
"winner_info": (data.get("info") or data.get("winner_info") or "").strip(),
179+
"winner_image_url": (data.get("image") or data.get("winner_image_url") or "").strip(),
178180
}
179181

180182
for key, value in fields.items():
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""OWASP management tests module."""
Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""OWASP management commands tests module."""
Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,90 @@
1+
"""Tests for owasp_update_badges management command."""
2+
3+
from django.core.management import call_command
4+
from django.test import TestCase
5+
6+
from apps.github.models.user import User
7+
from apps.nest.models.badge import Badge
8+
from apps.owasp.models.award import Award
9+
10+
WASPY_BADGE_NAME = "WASPY Award Winner"
11+
12+
13+
class TestOwaspUpdateBadges(TestCase):
14+
"""Test cases for owasp_update_badges command."""
15+
16+
def setUp(self):
17+
"""Set up test data."""
18+
self.user1 = User.objects.create(login="winner1", name="Winner One")
19+
self.user2 = User.objects.create(login="not_winner", name="Not Winner")
20+
21+
# Reviewed WASPY award -> should receive badge
22+
Award.objects.create(
23+
category=Award.Category.WASPY,
24+
name="Event Person of the Year - Winner One (2024)",
25+
description="",
26+
year=2024,
27+
winner_name="Winner One",
28+
user=self.user1,
29+
is_reviewed=True,
30+
)
31+
32+
def test_award_badge_add_and_remove(self):
33+
"""Test badge assignment and removal based on award status."""
34+
# Run command - should assign badge to user1
35+
call_command("owasp_update_badges")
36+
37+
waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME)
38+
assert self.user1.badges.filter(pk=waspy_badge.pk).exists()
39+
assert not self.user2.badges.filter(pk=waspy_badge.pk).exists()
40+
41+
# Make user1 no longer eligible by marking award as not reviewed
42+
Award.objects.filter(user=self.user1).update(is_reviewed=False)
43+
call_command("owasp_update_badges")
44+
45+
# Refresh and check badge was removed
46+
self.user1.refresh_from_db()
47+
assert not self.user1.badges.filter(pk=waspy_badge.pk).exists()
48+
49+
def test_repeated_runs(self):
50+
"""Test that running command twice doesn't create duplicates."""
51+
call_command("owasp_update_badges")
52+
call_command("owasp_update_badges") # Run twice
53+
54+
waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME)
55+
# Should still have exactly one badge association
56+
assert self.user1.badges.filter(pk=waspy_badge.pk).count() == 1
57+
58+
def test_badge_creation(self):
59+
"""Test that badge is created if it doesn't exist."""
60+
# Ensure badge doesn't exist
61+
Badge.objects.filter(name=WASPY_BADGE_NAME).delete()
62+
63+
call_command("owasp_update_badges")
64+
65+
# Badge should be created
66+
assert Badge.objects.filter(name=WASPY_BADGE_NAME).exists()
67+
68+
# User should have the badge
69+
waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME)
70+
assert self.user1.badges.filter(pk=waspy_badge.pk).exists()
71+
72+
def test_only_reviewed_awards_get_badges(self):
73+
"""Test that only reviewed awards result in badge assignment."""
74+
# Create not reviewed award
75+
Award.objects.create(
76+
category=Award.Category.WASPY,
77+
name="Another Award - Not Winner (2024)",
78+
description="",
79+
year=2024,
80+
winner_name="Not Winner",
81+
user=self.user2,
82+
is_reviewed=False, # Not reviewed
83+
)
84+
85+
call_command("owasp_update_badges")
86+
87+
waspy_badge = Badge.objects.get(name=WASPY_BADGE_NAME)
88+
# Only user1 (reviewed award) should have badge
89+
assert self.user1.badges.filter(pk=waspy_badge.pk).exists()
90+
assert not self.user2.badges.filter(pk=waspy_badge.pk).exists()

0 commit comments

Comments
 (0)