Skip to content

Commit c1ebcab

Browse files
committed
updated code
1 parent 24a5644 commit c1ebcab

File tree

4 files changed

+166
-110
lines changed

4 files changed

+166
-110
lines changed

backend/apps/github/management/commands/github_match_users.py

Lines changed: 15 additions & 12 deletions
Original file line numberDiff line numberDiff line change
@@ -20,8 +20,8 @@ def add_arguments(self, parser):
2020
parser.add_argument(
2121
"model_name",
2222
type=str,
23-
choices=("chapter", "committee", "project", "all"),
24-
help="Model to process: chapter, committee, project, or all.",
23+
choices=("chapter", "committee", "project"),
24+
help="Model to process: chapter, committee, project.",
2525
)
2626
parser.add_argument(
2727
"--threshold",
@@ -40,19 +40,22 @@ def handle(self, *_args, **kwargs):
4040
"project": Project,
4141
}
4242

43-
models_to_process = model_map.values() if model_name == "all" else [model_map[model_name]]
43+
if model_name not in model_map:
44+
self.stdout.write(
45+
self.style.ERROR("Invalid model name! Choose from: chapter, committee, project")
46+
)
47+
return
48+
49+
models_to_process = model_map[model_name]
4450

45-
self.stdout.write("Loading GitHub users into memory...")
4651
all_users = list(User.objects.values("id", "login", "name"))
47-
valid_users = [u for u in all_users if self._is_valid_user(u["login"], u["name"])]
48-
self.stdout.write(f"Found {len(valid_users)} valid users for matching.")
52+
valid_users = [u for u in all_users if self.is_valid_user(u["login"], u["name"])]
4953

50-
for model_class in models_to_process:
51-
self._process_entities(model_class, valid_users, threshold)
54+
self.process_entities(models_to_process, valid_users, threshold)
5255

5356
self.stdout.write(self.style.SUCCESS("\nCommand finished successfully."))
5457

55-
def _process_entities(self, model_class, users_list, threshold):
58+
def process_entities(self, model_class, users_list, threshold):
5659
"""Process entries."""
5760
model_label = model_class.__class__.__name__.capitalize()
5861
self.stdout.write(f"\n--- Processing {model_label} ---")
@@ -65,7 +68,7 @@ def _process_entities(self, model_class, users_list, threshold):
6568
if not entity.leaders_raw:
6669
continue
6770

68-
matched_users = self._find_user_matches(entity.leaders_raw, users_list, threshold)
71+
matched_users = self.find_user_matches(entity.leaders_raw, users_list, threshold)
6972

7073
if not matched_users:
7174
continue
@@ -100,11 +103,11 @@ def _process_entities(self, model_class, users_list, threshold):
100103
self.style.NOTICE(f" -> No new leader records to create for {model_label}.")
101104
)
102105

103-
def _is_valid_user(self, login, name):
106+
def is_valid_user(self, login, name):
104107
"""Check if GitHub user meets minimum requirements."""
105108
return len(login) >= ID_MIN_LENGTH and len(name or "") >= ID_MIN_LENGTH
106109

107-
def _find_user_matches(self, leaders_raw, users_list, threshold):
110+
def find_user_matches(self, leaders_raw, users_list, threshold):
108111
"""Find user matches for a list of raw leader names."""
109112
matched_users = []
110113

backend/apps/owasp/migrations/0047_remove_chapter_leaders_and_more.py

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
# Generated by Django 5.2.5 on 2025-08-12 21:01
1+
# Generated by Django 5.2.5 on 2025-08-13 06:33
22

33
import django.db.models.deletion
44
from django.db import migrations, models
@@ -89,7 +89,8 @@ class Migration(migrations.Migration):
8989
"indexes": [
9090
models.Index(
9191
fields=["content_type", "object_id"], name="owasp_entit_content_969a6f_idx"
92-
)
92+
),
93+
models.Index(fields=["member"], name="owasp_entit_member__6e516f_idx"),
9394
],
9495
"unique_together": {("content_type", "object_id", "member", "kind")},
9596
},

