diff --git a/src/teams/migrations/0064_teamshiftassignment_teamshift_team_members_new.py b/src/teams/migrations/0064_teamshiftassignment_teamshift_team_members_new.py new file mode 100644 index 000000000..333c2977d --- /dev/null +++ b/src/teams/migrations/0064_teamshiftassignment_teamshift_team_members_new.py @@ -0,0 +1,33 @@ +# Generated by Django 4.2.21 on 2025-06-09 15:29 + +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0063_alter_team_member_group'), + ] + + operations = [ + migrations.CreateModel( + name='TeamShiftAssignment', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('for_sale', models.BooleanField(default=False, help_text='Is the shift assignment for sale?')), + ('team_member', models.ForeignKey(help_text='The team member on shift', on_delete=django.db.models.deletion.CASCADE, to='teams.teammember')), + ('team_shift', models.ForeignKey(help_text='The shift', on_delete=django.db.models.deletion.CASCADE, to='teams.teamshift')), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='teamshift', + name='team_members_new', + field=models.ManyToManyField(blank=True, related_name='team_members_new', through='teams.TeamShiftAssignment', to='teams.teammember'), + ), + ] diff --git a/src/teams/migrations/0065_teamshiftassignment_migrate_data.py b/src/teams/migrations/0065_teamshiftassignment_migrate_data.py new file mode 100644 index 000000000..19c42b47a --- /dev/null +++ b/src/teams/migrations/0065_teamshiftassignment_migrate_data.py @@ -0,0 +1,19 @@ +from django.db import migrations, models +import django.db.models.deletion + +def migrate_assignments(apps, schema_editor): + TeamShift = apps.get_model("teams", "TeamShift") + for teamshift in TeamShift.objects.all(): + members = teamshift.team_members.all() + teamshift.team_members_new.set(members) + teamshift.save() + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0064_teamshiftassignment_teamshift_team_members_new'), + ] + + operations = [ + migrations.RunPython(migrate_assignments), + ] diff --git a/src/teams/migrations/0066_remove_teamshift_team_members.py b/src/teams/migrations/0066_remove_teamshift_team_members.py new file mode 100644 index 000000000..cc06a03fe --- /dev/null +++ b/src/teams/migrations/0066_remove_teamshift_team_members.py @@ -0,0 +1,17 @@ +# Generated by Django 4.2.21 on 2025-06-09 15:37 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0065_teamshiftassignment_migrate_data'), + ] + + operations = [ + migrations.RemoveField( + model_name='teamshift', + name='team_members', + ), + ] diff --git a/src/teams/migrations/0067_rename_team_members_new_teamshift_team_members.py b/src/teams/migrations/0067_rename_team_members_new_teamshift_team_members.py new file mode 100644 index 000000000..0dda9d3d8 --- /dev/null +++ b/src/teams/migrations/0067_rename_team_members_new_teamshift_team_members.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-06-09 15:38 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0066_remove_teamshift_team_members'), + ] + + operations = [ + migrations.RenameField( + model_name='teamshift', + old_name='team_members_new', + new_name='team_members', + ), + ] diff --git a/src/teams/migrations/0068_alter_teamshift_team_members.py b/src/teams/migrations/0068_alter_teamshift_team_members.py new file mode 100644 index 000000000..5a7973ea2 --- /dev/null +++ b/src/teams/migrations/0068_alter_teamshift_team_members.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-06-09 15:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0067_rename_team_members_new_teamshift_team_members'), + ] + + operations = [ + migrations.AlterField( + model_name='teamshift', + name='team_members', + field=models.ManyToManyField(blank=True, through='teams.TeamShiftAssignment', to='teams.teammember'), + ), + ] diff --git a/src/teams/migrations/0069_teamshiftassignment_updated_at.py b/src/teams/migrations/0069_teamshiftassignment_updated_at.py new file mode 100644 index 000000000..7f0353772 --- /dev/null +++ b/src/teams/migrations/0069_teamshiftassignment_updated_at.py @@ -0,0 +1,18 @@ +# Generated by Django 4.2.21 on 2025-06-22 17:38 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0068_alter_teamshift_team_members'), + ] + + operations = [ + migrations.AddField( + model_name='teamshiftassignment', + name='updated_at', + field=models.DateTimeField(auto_now=True), + ), + ] diff --git a/src/teams/models.py b/src/teams/models.py index 2d55e3205..d5c10343f 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -1,4 +1,5 @@ """All models for teams application.""" + from __future__ import annotations import logging @@ -6,14 +7,11 @@ from django.conf import settings from django.contrib.auth.models import Group -from django.contrib.auth.models import Permission -from django.contrib.contenttypes.models import ContentType from django.contrib.postgres.fields import DateTimeRangeField from django.db import models from django.urls import reverse_lazy from django_prometheus.models import ExportModelOperationsMixin -from camps.models import Permission as CampPermission from utils.models import CampRelatedModel from utils.models import CreatedUpdatedModel from utils.models import UUIDModel @@ -58,6 +56,7 @@ class Team(ExportModelOperationsMixin("team"), CampRelatedModel): """Model for team.""" + camp = models.ForeignKey( "camps.Camp", related_name="teams", @@ -211,6 +210,7 @@ class Team(ExportModelOperationsMixin("team"), CampRelatedModel): class Meta: """Meta.""" + ordering: ClassVar[list[str]] = ["name"] unique_together = (("name", "camp"), ("slug", "camp")) @@ -394,6 +394,7 @@ def tasker_permission_set(self) -> str: class TeamMember(ExportModelOperationsMixin("team_member"), CampRelatedModel): """Model for team member.""" + user = models.ForeignKey( "auth.User", on_delete=models.PROTECT, @@ -425,6 +426,7 @@ class TeamMember(ExportModelOperationsMixin("team_member"), CampRelatedModel): class Meta: """Meta.""" + ordering: ClassVar[list[str]] = ["-lead", "-approved"] def __str__(self) -> str: @@ -471,6 +473,7 @@ def update_group_membership(self, deleted=False) -> None: class TeamTask(ExportModelOperationsMixin("team_task"), CampRelatedModel): """Model for team tasks.""" + team = models.ForeignKey( "teams.Team", related_name="tasks", @@ -498,6 +501,7 @@ class TeamTask(ExportModelOperationsMixin("team_task"), CampRelatedModel): class Meta: """Meta.""" + ordering: ClassVar[list[str]] = ["completed", "when", "name"] unique_together = (("name", "team"), ("slug", "team")) @@ -539,6 +543,7 @@ class TaskComment( CreatedUpdatedModel, ): """Model for task comments.""" + task = models.ForeignKey( "teams.TeamTask", on_delete=models.PROTECT, @@ -548,10 +553,40 @@ class TaskComment( comment = models.TextField() +class TeamShiftAssignment(CampRelatedModel): + """Through model for the shift<>member m2m storing the for_sale state of the shift assignment.""" + + 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?", + ) + + updated_at = models.DateTimeField(auto_now=True) + + @property + def camp(self) -> Camp: + """All CampRelatedModels must have a camp FK or a camp property.""" + return self.team_shift.camp + + class TeamShift(ExportModelOperationsMixin("team_shift"), CampRelatedModel): """Model for team shifts.""" + class Meta: """Meta.""" + ordering = ("shift_range",) team = models.ForeignKey( @@ -563,7 +598,7 @@ class Meta: shift_range = DateTimeRangeField() - team_members = models.ManyToManyField(TeamMember, blank=True) + team_members = models.ManyToManyField("teams.TeamMember", blank=True, through=TeamShiftAssignment) people_required = models.IntegerField(default=1) @@ -582,3 +617,13 @@ def __str__(self) -> str: def users(self) -> list[TeamMember]: """Returns a list of team members on this shift.""" return [member.user for member in self.team_members.all()] + + @property + def for_sale_users(self) -> list[TeamMember]: + """Returns a list of team members who marked this shift for sale.""" + return [member.user for member in self.team_members.filter(teamshiftassignment__for_sale=True)] + + @property + def shifts_taken(self) -> int: + """Returns the number of taken shifts that are not for sale.""" + return self.team_members.filter(teamshiftassignment__for_sale=False).count() diff --git a/src/teams/templates/team_shift_confirm_action.html b/src/teams/templates/team_shift_confirm_action.html new file mode 100644 index 000000000..c8f3d2290 --- /dev/null +++ b/src/teams/templates/team_shift_confirm_action.html @@ -0,0 +1,15 @@ +{% extends 'team_base.html' %} + +{% block title %} + {{ action }} | {{ block.super }} +{% endblock %} + +{% block team_content %} + +
{% csrf_token %} +

