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<N>" 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 <noreply@anthropic.com>
This commit is contained in:
2026-08-23 15:23:01 +02:00
parent bf62759bfc
commit 1375083739
3 changed files with 99 additions and 9 deletions

View File

@@ -6,6 +6,7 @@ mobile/coach_mixins.py's CoachScopeMixin for the shared scaffolding.
""" """
import datetime import datetime
import re
from django import forms from django import forms
from django.contrib.auth.mixins import LoginRequiredMixin 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 The pool is teams.services.eligible_roster_members(club) minus whoever's
already on this team+season -- the same two rules TeamMembershipForm already on this team+season -- the same two rules TeamMembershipForm
applies internally, just reused directly rather than through the form. applies internally, just reused directly rather than through the form.
"Suggested" (on this team last season) is real, computed data. "Age "Suggested" is two real, computed sources unioned together: whoever was
eligible" from the mock isn't built -- neither Club nor Team carries an on this team last season, plus whoever's on the closest younger team
age-group field to compare a birth date against, so faking that filter *this* season (see _feeder_team -- a guess from team naming, since
would just mean it silently matched nothing. A plain first/last-name neither Club nor Team carries a real age-group field to link them
search (?q=) narrows the pool further, ANDed with whichever filter chip properly). "Age eligible" from the mock still isn't built -- there's no
is active -- useful once a club's eligible-member pool outgrows a single birth-date cutoff to compare against, and faking that filter would just
screenful. 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" template_name = "mobile/coach/add_player.html"
@@ -709,11 +712,36 @@ class CoachAddPlayerView(CoachScopeMixin, LoginRequiredMixin, TemplateView):
#: param at all) means "All". #: param at all) means "All".
FILTERS = {"suggested", "no_team"} FILTERS = {"suggested", "no_team"}
#: Matches a "U<number>" 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): def get(self, request, *args, **kwargs):
if not self.can_manage_active_team: if not self.can_manage_active_team:
return HttpResponseRedirect(reverse("mobile:coach_today")) return HttpResponseRedirect(reverse("mobile:coach_today"))
return super().get(request, *args, **kwargs) 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<N>"."""
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): def _candidate_pool(self, season):
taken = TeamMembership.objects.filter(team=self.active_team, season=season).values_list("member_id", flat=True) 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) 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": if filter_param == "no_team":
pool = pool.exclude(team_memberships__season=season) pool = pool.exclude(team_memberships__season=season)
elif filter_param == "suggested": elif filter_param == "suggested":
suggested_ids = set()
previous_season = Season.before(self.request.club, season) 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: if search_query:
pool = pool.filter(Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query)) pool = pool.filter(Q(first_name__icontains=search_query) | Q(last_name__icontains=search_query))

View File

@@ -35,7 +35,7 @@
</div> </div>
{% if filter_param == "suggested" %} {% if filter_param == "suggested" %}
<p class="text-xs text-muted">{% trans "Was on this team's roster last season." %}</p> <p class="text-xs text-muted">{% trans "Was on this team's roster last season, or is on the age group below this season." %}</p>
{% elif filter_param == "no_team" %} {% elif filter_param == "no_team" %}
<p class="text-xs text-muted">{% trans "Not on any team's roster yet this season." %}</p> <p class="text-xs text-muted">{% trans "Not on any team's roster yet this season." %}</p>
{% endif %} {% endif %}

View File

@@ -4010,6 +4010,62 @@ class CoachAddPlayerViewTests(TestCase):
self.assertEqual(list(candidates), [returning]) self.assertEqual(list(candidates), [returning])
self.assertNotIn(new_signup, candidates) 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): def test_search_matches_first_or_last_name(self):
match = self.make_eligible_member(first_name="Zara", last_name="Zenith") match = self.make_eligible_member(first_name="Zara", last_name="Zenith")
other = self.make_eligible_member(first_name="Not", last_name="Matching") other = self.make_eligible_member(first_name="Not", last_name="Matching")