From 13750837398684f07df287cbd529853242e4d17c Mon Sep 17 00:00:00 2001 From: Bernard Siebens Date: Sun, 23 Aug 2026 15:23:01 +0200 Subject: [PATCH] Add players: "Suggested" also surfaces players moving up from a younger team Team carries no real age-group field, so this is a guess -- the club's own team with the closest smaller "U" number in its name/short name (if either side has one at all), current-season roster only. Silently adds nothing extra for a club that doesn't name teams that way, same as the existing "last season's roster" half of Suggested already does for a team with no prior season on file. Co-Authored-By: Claude Sonnet 5 --- mobile/coach_views.py | 50 ++++++++++++++--- mobile/templates/mobile/coach/add_player.html | 2 +- mobile/tests.py | 56 +++++++++++++++++++ 3 files changed, 99 insertions(+), 9 deletions(-) diff --git a/mobile/coach_views.py b/mobile/coach_views.py index 8ade591..b096347 100644 --- a/mobile/coach_views.py +++ b/mobile/coach_views.py @@ -6,6 +6,7 @@ mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding. """ import datetime +import re from django import forms from django.contrib.auth.mixins import LoginRequiredMixin @@ -692,13 +693,15 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView): The pool is teams.services.eligible_roster_members(club) minus whoever's already on this team+season -- the same two rules TeamMembershipForm applies internally, just reused directly rather than through the form. - "Suggested" (on this team last season) is real, computed data. "Age - eligible" from the mock isn't built -- neither Club nor Team carries an - age-group field to compare a birth date against, so faking that filter - would just mean it silently matched nothing. A plain first/last-name - search (?q=) narrows the pool further, ANDed with whichever filter chip - is active -- useful once a club's eligible-member pool outgrows a single - screenful. + "Suggested" is two real, computed sources unioned together: whoever was + on this team last season, plus whoever's on the closest younger team + *this* season (see _feeder_team -- a guess from team naming, since + neither Club nor Team carries a real age-group field to link them + properly). "Age eligible" from the mock still isn't built -- there's no + birth-date cutoff to compare against, and faking that filter would just + mean it silently matched nothing. A plain first/last-name search (?q=) + narrows the pool further, ANDed with whichever filter chip is active -- + useful once a club's eligible-member pool outgrows a single screenful. """ template_name = "mobile/coach/add_player.html" @@ -709,11 +712,36 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView): #: param at all) means "All". FILTERS = {"suggested", "no_team"} + #: Matches a "U" youth age-group marker in a team's name/short + #: name (e.g. "U14", "u16 boys") -- the only age-group signal available + #: anywhere on Team today. + AGE_GROUP_RE = re.compile(r"u(\d+)", re.IGNORECASE) + def get(self, request, *args, **kwargs): if not self.can_manage_active_team: return HttpResponseRedirect(reverse("mobile:coach_today")) return super().get(request, *args, **kwargs) + def _age_group(self, team): + match = self.AGE_GROUP_RE.search(team.name) or self.AGE_GROUP_RE.search(team.short_name) + return int(match.group(1)) if match else None + + def _feeder_team(self): + """The club's own team with the closest smaller age-group number + than the active team's, if either carries one -- a guess (see this + view's own docstring), so it silently returns None for a club that + doesn't name teams "U".""" + active_age = self._age_group(self.active_team) + if active_age is None: + return None + + feeder, feeder_age = None, None + for team in Team.objects.filter(club=self.request.club).exclude(pk=self.active_team.pk): + age = self._age_group(team) + if age is not None and age < active_age and (feeder_age is None or age > feeder_age): + feeder, feeder_age = team, age + return feeder + def _candidate_pool(self, season): taken = TeamMembership.objects.filter(team=self.active_team, season=season).values_list("member_id", flat=True) return eligible_roster_members(self.request.club).exclude(pk__in=taken) @@ -735,8 +763,14 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView): if filter_param == "no_team": pool = pool.exclude(team_memberships__season=season) elif filter_param == "suggested": + suggested_ids = set() previous_season = Season.before(self.request.club, season) - pool = pool.filter(team_memberships__team=self.active_team, team_memberships__season=previous_season) if previous_season is not None else pool.none() + if previous_season is not None: + suggested_ids.update(pool.filter(team_memberships__team=self.active_team, team_memberships__season=previous_season).values_list("pk", flat=True)) + feeder_team = self._feeder_team() + if feeder_team is not None: + suggested_ids.update(pool.filter(team_memberships__team=feeder_team, team_memberships__season=season).values_list("pk", flat=True)) + pool = pool.filter(pk__in=suggested_ids) if search_query: pool = pool.filter(Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query)) diff --git a/mobile/templates/mobile/coach/add_player.html b/mobile/templates/mobile/coach/add_player.html index b36b5e1..602e66e 100644 --- a/mobile/templates/mobile/coach/add_player.html +++ b/mobile/templates/mobile/coach/add_player.html @@ -35,7 +35,7 @@ {% if filter_param == "suggested" %} -