{{ action }}

+ + Cancel +
+ +{% endblock %} diff --git a/src/teams/templates/team_shift_list.html b/src/teams/templates/team_shift_list.html index a2a2cb789..e5ebfa162 100644 --- a/src/teams/templates/team_shift_list.html +++ b/src/teams/templates/team_shift_list.html @@ -55,11 +55,11 @@

{{ shift.shift_range.upper|date:'H:i' }} - {{ shift.people_required }} + {{ shift.team_members.count }} of {{ shift.people_required }} assigned - {% for member in shift.team_members.all %} - {{ member.user.profile.get_public_credit_name }}{% if not forloop.last %},{% endif %} + {% for member in shift.teamshiftassignment_set.all %} + {{ member.team_member.user.profile.get_public_credit_name }}{% if member.for_sale %} (for sale!){% endif %}{% if not forloop.last %},{% endif %} {% empty %} None! {% endfor %} @@ -80,7 +80,13 @@

href="{% url 'teams:shift_member_drop' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}"> Unassign me - {% elif shift.people_required > shift.team_members.count %} + {% if user not in shift.for_sale_users %} + + Make available + + {% endif %} + {% elif shift.people_required > shift.shifts_taken %} Assign me diff --git a/src/teams/templates/team_user_shifts.html b/src/teams/templates/team_user_shifts.html index a42cdb708..2ab1dd548 100644 --- a/src/teams/templates/team_user_shifts.html +++ b/src/teams/templates/team_user_shifts.html @@ -33,10 +33,14 @@