backend/apps/owasp/models/entity_member.py

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@
44
from django.contrib.contenttypes.models import ContentType
55
from django.db import models
66

7-
from apps.github.models import User
7+
from apps.github.models.user import User
88

99

1010
class EntityMember(models.Model):
@@ -18,6 +18,7 @@ class Meta:
1818
unique_together = ("content_type", "object_id", "member", "kind")
1919
indexes = [
2020
models.Index(fields=["content_type", "object_id"]),
21+
models.Index(fields=["member"]),
2122
]
2223
verbose_name_plural = "Entity Members"
2324

Lines changed: 146 additions & 95 deletions
Original file line numberDiff line numberDiff line change
@@ -1,107 +1,158 @@
1-
"""Tests for the github_match_users Django management command."""
2-
1+
import io
32
from unittest.mock import MagicMock, patch
43

54
import pytest
6-
from django.core.management.base import BaseCommand
5+
from django.core.management import call_command
6+
from django.core.management.base import CommandError
7+
from django.test import SimpleTestCase
8+
9+
COMMAND_PATH = "apps.github.management.commands.github_match_users"
10+
11+
12+
class MatchLeadersCommandMockTest(SimpleTestCase):
13+
"""Test suite for the github_match_users management command using mocks."""
14+
15+
@classmethod
16+
def setUpClass(cls):
17+
"""Start all necessary patchers once for the entire test class."""
18+
super().setUpClass()
19+
cls.user_patcher = patch(f"{COMMAND_PATH}.User")
20+
cls.chapter_patcher = patch(f"{COMMAND_PATH}.Chapter")
21+
cls.committee_patcher = patch(f"{COMMAND_PATH}.Committee")
22+
cls.project_patcher = patch(f"{COMMAND_PATH}.Project")
23+
cls.content_type_patcher = patch(f"{COMMAND_PATH}.ContentType")
24+
cls.entity_member_patcher = patch(f"{COMMAND_PATH}.EntityMember")
25+
26+
cls.mock_user = cls.user_patcher.start()
27+
cls.mock_chapter = cls.chapter_patcher.start()
28+
cls.mock_committee = cls.committee_patcher.start()
29+
cls.mock_project = cls.project_patcher.start()
30+
cls.mock_content_type = cls.content_type_patcher.start()
31+
cls.mock_entity_member = cls.entity_member_patcher.start()
32+
33+
@classmethod
34+
def tearDownClass(cls):
35+
"""Stop all patchers after all tests have run."""
36+
super().tearDownClass()
37+
patch.stopall()
38+
39+
def setUp(self):
40+
"""Configure the behavior of the mocks before each test."""
41+
for mock in [
42+
self.mock_user,
43+
self.mock_chapter,
44+
self.mock_committee,
45+
self.mock_project,
46+
self.mock_content_type,
47+
self.mock_entity_member,
48+
]:
49+
mock.reset_mock()
50+
51+
def entity_member_side_effect(*_, **kwargs):
52+
instance = MagicMock()
53+
instance.object_id = kwargs.get("object_id")
54+
instance.member_id = kwargs.get("member_id")
55+
return instance
56+
57+
self.mock_entity_member.side_effect = entity_member_side_effect
58+
59+
def bulk_create_side_effect(instances, *_, **__):
60+
return instances
61+
62+
self.mock_entity_member.objects.bulk_create.side_effect = bulk_create_side_effect
63+
64+
self.mock_users_data = [
65+
{"id": 1, "login": "john.doe", "name": "John Doe"},
66+
{"id": 2, "login": "jane.doe", "name": "Jane Doe"},
67+
{"id": 3, "login": "peter_jones", "name": "Peter Jones"},
68+
{"id": 4, "login": "samantha", "name": "Samantha Smith"},
69+
{"id": 5, "login": "a", "name": "B"},
70+
]
71+
self.mock_user.objects.values.return_value = self.mock_users_data
772

