team shifts: implement marking shifts for sale

A common occurence is that not enough people volunteer for shifts at
first, and then the few volunteers take all the shifts (too many). Then
late comers aren't able to take any shifts.

This commit implements a third state for shift assignment: for sale. A
shift marked for sale means the team member is able to take the shift if
need be, but would rather someone else takes the shift. Another team
member will be able to take shifts at the same time even if the required
number of people are met.

The current semantics is for-sale shifts are replaced eagerly. Another
possible implementation would be to only replace for-sale shifts if the
number of required people is met.
This commit is contained in:
Reynir Björnsson 2021-08-24 18:07:58 +02:00
parent b3f127f197
commit 005431d6f6
5 changed files with 55 additions and 4 deletions

View file

@ -401,7 +401,7 @@ class TeamShift(CampRelatedModel):
shift_range = DateTimeRangeField() shift_range = DateTimeRangeField()
team_members = models.ManyToManyField(TeamMember, blank=True) team_members = models.ManyToManyField(TeamMember, blank=True, through=TeamShiftAssignment)
people_required = models.IntegerField(default=1) people_required = models.IntegerField(default=1)
@ -420,3 +420,21 @@ class TeamShift(CampRelatedModel):
@property @property
def users(self): def users(self):
return [member.user for member in self.team_members.all()] return [member.user for member in self.team_members.all()]
class TeamShiftAssignment(CampRelatedModel):
team_shift = models.ForeignKey(
"teams.TeamShift",
on_delete=models.CASCADE,
help_text="The shift",
)
team_member = models.ForeignKey(
"teams.TeamMember",
on_delete=models.CASCADE,
help_text="The team member on shift",
)
for_sale = models.BooleanField(
default=False,
help_text="Is the shift assignment for sale?",
)

View file

@ -50,7 +50,7 @@ Shifts | {{ block.super }}
{{ shift.people_required }} {{ shift.people_required }}
<td> <td>
{% for member in shift.team_members.all %} {% for member in shift.team_members.all %}
{{ member.user.profile.get_public_credit_name }}{% if not forloop.last %},{% endif %} {{ member.user.profile.get_public_credit_name }}{% if member.for_sale %} <em>(for sale!)</em>{% endif %}{% if not forloop.last %},{% endif %}
{% empty %} {% empty %}
None! None!
{% endfor %} {% endfor %}
@ -71,7 +71,11 @@ Shifts | {{ block.super }}
href="{% url 'teams:shift_member_drop' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}"> href="{% url 'teams:shift_member_drop' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}">
<i class="fas fa-thumbs-down"></i> Unassign me <i class="fas fa-thumbs-down"></i> Unassign me
</a> </a>
{% elif shift.people_required > shift.team_members.count %} <a class="btn btn-warning"
href="{% url 'teams:shift_member_sell' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}">
<i class="fas fa-thumbs-down"></i> Sell shift
</a>
{% elif shift.people_required > shift.team_members.filter(for_sale=False).count %}
<a class="btn btn-success" <a class="btn btn-success"
href="{% url 'teams:shift_member_take' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}"> href="{% url 'teams:shift_member_take' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}">
<i class="fas fa-thumbs-up"></i> Assign me <i class="fas fa-thumbs-up"></i> Assign me

View file

@ -34,6 +34,10 @@ Your shifts | {{ block.super }}
<td> <td>
{{ shift.shift_range.upper|date:'H:i' }} {{ shift.shift_range.upper|date:'H:i' }}
</td> </td>
<td>
<a class="btn btn-warning"
href="{% url 'teams:shift_member_sell' camp_slug=camp.slug team_slug=shift.team.slug pk=shift.pk %}">
<i class="fas fa-thumbs-down"></i> Sell shift
<td> <td>
<a class="btn btn-danger" <a class="btn btn-danger"
href="{% url 'teams:shift_member_drop' camp_slug=camp.slug team_slug=shift.team.slug pk=shift.pk %}"> href="{% url 'teams:shift_member_drop' camp_slug=camp.slug team_slug=shift.team.slug pk=shift.pk %}">
@ -45,4 +49,4 @@ Your shifts | {{ block.super }}
</tbody> </tbody>
</table> </table>
{% endblock %} {% endblock %}

View file

@ -179,6 +179,11 @@ urlpatterns = [
MemberDropsShift.as_view(), MemberDropsShift.as_view(),
name="shift_member_drop", name="shift_member_drop",
), ),
path(
"sell",
MemberSellsShift.as_view(),
name="shift_member_sell",
),
] ]
), ),
), ),

View file

@ -274,6 +274,9 @@ class MemberTakesShift(LoginRequiredMixin, CampViewMixin, View):
request, template.render(Context({"shifts": overlapping_shifts})) request, template.render(Context({"shifts": overlapping_shifts}))
) )
else: else:
# Remove at most one shift assignment for sale
for shift_assignment in shift.team_members.filter(for_sale=True)[:1]:
shift_assignment.delete()
shift.team_members.add(team_member) shift.team_members.add(team_member)
kwargs.pop("pk") kwargs.pop("pk")
@ -297,6 +300,23 @@ class MemberDropsShift(LoginRequiredMixin, CampViewMixin, View):
return HttpResponseRedirect(reverse("teams:shifts", kwargs=kwargs)) return HttpResponseRedirect(reverse("teams:shifts", kwargs=kwargs))
class MemberSellsShift(LoginRequiredMixin, CampViewMixin, View):
http_methods = ["get"]
def get(self, request, **kwargs):
shift = TeamShift.objects.get(id=kwargs["pk"])
team = Team.objects.get(camp=self.camp, slug=kwargs["team_slug"])
team_member = TeamMember.objects.get(team=team, user=request.user)
shift_assignment = shift.team_members.get(team_member = team_member.pk)
shift_assignment.for_sale = True
shift_assignment.save()
kwargs.pop("pk")
return HttpResponseRedirect(reverse("teams:shifts", kwargs=kwargs))
class UserShifts(CampViewMixin, TemplateView): class UserShifts(CampViewMixin, TemplateView):
template_name = "team_user_shifts.html" template_name = "team_user_shifts.html"