Skip to content

Commit 1136677

Browse files
authored
Let team leads pull in applicants who picked no team (#403)
Volunteers who applied without choosing a team were invisible to leads (no team roster lists them). Surface them on each team dashboard as "Applicants without a team", and let a lead (or admin) add one to their team with a button. - TeamDashboardView exposes unassigned_applicants (pending, no team, same edition). - AddApplicantToTeamView (scoped by TeamLeadRequiredMixin) attaches a genuinely unassigned, pending, same-edition applicant to the team's pending roster; the normal approval still applies. 575 tests pass, 100% coverage; lint clean.
1 parent 6077315 commit 1136677

4 files changed

Lines changed: 247 additions & 0 deletions

File tree

portal/urls.py

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -82,6 +82,11 @@
8282
volunteer_view.TeamDashboardView.as_view(),
8383
name="team_dashboard",
8484
),
85+
path(
86+
"teams/<int:pk>/applicants/<int:profile_pk>/add/",
87+
volunteer_view.AddApplicantToTeamView.as_view(),
88+
name="team_add_applicant",
89+
),
8590
path(
8691
"teams/<int:pk>/edit/",
8792
volunteer_view.TeamUpdate.as_view(),

templates/team/team_dashboard.html

Lines changed: 57 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,63 @@ <h3 class="h5 mt-4" id="approved">
196196
</p>
197197
{% endif %}
198198
</div>
199+
{# Applicants who applied without a team: leads can pull one onto their #}
200+
{# team. Outside the roster filter (they are not team members yet). #}
201+
{% if can_manage_members and unassigned_applicants %}
202+
<div class="mt-5">
203+
<h3 class="h5">
204+
<i class="fa-solid fa-user-plus text-primary"></i>
205+
{% trans "Applicants without a team" %}
206+
<span class="badge bg-primary rounded-pill">{{ unassigned_applicants.count }}</span>
207+
</h3>
208+
<p class="small text-secondary">
209+
{% trans "These volunteers applied but no team has picked them up yet. Add anyone who'd be a fit for yours." %}
210+
</p>
211+
<div class="table-responsive">
212+
<table class="table table-hover table-sm align-middle">
213+
<thead class="table-light">
214+
<tr>
215+
<th>
216+
{% trans "Volunteer" %}
217+
</th>
218+
<th>
219+
{% trans "Region" %}
220+
</th>
221+
<th>
222+
{% trans "Discord" %}
223+
</th>
224+
<th>
225+
</th>
226+
</tr>
227+
</thead>
228+
<tbody>
229+
{% for applicant in unassigned_applicants %}
230+
<tr>
231+
<td>
232+
{{ applicant.user.get_full_name|default:applicant.user.username }}
233+
</td>
234+
<td>
235+
{{ applicant.region|default:"-" }}
236+
</td>
237+
<td>
238+
{{ applicant.discord_username|default:"-" }}
239+
</td>
240+
<td>
241+
<form method="post"
242+
action="{% url 'team_add_applicant' team.id applicant.id %}">
243+
{% csrf_token %}
244+
<button type="submit" class="btn btn-sm btn-outline-primary">
245+
<i class="fa-solid fa-plus"></i> {% trans "Add to team" %}
246+
</button>
247+
</form>
248+
</td>
249+
</tr>
250+
{% endfor %}
251+
</tbody>
252+
</table>
253+
</div>
254+
</div>
255+
{% endif %}
199256
<script>
200257
(function () {
201258
var buttons = document.querySelectorAll(".js-roster-filter");

tests/volunteer/test_views.py

Lines changed: 142 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1669,6 +1669,148 @@ def test_lead_dashboard_rail_lists_only_their_teams(
16691669
assert "OtherTeam" not in response.content.decode()
16701670

16711671

1672+
@pytest.mark.django_db
1673+
class TestUnassignedApplicants:
1674+
"""Leads can see, and pull in, applicants who picked no team."""
1675+
1676+
def _lead_team(self, portal_user, conference):
1677+
team = Team.objects.create(
1678+
short_name="Comms", description="d", conference=conference
1679+
)
1680+
lead = VolunteerProfile.objects.create(
1681+
user=portal_user,
1682+
conference=conference,
1683+
application_status=ApplicationStatus.APPROVED,
1684+
)
1685+
team.team_leads.add(lead)
1686+
return team
1687+
1688+
def _applicant(self, django_user_model, username, conference, **kwargs):
1689+
return VolunteerProfile.objects.create(
1690+
user=django_user_model.objects.create_user(username),
1691+
conference=conference,
1692+
application_status=kwargs.get("status", ApplicationStatus.PENDING),
1693+
)
1694+
1695+
def test_lead_sees_unassigned_applicants(
1696+
self, client, portal_user, conference, django_user_model
1697+
):
1698+
team = self._lead_team(portal_user, conference)
1699+
applicant = self._applicant(django_user_model, "app", conference)
1700+
client.force_login(portal_user)
1701+
response = client.get(reverse("team_dashboard", kwargs={"pk": team.pk}))
1702+
assert applicant in list(response.context["unassigned_applicants"])
1703+
content = response.content.decode()
1704+
assert "Applicants without a team" in content
1705+
assert (
1706+
reverse(
1707+
"team_add_applicant",
1708+
kwargs={"pk": team.pk, "profile_pk": applicant.pk},
1709+
)
1710+
in content
1711+
)
1712+
1713+
def test_assigned_and_nonpending_are_excluded(
1714+
self, client, portal_user, conference, django_user_model
1715+
):
1716+
team = self._lead_team(portal_user, conference)
1717+
other = Team.objects.create(
1718+
short_name="Other", description="d", conference=conference
1719+
)
1720+
assigned = self._applicant(django_user_model, "assigned", conference)
1721+
assigned.teams.add(other)
1722+
approved = self._applicant(
1723+
django_user_model,
1724+
"appr",
1725+
conference,
1726+
status=ApplicationStatus.APPROVED,
1727+
)
1728+
client.force_login(portal_user)
1729+
unassigned = list(
1730+
client.get(reverse("team_dashboard", kwargs={"pk": team.pk})).context[
1731+
"unassigned_applicants"
1732+
]
1733+
)
1734+
assert assigned not in unassigned # already on a team
1735+
assert approved not in unassigned # not pending
1736+
1737+
def test_lead_can_add_applicant_to_their_team(
1738+
self, client, portal_user, conference, django_user_model
1739+
):
1740+
team = self._lead_team(portal_user, conference)
1741+
applicant = self._applicant(django_user_model, "app", conference)
1742+
client.force_login(portal_user)
1743+
response = client.post(
1744+
reverse(
1745+
"team_add_applicant",
1746+
kwargs={"pk": team.pk, "profile_pk": applicant.pk},
1747+
)
1748+
)
1749+
assertRedirects(response, reverse("team_dashboard", kwargs={"pk": team.pk}))
1750+
assert team in applicant.teams.all()
1751+
# ...and now shows in the team's pending roster.
1752+
assert applicant in Team.objects.get(pk=team.pk).pending_members
1753+
1754+
def test_non_lead_cannot_add(
1755+
self, client, portal_user, conference, django_user_model
1756+
):
1757+
team = Team.objects.create(
1758+
short_name="Comms", description="d", conference=conference
1759+
)
1760+
applicant = self._applicant(django_user_model, "app", conference)
1761+
client.force_login(portal_user) # not a lead of this team
1762+
response = client.post(
1763+
reverse(
1764+
"team_add_applicant",
1765+
kwargs={"pk": team.pk, "profile_pk": applicant.pk},
1766+
)
1767+
)
1768+
assert response.status_code in (302, 403)
1769+
assert applicant.teams.count() == 0
1770+
1771+
def test_add_rejects_already_assigned_applicant(
1772+
self, client, admin_user, conference, django_user_model
1773+
):
1774+
team = Team.objects.create(
1775+
short_name="Comms", description="d", conference=conference
1776+
)
1777+
other = Team.objects.create(
1778+
short_name="Other", description="d", conference=conference
1779+
)
1780+
applicant = self._applicant(django_user_model, "app", conference)
1781+
applicant.teams.add(other)
1782+
client.force_login(admin_user)
1783+
client.post(
1784+
reverse(
1785+
"team_add_applicant",
1786+
kwargs={"pk": team.pk, "profile_pk": applicant.pk},
1787+
)
1788+
)
1789+
assert list(applicant.teams.all()) == [other] # unchanged
1790+
1791+
def test_add_missing_applicant_warns(self, client, admin_user, conference):
1792+
team = Team.objects.create(
1793+
short_name="Comms", description="d", conference=conference
1794+
)
1795+
client.force_login(admin_user)
1796+
response = client.post(
1797+
reverse("team_add_applicant", kwargs={"pk": team.pk, "profile_pk": 99999})
1798+
)
1799+
assertRedirects(response, reverse("team_dashboard", kwargs={"pk": team.pk}))
1800+
1801+
def test_add_missing_team_redirects(
1802+
self, client, admin_user, conference, django_user_model
1803+
):
1804+
applicant = self._applicant(django_user_model, "app", conference)
1805+
client.force_login(admin_user)
1806+
response = client.post(
1807+
reverse(
1808+
"team_add_applicant", kwargs={"pk": 99999, "profile_pk": applicant.pk}
1809+
)
1810+
)
1811+
assertRedirects(response, reverse("teams"))
1812+
1813+
16721814
@pytest.mark.django_db
16731815
class TestMyTeams:
16741816
def _lead(self, user, conference):

volunteer/views.py

Lines changed: 43 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -485,13 +485,56 @@ def get_context_data(self, **kwargs):
485485
is_admin or team.team_leads.filter(user=user).exists()
486486
)
487487

488+
# Applicants who applied to this edition without picking a team. A lead
489+
# can pull one onto their team; no other roster surfaces them.
490+
context["unassigned_applicants"] = (
491+
VolunteerProfile.objects.filter(
492+
conference=team.conference,
493+
application_status=ApplicationStatus.PENDING,
494+
teams__isnull=True,
495+
)
496+
.select_related("user")
497+
.order_by("user__username")
498+
)
499+
488500
# Teams rail (Stage B): sibling teams in this edition the user may open,
489501
# with the current team highlighted.
490502
context["sidebar_teams"] = sidebar_teams_for(user, team.conference)
491503
context["sidebar_current_team_id"] = team.id
492504
return context
493505

494506

507+
class AddApplicantToTeamView(TeamLeadRequiredMixin, View):
508+
"""Let a lead (or admin) pull an unassigned applicant onto their team.
509+
510+
Scoped by ``TeamLeadRequiredMixin`` to the team in the URL. Only a genuinely
511+
unassigned, pending applicant from the same edition can be added; they join
512+
the team's pending roster and still go through the normal approval.
513+
"""
514+
515+
def post(self, request, pk, profile_pk):
516+
team = Team.objects.filter(pk=pk).first()
517+
if team is None:
518+
return redirect("teams")
519+
520+
applicant = VolunteerProfile.objects.filter(pk=profile_pk).first()
521+
if (
522+
applicant is None
523+
or applicant.conference_id != team.conference_id
524+
or applicant.application_status != ApplicationStatus.PENDING
525+
or applicant.teams.exists()
526+
):
527+
messages.warning(
528+
request, "That volunteer is no longer available to add to this team."
529+
)
530+
return redirect("team_dashboard", pk=team.pk)
531+
532+
applicant.teams.add(team)
533+
name = applicant.user.get_full_name() or applicant.user.username
534+
messages.success(request, f"Added {name} to {team.short_name}.")
535+
return redirect("team_dashboard", pk=team.pk)
536+
537+
495538
class MyTeamsView(LoginRequiredMixin, ListView):
496539
"""Teams the current user leads, across every edition.
497540

0 commit comments

Comments
 (0)