8-
from apps.github.management.commands.github_match_users import Command
73+
self.mock_chapter_1 = self._create_mock_entity(
74+
1, "Test Chapter 1", ["john.doe", "Unknown Person"]
75+
)
76+
self.mock_chapter_2 = self._create_mock_entity(2, "Test Chapter 2", ["Jane Doe"])
77+
self.mock_chapter_3 = self._create_mock_entity(
78+
3, "Test Chapter 3", ["peter_jones", "peter jones"]
79+
)
80+
self.mock_chapter_4 = self._create_mock_entity(4, "Fuzzy Chapter", ["Jone Doe"])
81+
self.mock_chapter.objects.all.return_value = [
82+
self.mock_chapter_1,
83+
self.mock_chapter_2,
84+
self.mock_chapter_3,
85+
self.mock_chapter_4,
86+
]
987

88+
self.mock_committee_1 = self._create_mock_entity(101, "Test Committee", ["john.doe"])
89+
self.mock_committee.objects.all.return_value = [self.mock_committee_1]
1090

11-
@pytest.fixture
12-
def command():
13-
"""Return a command instance with a mocked stdout."""
14-
cmd = Command()
15-
cmd.stdout = MagicMock()
16-
return cmd
91+
self.mock_content_type.objects.get_for_model.return_value = MagicMock()
1792

93+
def _create_mock_entity(self, pk, name, leaders_raw):
94+
"""Create a mock entity object."""
95+
mock_entity = MagicMock()
96+
mock_entity.pk = pk
97+
mock_entity.leaders_raw = leaders_raw
98+
mock_entity.__str__.return_value = name
99+
type(mock_entity).__name__ = "MagicMockModel"
100+
return mock_entity
18101

19-
class TestGithubMatchUsersCommand:
20-
"""Test suite for the command's setup and helper methods."""
102+
def test_command_with_invalid_model_name(self):
103+
"""Test that the command raises an error for an invalid model name."""
104+
with pytest.raises(CommandError):
105+
call_command("github_match_users", "invalid_model")
21106

22-
def test_command_help_text(self, command):
23-
"""Test that the command has the new, correct help text."""
24-
assert (
25-
command.help
26-
== "Matches entity leader names with GitHub Users and creates EntityMember records."
27-
)
107+
def test_exact_and_fuzzy_matches(self):
108+
"""Test exact and fuzzy matching for chapters."""
109+
out = io.StringIO()
110+
call_command("github_match_users", "chapter", stdout=out)
28111

29-
def test_command_inheritance(self, command):
30-
"""Test that the command inherits from BaseCommand."""
31-
assert isinstance(command, BaseCommand)
32-
33-
def test_add_arguments(self, command):
34-
"""Test that the command adds the correct arguments for the new version."""
35-
parser = MagicMock()
36-
command.add_arguments(parser)
37-
38-
assert parser.add_argument.call_count == 2
39-
parser.add_argument.assert_any_call(
40-
"model_name",
41-
type=str,
42-
choices=("chapter", "committee", "project", "all"),
43-
help="Model to process: chapter, committee, project, or all.",
44-
)
45-
parser.add_argument.assert_any_call(
46-
"--threshold",
47-
type=int,
48-
default=75,
49-
help="Threshold for fuzzy matching (0-100)",
50-
)
112+
mock_bulk_create = self.mock_entity_member.objects.bulk_create
113+
# FIX: Use plain assert
114+
assert mock_bulk_create.called
51115

