Skip to content

Commit e224251

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

File tree

11 files changed

+235
-33
lines changed

11 files changed

+235
-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: 15 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -26,14 +26,15 @@ class AwardAdmin(admin.ModelAdmin):
2626
)
2727
search_fields = (
2828
"name",
29-
"category",
3029
"winner_name",
3130
"description",
3231
"winner_info",
3332
)
3433
ordering = ("-year", "category", "name")
3534

3635
autocomplete_fields = ("user",)
36+
actions = ("mark_reviewed", "mark_unreviewed")
37+
list_select_related = ("user",)
3738

3839
fieldsets = (
3940
(
@@ -64,6 +65,16 @@ class AwardAdmin(admin.ModelAdmin):
6465

6566
readonly_fields = ("nest_created_at", "nest_updated_at")
6667

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

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

Lines changed: 50 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -58,7 +58,11 @@ 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"))
@@ -75,6 +79,10 @@ def handle(self, *args, **options):
7579
# Print summary
7680
self._print_summary()
7781

82+
# Update badges after successful sync
83+
if not options["dry_run"]:
84+
self._update_badges()
85+
7886
except Exception as e:
7987
logger.exception("Error syncing awards")
8088
self.stdout.write(self.style.ERROR(f"Error syncing awards: {e!s}"))
@@ -90,6 +98,7 @@ def _process_award(self, award_data: dict, *, dry_run: bool = False):
9098
award_name = award_data.get("title", "")
9199
category = award_data.get("category", "")
92100
year = award_data.get("year")
101+
award_description = award_data.get("description", "") or ""
93102
winners = award_data.get("winners", [])
94103

95104
if not award_name or not category or not year:
@@ -106,6 +115,7 @@ def _process_award(self, award_data: dict, *, dry_run: bool = False):
106115
"name": winner_data.get("name", ""),
107116
"info": winner_data.get("info", ""),
108117
"image": winner_data.get("image", ""),
118+
"description": award_description,
109119
}
110120

111121
self._process_winner(winner_with_context, dry_run=dry_run)
@@ -138,6 +148,8 @@ def _process_winner(self, winner_data: dict, *, dry_run: bool = False):
138148
is_new = False
139149
except Award.DoesNotExist:
140150
is_new = True
151+
except Award.MultipleObjectsReturned:
152+
is_new = False
141153

142154
# Use the model's update_data method
143155
award = Award.update_data(winner_data, save=True)
@@ -239,15 +251,29 @@ def _extract_github_username(self, text: str) -> str | None:
239251
if not text:
240252
return None
241253

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

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

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

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

264290
# Convert to lowercase and replace spaces
265291
base_variations = [
266292
clean_name.lower().replace(" ", ""),
267293
clean_name.lower().replace(" ", "-"),
268-
clean_name.lower().replace(" ", "_"),
269294
]
270295

271296
# Add variations with different cases
272297
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-
)
298+
potential_logins.extend([variation, variation.replace("-", "")])
282299

283300
# Remove duplicates while preserving order
284301
seen = set()
285302
unique_logins = []
286303
for login in potential_logins:
304+
# Skip invalid characters for GitHub logins
305+
if "_" in login:
306+
continue
287307
if login and login not in seen and len(login) >= min_login_length:
288308
seen.add(login)
289309
unique_logins.append(login)
@@ -306,3 +326,15 @@ def _print_summary(self):
306326
self.stdout.write(f" - {name}")
307327

308328
self.stdout.write("\nSync completed successfully!")
329+
330+
def _update_badges(self):
331+
"""Update user badges based on synced awards."""
332+
from django.core.management import call_command
333+
334+
self.stdout.write("Updating user badges...")
335+
try:
336+
call_command("owasp_update_badges")
337+
self.stdout.write("Badge update completed successfully!")
338+
except Exception as e:
339+
logger.exception("Error updating badges")
340+
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: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
# Generated migration to add unique constraint on Award fields
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
dependencies = [
8+
("owasp", "0048_entitymember"),
9+
]
10+
11+
operations = [
12+
migrations.AddConstraint(
13+
model_name="award",
14+
constraint=models.UniqueConstraint(
15+
fields=["name", "category", "year", "winner_name"],
16+
name="owasp_award_unique_fields",
17+
),
18+
),
19+
]

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

0 commit comments

Comments
 (0)