{% trans "Was on this team's roster last season." %}

+

{% trans "Was on this team's roster last season, or is on the age group below this season." %}

{% elif filter_param == "no_team" %}

{% trans "Not on any team's roster yet this season." %}

{% endif %} diff --git a/mobile/tests.py b/mobile/tests.py index d88416e..0c25c80 100644 --- a/mobile/tests.py +++ b/mobile/tests.py @@ -4010,6 +4010,62 @@ class CoachAddPlayerViewTests(TestCase): self.assertEqual(list(candidates), [returning]) self.assertNotIn(new_signup, candidates) + def test_suggested_filter_includes_players_from_the_closest_younger_team(self): + # self.team is "U16" (see setUpTestData) -- a "U14" sibling is the + # closest smaller age-group number, so its *current*-season roster + # counts as "coming up" candidates. + u14 = Team.objects.create(club=self.club, name="U14", short_name="U14") + coming_up = self.make_eligible_member(first_name="Coming", last_name="Up") + TeamMembership.objects.create(team=u14, member=coming_up, season=self.season) + not_a_candidate = self.make_eligible_member(first_name="Not", last_name="Candidate") + self.client.force_login(self.user) + + response = self.client.get(reverse("mobile:coach_add_player") + "?filter=suggested", HTTP_HOST="ajax-united.rosterchief.app") + + candidates = response.context["candidates"] + self.assertIn(coming_up, candidates) + self.assertNotIn(not_a_candidate, candidates) + + def test_suggested_filter_picks_the_closest_younger_team_not_any_smaller_one(self): + u14 = Team.objects.create(club=self.club, name="U14", short_name="U14") + u12 = Team.objects.create(club=self.club, name="U12", short_name="U12") + from_u14 = self.make_eligible_member(first_name="From", last_name="U14") + TeamMembership.objects.create(team=u14, member=from_u14, season=self.season) + from_u12 = self.make_eligible_member(first_name="From", last_name="U12") + TeamMembership.objects.create(team=u12, member=from_u12, season=self.season) + self.client.force_login(self.user) + + response = self.client.get(reverse("mobile:coach_add_player") + "?filter=suggested", HTTP_HOST="ajax-united.rosterchief.app") + + candidates = response.context["candidates"] + self.assertIn(from_u14, candidates) + self.assertNotIn(from_u12, candidates) + + def test_suggested_filter_matches_nothing_extra_without_a_u_number(self): + self.team.name = "First Team" + self.team.short_name = "1st" + self.team.save() + sibling = Team.objects.create(club=self.club, name="Reserves", short_name="Res") + member = self.make_eligible_member(first_name="Reserve", last_name="Player") + TeamMembership.objects.create(team=sibling, member=member, season=self.season) + self.client.force_login(self.user) + + response = self.client.get(reverse("mobile:coach_add_player") + "?filter=suggested", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertNotIn(member, response.context["candidates"]) + + def test_suggested_filter_does_not_duplicate_a_player_matching_both_sources(self): + previous_season = Season.objects.create(club=self.club, start_date=self.season.start_date - datetime.timedelta(days=365), end_date=self.season.start_date - datetime.timedelta(days=1)) + u14 = Team.objects.create(club=self.club, name="U14", short_name="U14") + both = self.make_eligible_member(first_name="Both", last_name="Sources") + TeamMembership.objects.create(team=self.team, member=both, season=previous_season) + TeamMembership.objects.create(team=u14, member=both, season=self.season) + self.client.force_login(self.user) + + response = self.client.get(reverse("mobile:coach_add_player") + "?filter=suggested", HTTP_HOST="ajax-united.rosterchief.app") + + self.assertEqual(list(response.context["candidates"]).count(both), 1) + def test_search_matches_first_or_last_name(self): match = self.make_eligible_member(first_name="Zara", last_name="Zenith") other = self.make_eligible_member(first_name="Not", last_name="Matching")