52-
@pytest.mark.parametrize(
53-
("login", "name", "expected"),
54-
[
55-
("validlogin", "Valid Name", True),
56-
("ok", "Valid Name", True),
57-
("validlogin", "V", False),
58-
("v", "Valid Name", False),
59-
("v", "V", False),
60-
("", "", False),
61-
("validlogin", "", False),
62-
("", "Valid Name", False),
63-
("validlogin", None, False),
64-
],
65-
)
66-
def test_is_valid_user(self, command, login, name, expected):
67-
"""Test the _is_valid_user method."""
68-
with patch("apps.github.management.commands.github_match_users.ID_MIN_LENGTH", 2):
69-
assert command._is_valid_user(login, name) == expected
70-
71-
72-
class TestFindUserMatches:
73-
"""Test suite for the _find_user_matches helper method."""
74-
75-
@pytest.fixture
76-
def mock_users(self):
77-
"""Return a dictionary of mock users."""
78-
return [
79-
{"id": 1, "login": "john_doe", "name": "John Doe"},
80-
{"id": 2, "login": "jane_doe", "name": "Jane Doe"},
81-
{"id": 3, "login": "peter_jones", "name": "Peter Jones"},
82-
]
116+
call_args_list = mock_bulk_create.call_args[0][0]
117+
118+
assert len(call_args_list) == 4
119+
created_members = {(m.object_id, m.member_id) for m in call_args_list}
120+
121+
assert (1, 1) in created_members
122+
assert (3, 3) in created_members
123+
assert (4, 1) in created_members
124+
assert (2, 2) in created_members
125+
126+
def test_fuzzy_match_below_threshold(self):
127+
"""Test that a fuzzy match is not found when the score is below the threshold."""
128+
out = io.StringIO()
129+
call_command("github_match_users", "chapter", "--threshold=95", stdout=out)
130+
131+
mock_bulk_create = self.mock_entity_member.objects.bulk_create
132+
assert mock_bulk_create.called
133+
call_args_list = mock_bulk_create.call_args[0][0]
134+
135+
assert len(call_args_list) == 3
136+
created_members = {(m.object_id, m.member_id) for m in call_args_list}
137+
assert (4, 2) not in created_members
138+
139+
def test_is_valid_user_filtering(self):
140+
"""Test that users who do not meet the minimum length requirements are filtered out."""
141+
mock_invalid_chapter = self._create_mock_entity(99, "Invalid Chapter", ["a"])
142+
self.mock_chapter.objects.all.return_value = [mock_invalid_chapter]
143+
144+
out = io.StringIO()
145+
call_command("github_match_users", "chapter", stdout=out)
146+
147+
assert not self.mock_entity_member.objects.bulk_create.called
148+
assert "No new leader records to create" in out.getvalue()
149+
150+
@patch(f"{COMMAND_PATH}.fuzz")
151+
def test_exact_match_is_preferred_over_fuzzy(self, mock_fuzz):
152+
"""Test that if an exact match is found, fuzzy matching is not performed."""
153+
out = io.StringIO()
154+
call_command("github_match_users", "committee", stdout=out)
155+
156+
assert not mock_fuzz.token_sort_ratio.called
83157

84-
def test_exact_match(self, command, mock_users):
85-
"""Test exact matching by login and name."""
86-
leaders_raw = ["john_doe", "Jane Doe"]
87-
matches = command._find_user_matches(leaders_raw, mock_users, 90)
88-
89-
assert len(matches) == 2
90-
assert any(u["id"] == 1 for u in matches)
91-
assert any(u["id"] == 2 for u in matches)
92-
93-
@patch("apps.github.management.commands.github_match_users.fuzz")
94-
def test_fuzzy_match(self, mock_fuzz, command, mock_users):
95-
"""Test fuzzy matching."""
96-
mock_fuzz.token_sort_ratio.side_effect = lambda _, s2: 90 if "peter" in s2.lower() else 10
97-
leaders_raw = ["pete_jones"]
98-
matches = command._find_user_matches(leaders_raw, mock_users, 80)
99-
100-
assert len(matches) == 1
101-
assert matches[0]["id"] == 3
102-
103-
def test_unmatched_leader(self, command, mock_users):
104-
"""Test that an unknown leader returns no matches."""
105-
leaders_raw = ["unknown_leader"]
106-
matches = command._find_user_matches(leaders_raw, mock_users, 100)
107-
assert matches == []
158+
assert "Created 1 new leader records" in out.getvalue()

0 commit comments

Comments
 (0)