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 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<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):
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<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):
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))

View File

@@ -35,7 +35,7 @@
</div>
{% 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" %}
<p class="text-xs text-muted">{% trans "Not on any team's roster yet this season." %}</p>
{% endif %}

View File

@@ -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")