Skip to content

Commit e047e2f

Browse files
committed
implemented matching leader model
1 parent 0c8088e commit e047e2f

File tree

6 files changed

+335
-38
lines changed

6 files changed

+335
-38
lines changed
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
"""A command to perform fuzzy and exact matching of leaders with GitHub users models."""
2+
3+
from django.core.management.base import BaseCommand
4+
from django.db import models
5+
from django.db.utils import DatabaseError
6+
from thefuzz import fuzz
7+
8+
from apps.github.models.user import User
9+
from apps.owasp.models.chapter import Chapter
10+
from apps.owasp.models.committee import Committee
11+
from apps.owasp.models.project import Project
12+
13+
MIN_NO_OF_WORDS = 2
14+
15+
16+
class Command(BaseCommand):
17+
help = "Process raw leaders for multiple models and suggest leaders."
18+
19+
def add_arguments(self, parser):
20+
parser.add_argument(
21+
"model_name",
22+
type=str,
23+
help="Model name to process leaders for (e.g., Chapter, Committee, Project)",
24+
)
25+
parser.add_argument(
26+
"--threshold", type=int, default=95, help="Threshold for fuzzy matching"
27+
)
28+
29+
def handle(self, *args, **kwargs):
30+
model_name = kwargs["model_name"]
31+
threshold = kwargs["threshold"]
32+
33+
model_map = {
34+
"chapter": Chapter,
35+
"committee": Committee,
36+
"project": Project,
37+
}
38+
39+
model_class = model_map.get(model_name.lower())
40+
41+
if not model_class:
42+
self.stdout.write(
43+
self.style.ERROR("Invalid model name! Choose from: chapter, committee, project")
44+
)
45+
return
46+
47+
all_users = User.objects.all()
48+
filtered_users = [
49+
u
50+
for u in all_users
51+
if len(u.login) >= MIN_NO_OF_WORDS and (u.name and len(u.name) >= MIN_NO_OF_WORDS)
52+
]
53+
54+
instances = model_class.objects.all()
55+
for instance in instances:
56+
self.stdout.write(f"Processing leaders for {model_name.capitalize()} {instance.id}...")
57+
exact_matches, fuzzy_matches, unmatched_leaders = self.process_leaders(
58+
instance.leaders_raw, threshold, filtered_users
59+
)
60+
instance.suggested_leaders.set(list(set(exact_matches + fuzzy_matches)))
61+
instance.save()
62+
63+
if unmatched_leaders:
64+
self.stdout.write(f"Unmatched leaders for {instance.name}: {unmatched_leaders}")
65+
66+
def process_leaders(self, leaders_raw, threshold, filtered_users):
67+
"""Process leaders and return the suggested leaders with exact and fuzzy matching."""
68+
if not leaders_raw:
69+
return [], [], []
70+
71+
exact_matches = []
72+
fuzzy_matches = []
73+
unmatched_leaders = []
74+
75+
for leader in leaders_raw:
76+
try:
77+
leaderdata = User.objects.filter(
78+
models.Q(login__iexact=leader) | models.Q(name__iexact=leader)
79+
).first()
80+
if leaderdata:
81+
exact_matches.append(leaderdata)
82+
self.stdout.write(f"Exact match found for {leader}: {leaderdata}")
83+
continue
84+
85+
matches = [
86+
u
87+
for u in filtered_users
88+
if (fuzz.partial_ratio(leader, u.login) >= threshold)
89+
or (fuzz.partial_ratio(leader, u.name if u.name else "") >= threshold)
90+
]
91+
92+
new_fuzzy_matches = [m for m in matches if m not in exact_matches]
93+
fuzzy_matches.extend(new_fuzzy_matches)
94+
95+
if matches:
96+
for match in new_fuzzy_matches:
97+
self.stdout.write(f"Fuzzy match found for {leader}: {match}")
98+
else:
99+
unmatched_leaders.append(leader)
100+
101+
except DatabaseError as e:
102+
unmatched_leaders.append(leader)
103+
self.stdout.write(self.style.ERROR(f"Error processing leader {leader}: {e}"))
104+
105+
return exact_matches, fuzzy_matches, unmatched_leaders

backend/apps/owasp/admin.py

+27-7
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
"""OWASP app admin."""
22

3-
from django.contrib import admin
3+
from django.contrib import admin, messages
44
from django.utils.safestring import mark_safe
55

66
from apps.owasp.models.chapter import Chapter
@@ -28,12 +28,31 @@ def custom_field_owasp_url(self, obj):
2828
f"<a href='https://owasp.org/{obj.key}' target='_blank'>↗️</a>"
2929
)
3030

