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 %}
+
+
+
+{% 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 @@ |
{{ 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()