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:
@@ -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))
|
||||||
|
|||||||
@@ -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 %}
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
Reference in New Issue
Block a user