31+
def approve_suggested_leaders(self, request, queryset):
32+
"""Approve all suggested leaders for selected entities."""
33+
for entity in queryset:
34+
suggestions = entity.suggested_leaders.all()
35+
entity.leaders.add(*suggestions)
36+
self.message_user(
37+
request,
38+
f"Approved {suggestions.count()} leader suggestions for {entity.name}",
39+
messages.SUCCESS,
40+
)
41+
3142
custom_field_github_urls.short_description = "GitHub 🔗"
3243
custom_field_owasp_url.short_description = "OWASP 🔗"
44+
approve_suggested_leaders.short_description = "Approve all suggested leaders"
3345

3446

35-
class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin):
36-
autocomplete_fields = ("owasp_repository",)
47+
class LeaderEntityAdmin(admin.ModelAdmin, GenericEntityAdminMixin):
48+
"""Admin class for entities that have leaders."""
49+
50+
actions = ["approve_suggested_leaders"]
51+
filter_horizontal = ("suggested_leaders",)
52+
53+
54+
class ChapterAdmin(LeaderEntityAdmin):
55+
autocomplete_fields = ("owasp_repository", "leaders")
3756
list_display = (
3857
"name",
3958
"region",
@@ -48,23 +67,24 @@ class ChapterAdmin(admin.ModelAdmin, GenericEntityAdminMixin):
4867
search_fields = ("name", "key")
4968

5069

51-
class CommitteeAdmin(admin.ModelAdmin):
52-
autocomplete_fields = ("owasp_repository",)
70+
class CommitteeAdmin(LeaderEntityAdmin):
71+
autocomplete_fields = ("owasp_repository", "leaders")
5372
search_fields = ("name",)
5473

5574

56-
class EventAdmin(admin.ModelAdmin):
75+
class EventAdmin(admin.ModelAdmin, GenericEntityAdminMixin):
5776
autocomplete_fields = ("owasp_repository",)
5877
list_display = ("name",)
5978
search_fields = ("name",)
6079

6180

62-
class ProjectAdmin(admin.ModelAdmin, GenericEntityAdminMixin):
81+
class ProjectAdmin(LeaderEntityAdmin):
6382
autocomplete_fields = (
6483
"organizations",
6584
"owasp_repository",
6685
"owners",
6786
"repositories",
87+
"leaders",
6888
)
6989
list_display = (
7090
"custom_field_name",
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
# Generated by Django 5.1.6 on 2025-02-22 21:43
2+
3+
from django.db import migrations, models
4+
5+
6+
class Migration(migrations.Migration):
7+
8+
dependencies = [
9+
('github', '0015_alter_release_author'),
10+
('owasp', '0014_project_custom_tags'),
11+
]
12+
13+
operations = [
14+
migrations.AddField(
15+
model_name='chapter',
16+
name='leaders',
17+
field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'),
18+
),
19+
migrations.AddField(
20+
model_name='chapter',
21+
name='suggested_leaders',
22+
field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'),
23+
),
24+
migrations.AddField(
25+
model_name='committee',
26+
name='leaders',
27+
field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'),
28+
),
29+
migrations.AddField(
30+
model_name='committee',
31+
name='suggested_leaders',
32+
field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'),
33+
),
34+
migrations.AddField(
35+
model_name='project',
36+
name='leaders',
37+
field=models.ManyToManyField(blank=True, related_name='normal_%(class)s', to='github.user', verbose_name='Leaders'),
38+
),
39+
migrations.AddField(
40+
model_name='project',
41+
name='suggested_leaders',
42+
field=models.ManyToManyField(blank=True, related_name='exact_matched_%(class)s', to='github.user', verbose_name='Exact Match Users'),
43+
),
44+
]

backend/apps/owasp/models/common.py

+11
Original file line numberDiff line numberDiff line change
@@ -164,6 +164,17 @@ class Meta:
164164
verbose_name="Entity invalid related URLs", default=list, blank=True, null=True
165165
)
166166

167+
# M2M
168+
suggested_leaders = models.ManyToManyField(
169+
"github.User",
170+
verbose_name="Exact Match Users",
171+
related_name="exact_matched_%(class)s",
172+
blank=True,
173+
)
174+
leaders = models.ManyToManyField(
175+
"github.User", verbose_name="Leaders", related_name="normal_%(class)s", blank=True
176+
)
177+
167178
def get_related_url(self, url, exclude_domains=(), include_domains=()):
168179
"""Get OWASP entity related URL."""
169180
if (

0 commit comments

Comments
 (0)