{{ shift.shift_range.upper|date:'H:i' }} - - - + + + + Sell shift + + Unassign me diff --git a/src/teams/tests/test_shift_views.py b/src/teams/tests/test_shift_views.py index a7fb212af..03126edd0 100644 --- a/src/teams/tests/test_shift_views.py +++ b/src/teams/tests/test_shift_views.py @@ -31,33 +31,42 @@ def setUpTestData(cls) -> None: def test_team_shift_view_permissions(self) -> None: """Test the team shift view permissions.""" - self.client.force_login(self.users[0]) # Non noc team member + self.client.force_login(self.users[0]) # Non noc team member # Test access control to the views - url = reverse("teams:shift_create", kwargs={ - "team_slug": self.teams["noc"].slug, - "camp_slug": self.camp.slug, - }) + url = reverse( + "teams:shift_create", + kwargs={ + "team_slug": self.teams["noc"].slug, + "camp_slug": self.camp.slug, + }, + ) response = self.client.get(path=url) assert response.status_code == 302 def test_team_user_shift_view(self) -> None: """Test the user shift view.""" - self.client.force_login(self.users[4]) # Noc teamlead - url = reverse("teams:user_shifts", kwargs={ - "camp_slug": self.camp.slug, - }) + self.client.force_login(self.users[4]) # Noc teamlead + url = reverse( + "teams:user_shifts", + kwargs={ + "camp_slug": self.camp.slug, + }, + ) response = self.client.get(path=url) assert response.status_code == 200 def test_team_shift_views(self) -> None: """Test the team shift views.""" - self.client.force_login(self.users[4]) # Noc teamlead + self.client.force_login(self.users[4]) # Noc teamlead # Test creating a shift - url = reverse("teams:shift_create", kwargs={ - "team_slug": self.teams["noc"].slug, - "camp_slug": self.camp.slug, - }) + url = reverse( + "teams:shift_create", + kwargs={ + "team_slug": self.teams["noc"].slug, + "camp_slug": self.camp.slug, + }, + ) response = self.client.get(url) assert response.status_code == 200 @@ -67,7 +76,7 @@ def test_team_shift_views(self) -> None: "from_datetime": self.camp.buildup.lower.date(), "to_datetime": self.camp.buildup.lower + timezone.timedelta(hours=1), "people_required": 1, - }, + }, follow=True, ) assert response.status_code == 200 @@ -84,7 +93,7 @@ def test_team_shift_views(self) -> None: "from_datetime": self.camp.buildup.lower, "to_datetime": self.camp.buildup.lower, "people_required": 1, - }, + }, follow=True, ) assert response.status_code == 200 @@ -101,7 +110,7 @@ def test_team_shift_views(self) -> None: "from_datetime": self.camp.buildup.lower + timezone.timedelta(hours=1), "to_datetime": self.camp.buildup.lower, "people_required": 1, - }, + }, follow=True, ) assert response.status_code == 200 @@ -112,10 +121,13 @@ def test_team_shift_views(self) -> None: self.assertEqual(len(matches), 1, "team shift Start can not be before to end") # Test Creating multiple shifts - url = reverse("teams:shift_create_multiple", kwargs={ - "team_slug": self.teams["noc"].slug, - "camp_slug": self.camp.slug, - }) + url = reverse( + "teams:shift_create_multiple", + kwargs={ + "team_slug": self.teams["noc"].slug, + "camp_slug": self.camp.slug, + }, + ) response = self.client.get(url) assert response.status_code == 200 @@ -127,7 +139,7 @@ def test_team_shift_views(self) -> None: "shift_length": 60, "number_of_shifts": 10, "people_required": 5, - }, + }, follow=True, ) assert response.status_code == 200 @@ -143,11 +155,14 @@ def test_team_shift_views(self) -> None: shift_id = int(shift_link["href"].split("/")[5]) # Test the update view - url = reverse("teams:shift_update", kwargs={ - "team_slug": self.teams["noc"].slug, - "camp_slug": self.camp.slug, - "pk": shift_id, - }) + url = reverse( + "teams:shift_update", + kwargs={ + "team_slug": self.teams["noc"].slug, + "camp_slug": self.camp.slug, + "pk": shift_id, + }, + ) from_datetime = self.camp.buildup.lower to_datetime = from_datetime + timezone.timedelta(hours=2) @@ -160,7 +175,7 @@ def test_team_shift_views(self) -> None: "from_datetime": from_datetime, "to_datetime": to_datetime, "people_required": 2, - }, + }, follow=True, ) assert response.status_code == 200 @@ -168,14 +183,21 @@ def test_team_shift_views(self) -> None: content = response.content.decode() soup = BeautifulSoup(content, "html.parser") row = soup.select_one("table#main_table > tbody > tr:nth-of-type(1) td:nth-of-type(3)").get_text(strip=True) - self.assertEqual(row, "2", "team shift people required count does not return 2 entries after update") + self.assertEqual( + row, + "0 of 2 assigned", + "team shift people required count does not return 2 entries after update", + ) # Test the delete view - url = reverse("teams:shift_delete", kwargs={ - "team_slug": self.teams["noc"].slug, - "camp_slug": self.camp.slug, - "pk": shift_id, - }) + url = reverse( + "teams:shift_delete", + kwargs={ + "team_slug": self.teams["noc"].slug, + "camp_slug": self.camp.slug, + "pk": shift_id, + }, + ) response = self.client.get(url) assert response.status_code == 200 @@ -186,9 +208,25 @@ def test_team_shift_views(self) -> None: rows = soup.select("table#main_table > tbody > tr") self.assertEqual(len(rows), 10, "team shift list does not return 10 entries after delete") + # Test creating a shift as a member not a lead. + self.client.force_login(self.users[1]) # Noc team member + url = reverse( + "teams:shift_create", + kwargs={ + "team_slug": self.teams["noc"].slug, + "camp_slug": self.camp.slug, + }, + ) + response = self.client.get(url, follow=True) + assert response.status_code == 200 + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-danger") + matches = [s for s in rows if "No thanks" in str(s)] + self.assertEqual(len(matches), 0, "team shift create authorization failed incorrect") + def test_team_shift_actions(self) -> None: """Test the team shift actions.""" - self.client.force_login(self.users[4]) # Noc teamlead + self.client.force_login(self.users[4]) # Noc teamlead team_shift_1 = TeamShift( team=self.teams["noc"], @@ -209,13 +247,21 @@ def test_team_shift_actions(self) -> None: ) team_shift_2.save() - url = reverse("teams:shift_member_take", kwargs={ - "team_slug": team_shift_1.team.slug, - "camp_slug": self.camp.slug, - "pk": team_shift_1.pk, - }) + # Test taking a shift + url = reverse( + "teams:shift_member_take", + kwargs={ + "team_slug": team_shift_1.team.slug, + "camp_slug": self.camp.slug, + "pk": team_shift_1.pk, + }, + ) response = self.client.get( path=url, + ) + assert response.status_code == 200 + response = self.client.post( + path=url, follow=True, ) assert response.status_code == 200 @@ -225,12 +271,16 @@ def test_team_shift_actions(self) -> None: matches = [s for s in rows if "Unassign me" in str(s)] self.assertEqual(len(matches), 1, "team shift assign failed") - url = reverse("teams:shift_member_take", kwargs={ - "team_slug": team_shift_1.team.slug, - "camp_slug": self.camp.slug, - "pk": team_shift_2.pk, - }) - response = self.client.get( + # Test taking a double shift + url = reverse( + "teams:shift_member_take", + kwargs={ + "team_slug": team_shift_1.team.slug, + "camp_slug": self.camp.slug, + "pk": team_shift_2.pk, + }, + ) + response = self.client.post( path=url, follow=True, ) @@ -241,14 +291,66 @@ def test_team_shift_actions(self) -> None: matches = [s for s in rows if "overlapping" in str(s)] self.assertEqual(len(matches), 1, "team shift double assign failed to fail") - url = reverse("teams:shift_member_drop", kwargs={ - "team_slug": team_shift_1.team.slug, - "camp_slug": self.camp.slug, - "pk": team_shift_1.pk, - }) + # Test selling a shift + url = reverse( + "teams:shift_member_sell", + kwargs={ + "team_slug": team_shift_1.team.slug, + "camp_slug": self.camp.slug, + "pk": team_shift_1.pk, + }, + ) + response = self.client.get( + path=url, + ) + assert response.status_code == 200 + response = self.client.post( + path=url, + follow=True, + ) + assert response.status_code == 200 + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select_one("table#main_table > tbody > tr:nth-of-type(1) td:nth-of-type(4)") + matches = [s for s in rows if "for sale!" in str(s)] + self.assertEqual(len(matches), 1, "team shift sell failed") + + # Test taking a sellable shift with user1 + self.client.force_login(self.users[1]) # Noc team member + url = reverse( + "teams:shift_member_take", + kwargs={ + "team_slug": team_shift_1.team.slug, + "camp_slug": self.camp.slug, + "pk": team_shift_1.pk, + }, + ) + response = self.client.post( + path=url, + follow=True, + ) + assert response.status_code == 200 + content = response.content.decode() + soup = BeautifulSoup(content, "html.parser") + rows = soup.select_one("table#main_table > tbody > tr:nth-of-type(1) td:nth-of-type(5)") + matches = [s for s in rows if "Unassign me" in str(s)] + self.assertEqual(len(matches), 1, "team shift assign failed") + # Test dropping a shift + url = reverse( + "teams:shift_member_drop", + kwargs={ + "team_slug": team_shift_1.team.slug, + "camp_slug": self.camp.slug, + "pk": team_shift_1.pk, + }, + ) response = self.client.get( path=url, + ) + assert response.status_code == 200 + response = self.client.post( + path=url, follow=True, ) assert response.status_code == 200 @@ -257,3 +359,32 @@ def test_team_shift_actions(self) -> None: rows = soup.select_one("table#main_table > tbody > tr:nth-of-type(1) td:nth-of-type(5)") matches = [s for s in rows if "Assign me" in str(s)] self.assertEqual(len(matches), 1, "team shift unassign failed") + + # Test taking a shift as a user not on this team + self.client.force_login(self.users[3]) # User not on the NOC team + url = reverse( + "teams:shift_member_take", + kwargs={ + "team_slug": team_shift_1.team.slug, + "camp_slug": self.camp.slug, + "pk": team_shift_1.pk, + }, + ) + response = self.client.get( + path=url, + follow=True + ) + assert response.status_code == 200 + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-danger") + matches = [s for s in rows if "No thanks" in str(s)] + self.assertEqual(len(matches), 0, "team shift authorization failed incorrect") + response = self.client.post( + path=url, + follow=True, + ) + assert response.status_code == 200 + soup = BeautifulSoup(content, "html.parser") + rows = soup.select("div.alert.alert-danger") + matches = [s for s in rows if "No thanks" in str(s)] + self.assertEqual(len(matches), 0, "team shift authorization failed incorrect") diff --git a/src/teams/urls.py b/src/teams/urls.py index 39deab476..36730a787 100644 --- a/src/teams/urls.py +++ b/src/teams/urls.py @@ -20,6 +20,7 @@ from teams.views.members import TeamMemberRemoveView from teams.views.members import TeamMembersView from teams.views.shifts import MemberDropsShift +from teams.views.shifts import MemberSellsShift from teams.views.shifts import MemberTakesShift from teams.views.shifts import ShiftCreateMultipleView from teams.views.shifts import ShiftCreateView @@ -176,6 +177,11 @@ MemberDropsShift.as_view(), name="shift_member_drop", ), + path( + "sell", + MemberSellsShift.as_view(), + name="shift_member_sell", + ), ], ), ), diff --git a/src/teams/views/mixins.py b/src/teams/views/mixins.py index 2ffc3388f..b8ab1b0a6 100644 --- a/src/teams/views/mixins.py +++ b/src/teams/views/mixins.py @@ -33,6 +33,23 @@ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: return super().dispatch(request, *args, **kwargs) +class EnsureTeamMemberMixin: + """Use to make sure request.user has team member permission for the team specified by kwargs['team_slug'].""" + + def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse: + """Method to make sure request.user has team member permission for the team specified by kwargs['team_slug'].""" + self.team = Team.objects.get(slug=kwargs["team_slug"], camp=self.camp) + if self.team.member_permission_set not in request.user.get_all_permissions(): + messages.error(request, "No thanks") + return redirect( + "teams:general", + camp_slug=self.camp.slug, + team_slug=self.team.slug, + ) + + return super().dispatch(request, *args, **kwargs) + + class EnsureTeamMemberLeadMixin(SingleObjectMixin): """Use to make sure request.user has team lead permission for the team which TeamMember belongs to.""" diff --git a/src/teams/views/shifts.py b/src/teams/views/shifts.py index 5a361d8ae..fb34e3708 100644 --- a/src/teams/views/shifts.py +++ b/src/teams/views/shifts.py @@ -1,4 +1,5 @@ """View for shifts in teams application.""" + from __future__ import annotations from typing import TYPE_CHECKING @@ -26,20 +27,23 @@ from teams.models import Team from teams.models import TeamMember from teams.models import TeamShift +from teams.models import TeamShiftAssignment from utils.mixins import IsTeamPermContextMixin from .mixins import EnsureTeamLeadMixin +from .mixins import EnsureTeamMemberMixin if TYPE_CHECKING: from django.db.models import QuerySet from django.http import HttpRequest from django.http import HttpResponse - + from django.forms import ModelForm from camps.models import Camp class ShiftListView(LoginRequiredMixin, CampViewMixin, IsTeamPermContextMixin, ListView): """Shift list view.""" + model = TeamShift template_name = "team_shift_list.html" context_object_name = "shifts" @@ -66,7 +70,7 @@ def date_choices(camp: Camp) -> list: minute_choices = [] # To begin with we assume a shift can not be shorter than an hour shift_minimum_length = 60 - while index * shift_minimum_length < 60: # noqa: PLR2004 + while index * shift_minimum_length < 60: # noqa: PLR2004 minutes = int(index * shift_minimum_length) minute_choices.append(minutes) index += 1 @@ -94,12 +98,14 @@ def get_time_choices(date: str) -> list: class ShiftForm(forms.ModelForm): """Form for shifts.""" + class Meta: """Meta.""" + model = TeamShift fields = ("from_datetime", "to_datetime", "people_required") - def __init__(self, instance: TeamShift|None=None, **kwargs) -> None: + def __init__(self, instance: TeamShift | None = None, **kwargs) -> None: """Method for setting up the form.""" camp = kwargs.pop("camp") super().__init__(instance=instance, **kwargs) @@ -151,6 +157,7 @@ def save(self, commit=True) -> TeamShift: class ShiftCreateView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, CreateView): """View for creating a single shift.""" + model = TeamShift template_name = "team_shift_form.html" form_class = ShiftForm @@ -184,6 +191,7 @@ def get_success_url(self) -> str: class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, UpdateView): """View for updating a single shift.""" + model = TeamShift template_name = "team_shift_form.html" form_class = ShiftForm @@ -209,6 +217,7 @@ def get_success_url(self) -> str: class ShiftDeleteView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, DeleteView): """View for deleting a shift.""" + model = TeamShift template_name = "team_shift_confirm_delete.html" active_menu = "shifts" @@ -230,7 +239,8 @@ def get_success_url(self) -> str: class MultipleShiftForm(forms.Form): """Form for creating multple shifts.""" - def __init__(self, instance: dict|None=None, **kwargs) -> None: + + def __init__(self, instance: dict | None = None, **kwargs) -> None: """Method for form init setting camp to kwargs.""" camp = kwargs.pop("camp") super().__init__(**kwargs) @@ -249,6 +259,7 @@ def __init__(self, instance: dict|None=None, **kwargs) -> None: class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, FormView): """View for creating multiple shifts.""" + template_name = "team_shift_form.html" form_class = MultipleShiftForm active_menu = "shifts" @@ -302,20 +313,31 @@ def get_context_data(self, **kwargs) -> dict: return context -class MemberTakesShift(LoginRequiredMixin, CampViewMixin, View): +class MemberTakesShift(LoginRequiredMixin, CampViewMixin, EnsureTeamMemberMixin, UpdateView): """View for adding a user to a shift.""" - http_methods = ("get",) + model = TeamShift + fields = [] + template_name = "team_shift_confirm_action.html" + context_object_name = "shifts" + active_menu = "shifts" + + def get_context_data(self, **kwargs) -> dict[str, object]: + """Method for setting the page context data.""" + context = super().get_context_data(**kwargs) + context['action'] = f"Are you sure you want to take this {self.object}?" + context["team"] = self.object.team + return context - def get(self, request: HttpRequest, **kwargs) -> HttpResponseRedirect: + def form_valid(self, form: ModelForm[TeamShift]) -> HttpResponseRedirect: """Method for adding user to a shift.""" - shift = TeamShift.objects.get(id=kwargs["pk"]) - team = Team.objects.get(camp=self.camp, slug=kwargs["team_slug"]) + shift = self.object + team = self.object.team - team_member = TeamMember.objects.get(team=team, user=request.user) + team_member = TeamMember.objects.get(team=team, user=self.request.user) overlapping_shifts = TeamShift.objects.filter( team__camp=self.camp, - team_members__user=request.user, + team_members__user=self.request.user, shift_range__overlap=shift.shift_range, ) @@ -329,37 +351,86 @@ def get(self, request: HttpRequest, **kwargs) -> HttpResponseRedirect: """, ) messages.error( - request, + self.request, template.render(Context({"shifts": overlapping_shifts})), ) else: + # Remove at most one shift assignment for sale if any + # When a shift is for sale and a user presses assign its first assigning the for sale one + for shift_assignment in shift.team_members.filter(teamshiftassignment__for_sale=True).order_by('teamshiftassignment__updated_at')[:1]: + shift.team_members.remove(shift_assignment) shift.team_members.add(team_member) - kwargs.pop("pk") + self.kwargs.pop("pk") - return HttpResponseRedirect(reverse("teams:shifts", kwargs=kwargs)) + return HttpResponseRedirect(reverse("teams:shifts", kwargs=self.kwargs)) -class MemberDropsShift(LoginRequiredMixin, CampViewMixin, View): +class MemberDropsShift(LoginRequiredMixin, CampViewMixin, EnsureTeamMemberMixin, UpdateView): + model = TeamShift + fields = [] + template_name = "team_shift_confirm_action.html" + context_object_name = "shifts" + active_menu = "shifts" """View for remove a user from a shift.""" - http_methods = ("get",) - def get(self, request: HttpRequest, **kwargs) -> HttpResponseRedirect: + def get_context_data(self, **kwargs) -> dict[str, object]: + """Method for setting the page context data.""" + context = super().get_context_data(**kwargs) + context['action'] = f"Are you sure you want to drop this {self.object}?" + context["team"] = self.object.team + return context + + def form_valid(self, form: ModelForm[TeamShift]) -> HttpResponseRedirect: """Method to remove user from shift.""" - shift = TeamShift.objects.get(id=kwargs["pk"]) - team = Team.objects.get(camp=self.camp, slug=kwargs["team_slug"]) + shift = self.object + team = Team.objects.get(camp=self.camp, slug=self.kwargs["team_slug"]) - team_member = TeamMember.objects.get(team=team, user=request.user) + team_member = TeamMember.objects.get(team=team, user=self.request.user) shift.team_members.remove(team_member) - kwargs.pop("pk") + self.kwargs.pop("pk") + + return HttpResponseRedirect(reverse("teams:shifts", kwargs=self.kwargs)) - return HttpResponseRedirect(reverse("teams:shifts", kwargs=kwargs)) + +class MemberSellsShift(LoginRequiredMixin, CampViewMixin, EnsureTeamMemberMixin, UpdateView): + """View for making a shift available for other user to take.""" + model = TeamShift + fields = [] + template_name = "team_shift_confirm_action.html" + context_object_name = "shifts" + active_menu = "shifts" + + def get_context_data(self, **kwargs) -> dict[str, object]: + """Method for setting the page context data.""" + context = super().get_context_data(**kwargs) + context['action'] = f"Are you sure you want to this {self.object} available to others?" + context["team"] = self.object.team + return context + + http_methods = ("get",) + + def form_valid(self, form: ModelForm[TeamShift]) -> HttpResponseRedirect: + """Method for making a shift available for other user to take.""" + shift = self.object + team = Team.objects.get(camp=self.camp, slug=self.kwargs["team_slug"]) + + team_member = TeamMember.objects.get(team=team, user=self.request.user) + + shift_assignment = TeamShiftAssignment.objects.get(team_member=team_member, team_shift=shift) + shift_assignment.for_sale = True + shift_assignment.save() + + self.kwargs.pop("pk") + + return HttpResponseRedirect(reverse("teams:shifts", kwargs=self.kwargs)) class UserShifts(CampViewMixin, TemplateView): """View for showing shifts for current user.""" + template_name = "team_user_shifts.html" def get_context_data(self, **kwargs) -> dict: diff --git a/src/utils/bootstrap/base.py b/src/utils/bootstrap/base.py index ded23177b..0dbe22632 100644 --- a/src/utils/bootstrap/base.py +++ b/src/utils/bootstrap/base.py @@ -2180,6 +2180,7 @@ def bootstrap_tests(self) -> None: self.camp = self.camps[1][0] self.add_team_permissions(self.camp) + self.camp.activate_team_permissions() self.teams = teams[self.camp.camp.lower.year] for member in TeamMember.objects.filter(team__camp=self.camp): member.save()