diff --git a/src/teams/__init__.py b/src/teams/__init__.py
index e69de29bb..d13152713 100644
--- a/src/teams/__init__.py
+++ b/src/teams/__init__.py
@@ -0,0 +1 @@
+"""Teams application."""
diff --git a/src/teams/admin.py b/src/teams/admin.py
index 27d87defa..9b661855d 100644
--- a/src/teams/admin.py
+++ b/src/teams/admin.py
@@ -1,5 +1,14 @@
+"""Django admin for teams."""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from typing import ClassVar
+
+ from django.db.models import QuerySet
+ from django.http import HttpRequest
+
from django.contrib import admin
from camps.utils import CampPropertyListFilter
@@ -14,26 +23,30 @@
@admin.register(TeamTask)
class TeamTaskAdmin(admin.ModelAdmin):
- list_display = ["id", "team", "name", "description"]
+ """Django admin for team tasks."""
+ list_display: ClassVar[list[str]] = ["id", "team", "name", "description"]
class TeamMemberInline(admin.TabularInline):
+ """Django admin inline field for team member."""
model = TeamMember
@admin.register(Team)
class TeamAdmin(admin.ModelAdmin):
+ """Django admin for teams."""
save_as = True
@admin.display(
description="Leads",
)
- def get_leads(self, obj):
+ def get_leads(self, obj: Team) -> str:
+ """Method to return team leads."""
return ", ".join(
[lead.profile.public_credit_name for lead in obj.leads.all()],
)
- list_display = [
+ list_display: ClassVar[list[str]] = [
"name",
"camp",
"get_leads",
@@ -46,7 +59,7 @@ def get_leads(self, obj):
"private_irc_channel_managed",
]
- list_filter = [
+ list_filter: ClassVar[list[str]] = [
CampPropertyListFilter,
"needs_members",
"public_irc_channel_bot",
@@ -54,16 +67,18 @@ def get_leads(self, obj):
"private_irc_channel_bot",
"private_irc_channel_managed",
]
- inlines = [TeamMemberInline]
+ inlines: ClassVar[list] = [TeamMemberInline]
@admin.register(TeamMember)
class TeamMemberAdmin(admin.ModelAdmin):
- list_filter = [CampPropertyListFilter, "team", "approved"]
+ """Django admin for team members."""
+ list_filter: ClassVar[list] = [CampPropertyListFilter, "team", "approved"]
- actions = ["approve_membership", "remove_member"]
+ actions: ClassVar[list[str]] = ["approve_membership", "remove_member"]
- def approve_membership(self, request, queryset) -> None:
+ def approve_membership(self, request: HttpRequest, queryset: QuerySet) -> None:
+ """Method for approving team membership status."""
teams_count = queryset.values("team").distinct().count()
updated = 0
@@ -80,7 +95,8 @@ def approve_membership(self, request, queryset) -> None:
approve_membership.description = "Approve membership."
- def remove_member(self, request, queryset) -> None:
+ def remove_member(self, request: HttpRequest, queryset: QuerySet) -> None:
+ """Method for removing team membership status."""
teams_count = queryset.values("team").distinct().count()
updated = 0
@@ -99,4 +115,5 @@ def remove_member(self, request, queryset) -> None:
@admin.register(TeamShift)
class TeamShiftAdmin(admin.ModelAdmin):
- list_filter = ["team"]
+ """Django admin for team shifts."""
+ list_filter: ClassVar[list[str]] = ["team"]
diff --git a/src/teams/apps.py b/src/teams/apps.py
index b5b2f0bc3..ca9b77e7c 100644
--- a/src/teams/apps.py
+++ b/src/teams/apps.py
@@ -1,3 +1,4 @@
+"""App config for the teams application."""
from __future__ import annotations
from django.apps import AppConfig
@@ -10,10 +11,13 @@
class TeamsConfig(AppConfig):
+ """App config for the signals connected to the teams application."""
name = "teams"
def ready(self) -> None:
- # connect the post_save signal, always including a dispatch_uid to prevent it being called multiple times in corner cases
+ """Method to connect the signals."""
+ # connect the post_save signal, always including a dispatch_uid to prevent
+ # it being called multiple times in corner cases
post_save.connect(
teammember_saved,
sender="teams.TeamMember",
diff --git a/src/teams/email.py b/src/teams/email.py
index 84b01d352..8bcc4c49f 100644
--- a/src/teams/email.py
+++ b/src/teams/email.py
@@ -1,13 +1,19 @@
+"""Email functions of teams application."""
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
from utils.email import add_outgoing_email
+if TYPE_CHECKING:
+ from django.forms import Form
+
logger = logging.getLogger(f"bornhack.{__name__}")
-def add_added_membership_email(membership):
+def add_added_membership_email(membership: Form) -> bool:
+ """Method to send email when team membership added."""
formatdict = {"team": membership.team.name, "camp": membership.team.camp.title}
return add_outgoing_email(
@@ -21,7 +27,8 @@ def add_added_membership_email(membership):
)
-def add_removed_membership_email(membership):
+def add_removed_membership_email(membership: Form) -> bool:
+ """Method to send email when team membership removed."""
formatdict = {"team": membership.team.name, "camp": membership.team.camp.title}
if membership.approved:
@@ -42,7 +49,8 @@ def add_removed_membership_email(membership):
)
-def add_new_membership_email(membership):
+def add_new_membership_email(membership: Form) -> bool:
+ """Method to send email when team membership requested."""
formatdict = {
"team": membership.team.name,
"camp": membership.team.camp.title,
diff --git a/src/teams/exceptions.py b/src/teams/exceptions.py
new file mode 100644
index 000000000..2e188218f
--- /dev/null
+++ b/src/teams/exceptions.py
@@ -0,0 +1,39 @@
+"""Exceptions for phonebook."""
+
+from __future__ import annotations
+
+import logging
+
+from django.core.exceptions import ValidationError
+
+logger = logging.getLogger(f"bornhack.{__name__}")
+
+
+class ReservedIrcNameError(ValidationError):
+ """Exception raised on reserved irc name."""
+
+ def __init__(self) -> None:
+ """Exception raised on reserved irc name."""
+ super().__init__("The public IRC channel name is reserved")
+
+
+class IrcChannelInUseError(ValidationError):
+ """Exception raised a public irc channel is in use."""
+
+ def __init__(self) -> None:
+ """Exception raised a public irc channel is in use."""
+ super().__init__("The public IRC channel name is already in use on another team!")
+
+class StartAfterEndError(ValidationError):
+ """Exception raised when start date is after end date."""
+
+ def __init__(self) -> None:
+ """Exception raised when start date is after end date."""
+ super().__init__("Start can not be after end.")
+
+class StartSameAsEndError(ValidationError):
+ """Exception raised when start date is the same as end date."""
+
+ def __init__(self) -> None:
+ """Exception raised when start date is the same as end date."""
+ super().__init__("Start can not be the same as end.")
diff --git a/src/teams/factories.py b/src/teams/factories.py
index 55bad0f11..b9cb20df6 100644
--- a/src/teams/factories.py
+++ b/src/teams/factories.py
@@ -1,3 +1,4 @@
+"""Factories for the teams application."""
from __future__ import annotations
import factory
@@ -6,7 +7,9 @@
class TeamFactory(factory.django.DjangoModelFactory):
+ """Team Factory for bootstrapping data."""
class Meta:
+ """Meta."""
model = Team
camp = factory.SubFactory("camps.factories.CampFactory")
diff --git a/src/teams/models.py b/src/teams/models.py
index a74ec638c..0bf12a87c 100644
--- a/src/teams/models.py
+++ b/src/teams/models.py
@@ -1,13 +1,14 @@
+"""All models for teams application."""
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
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.core.exceptions import ValidationError
from django.db import models
from django.urls import reverse_lazy
from django_prometheus.models import ExportModelOperationsMixin
@@ -18,6 +19,16 @@
from utils.models import UUIDModel
from utils.slugs import unique_slugify
+from .exceptions import IrcChannelInUseError
+from .exceptions import ReservedIrcNameError
+
+if TYPE_CHECKING:
+ from typing import ClassVar
+
+ from django.db.models import QuerySet
+
+ from camps.models import Camp
+
logger = logging.getLogger(f"bornhack.{__name__}")
@@ -46,6 +57,7 @@
class Team(ExportModelOperationsMixin("team"), CampRelatedModel):
+ """Model for team."""
camp = models.ForeignKey(
"camps.Camp",
related_name="teams",
@@ -104,19 +116,23 @@ class Team(ExportModelOperationsMixin("team"), CampRelatedModel):
null=True,
unique=True,
max_length=50,
- help_text="The public IRC channel for this team. Will be shown on the team page so people know how to reach the team. Leave empty if the team has no public IRC channel.",
+ help_text="The public IRC channel for this team. Will be shown on the team page so people know "
+ "how to reach the team. Leave empty if the team has no public IRC channel.",
)
public_irc_channel_bot = models.BooleanField(
default=False,
- help_text="Check to make the bot join the teams public IRC channel. Leave unchecked to disable the IRC bot for this channel.",
+ help_text="Check to make the bot join the teams public IRC channel. "
+ "Leave unchecked to disable the IRC bot for this channel.",
)
public_irc_channel_managed = models.BooleanField(
default=False,
- help_text="Check to make the bot manage the teams public IRC channel by registering it with NickServ and setting +Oo for all teammembers.",
+ help_text="Check to make the bot manage the teams public IRC channel by registering it with NickServ "
+ "and setting +Oo for all teammembers.",
)
public_irc_channel_fix_needed = models.BooleanField(
default=False,
- help_text="Used to indicate to the IRC bot that this teams public IRC channel is in need of a permissions and ACL fix.",
+ help_text="Used to indicate to the IRC bot that this teams public IRC channel is in need of a "
+ "permissions and ACL fix.",
)
private_irc_channel_name = models.CharField(
@@ -124,19 +140,23 @@ class Team(ExportModelOperationsMixin("team"), CampRelatedModel):
null=True,
unique=True,
max_length=50,
- help_text="The private IRC channel for this team. Will be shown to team members on the team page. Leave empty if the team has no private IRC channel.",
+ help_text="The private IRC channel for this team. Will be shown to team members on the team page. "
+ "Leave empty if the team has no private IRC channel.",
)
private_irc_channel_bot = models.BooleanField(
default=False,
- help_text="Check to make the bot join the teams private IRC channel. Leave unchecked to disable the IRC bot for this channel.",
+ help_text="Check to make the bot join the teams private IRC channel. "
+ "Leave unchecked to disable the IRC bot for this channel.",
)
private_irc_channel_managed = models.BooleanField(
default=False,
- help_text="Check to make the bot manage the private IRC channel by registering it with NickServ, setting +I and maintaining the ACL.",
+ help_text="Check to make the bot manage the private IRC channel by registering it with NickServ, "
+ "setting +I and maintaining the ACL.",
)
private_irc_channel_fix_needed = models.BooleanField(
default=False,
- help_text="Used to indicate to the IRC bot that this teams private IRC channel is in need of a permissions and ACL fix.",
+ help_text="Used to indicate to the IRC bot that this teams private IRC channel is in need of a "
+ "permissions and ACL fix.",
)
# Signal
@@ -149,7 +169,8 @@ class Team(ExportModelOperationsMixin("team"), CampRelatedModel):
)
class Meta:
- ordering = ["name"]
+ """Meta."""
+ ordering: ClassVar[list[str]] = ["name"]
unique_together = (("name", "camp"), ("slug", "camp"))
guide = models.TextField(
@@ -160,15 +181,18 @@ class Meta:
)
def __str__(self) -> str:
+ """Method to return a str of the model."""
return f"{self.name} ({self.camp})"
- def get_absolute_url(self):
+ def get_absolute_url(self) -> str:
+ """Method to return the absolute URL."""
return reverse_lazy(
"teams:general",
kwargs={"camp_slug": self.camp.slug, "team_slug": self.slug},
)
def save(self, **kwargs) -> None:
+ """Method for generating slugs and add groups if needed."""
# generate slug if needed
if not self.slug:
self.slug = unique_slugify(
@@ -192,6 +216,7 @@ def save(self, **kwargs) -> None:
super().save(**kwargs)
def clean(self) -> None:
+ """Method for cleaning data."""
# make sure the public irc channel name is prefixed with a # if it is set
if self.public_irc_channel_name and self.public_irc_channel_name[0] != "#":
self.public_irc_channel_name = f"#{self.public_irc_channel_name}"
@@ -202,11 +227,12 @@ def clean(self) -> None:
# make sure the channel names are not reserved
if self.public_irc_channel_name in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL):
- raise ValidationError("The public IRC channel name is reserved")
+ raise ReservedIrcNameError
if self.private_irc_channel_name in (settings.IRCBOT_PUBLIC_CHANNEL, settings.IRCBOT_VOLUNTEER_CHANNEL):
- raise ValidationError("The private IRC channel name is reserved")
+ raise ReservedIrcNameError
- # make sure public_irc_channel_name is not in use as public or private irc channel for another team, case insensitive
+ # make sure public_irc_channel_name is not in use as public or private irc channel for another team,
+ # case insensitive
if self.public_irc_channel_name and (
Team.objects.filter(
private_irc_channel_name__iexact=self.public_irc_channel_name,
@@ -219,11 +245,10 @@ def clean(self) -> None:
.exclude(pk=self.pk)
.exists()
):
- raise ValidationError(
- "The public IRC channel name is already in use on another team!",
- )
+ raise IrcChannelInUseError
- # make sure private_irc_channel_name is not in use as public or private irc channel for another team, case insensitive
+ # make sure private_irc_channel_name is not in use as public or private irc channel for another team,
+ # case insensitive
if self.private_irc_channel_name and (
Team.objects.filter(
private_irc_channel_name__iexact=self.private_irc_channel_name,
@@ -236,31 +261,31 @@ def clean(self) -> None:
.exclude(pk=self.pk)
.exists()
):
- raise ValidationError(
- "The private IRC channel name is already in use on another team!",
- )
+ raise IrcChannelInUseError
@property
- def memberships(self):
+ def memberships(self) -> QuerySet:
"""Returns all TeamMember objects for this team.
+
Use self.members.all() to get User objects for all members,
or use self.memberships.all() to get TeamMember objects for all members.
"""
return TeamMember.objects.filter(team=self)
@property
- def approved_members(self):
+ def approved_members(self) -> QuerySet:
"""Returns only approved members (returns User objects, not TeamMember objects)."""
return self.members.filter(teammember__approved=True)
@property
- def unapproved_members(self):
+ def unapproved_members(self) -> QuerySet:
"""Returns only unapproved members (returns User objects, not TeamMember objects)."""
return self.members.filter(teammember__approved=False)
@property
- def leads(self):
- """Return only approved team leads
+ def leads(self) -> QuerySet:
+ """Return only approved team leads.
+
Used to handle permissions for team leads.
"""
return self.members.filter(
@@ -269,9 +294,9 @@ def leads(self):
)
@property
- def regular_members(self):
- """Return only approved and not lead members with
- an approved public_credit_name.
+ def regular_members(self) -> QuerySet:
+ """Return only approved and not lead members with an approved public_credit_name.
+
Used on the people pages.
"""
return self.members.filter(
@@ -280,10 +305,8 @@ def regular_members(self):
)
@property
- def unnamed_members(self):
- """Returns only approved and not team lead members,
- without an approved public_credit_name.
- """
+ def unnamed_members(self) -> QuerySet:
+ """Returns only approved and not team lead members, without an approved public_credit_name."""
return self.members.filter(
teammember__approved=True,
teammember__lead=False,
@@ -292,34 +315,42 @@ def unnamed_members(self):
@property
def member_permission_set(self) -> str:
+ """Method for returning the team member permission set."""
return f"camps.{self.slug}_team_member"
@property
def mapper_permission_set(self) -> str:
+ """Method for returning the mapper permission set."""
return f"camps.{self.slug}_team_mapper"
@property
def facilitator_permission_set(self) -> str:
+ """Method for returning the facilitator permission set."""
return f"camps.{self.slug}_team_facilitator"
@property
def lead_permission_set(self) -> str:
+ """Method for returning the team lead permission set."""
return f"camps.{self.slug}_team_lead"
@property
def pos_permission_set(self) -> str:
+ """Method for returning the pos permission set."""
return f"camps.{self.slug}_team_pos"
@property
def infopager_permission_set(self) -> str:
+ """Method for returning the infopager permission set."""
return f"camps.{self.slug}_team_infopager"
@property
def tasker_permission_set(self) -> str:
+ """Method for returning the tasker permission set."""
return f"camps.{self.slug}_team_tasker"
class TeamMember(ExportModelOperationsMixin("team_member"), CampRelatedModel):
+ """Model for team member."""
user = models.ForeignKey(
"auth.User",
on_delete=models.PROTECT,
@@ -344,13 +375,17 @@ class TeamMember(ExportModelOperationsMixin("team_member"), CampRelatedModel):
irc_acl_fix_needed = models.BooleanField(
default=False,
- help_text="Maintained by the IRC bot, manual editing should not be needed. Will be set to true when a teammember sets or changes NickServ username, and back to false after the ACL has been fixed by the bot.",
+ help_text="Maintained by the IRC bot, manual editing should not be needed. "
+ "Will be set to true when a teammember sets or changes NickServ username, "
+ "and back to false after the ACL has been fixed by the bot.",
)
class Meta:
- ordering = ["-lead", "-approved"]
+ """Meta."""
+ ordering: ClassVar[list[str]] = ["-lead", "-approved"]
def __str__(self) -> str:
+ """Method for returning str of model."""
return "{} is {} {} member of team {}".format(
self.user,
"" if self.approved else "an unapproved",
@@ -359,7 +394,7 @@ def __str__(self) -> str:
)
@property
- def camp(self):
+ def camp(self) -> Camp:
"""All CampRelatedModels must have a camp FK or a camp property."""
return self.team.camp
@@ -399,6 +434,7 @@ def update_team_lead_permissions(self, deleted=False) -> None:
class TeamTask(ExportModelOperationsMixin("team_task"), CampRelatedModel):
+ """Model for team tasks."""
team = models.ForeignKey(
"teams.Team",
related_name="tasks",
@@ -425,10 +461,12 @@ class TeamTask(ExportModelOperationsMixin("team_task"), CampRelatedModel):
)
class Meta:
- ordering = ["completed", "when", "name"]
+ """Meta."""
+ ordering: ClassVar[list[str]] = ["completed", "when", "name"]
unique_together = (("name", "team"), ("slug", "team"))
- def get_absolute_url(self):
+ def get_absolute_url(self) -> str:
+ """Get the absolute URL for this model."""
return reverse_lazy(
"teams:task_detail",
kwargs={
@@ -439,13 +477,14 @@ def get_absolute_url(self):
)
@property
- def camp(self):
+ def camp(self) -> Camp:
"""All CampRelatedModels must have a camp FK or a camp property."""
return self.team.camp
camp_filter = "team__camp"
def save(self, **kwargs) -> None:
+ """Method for generating the slug if needed."""
# generate slug if needed
if not self.slug:
self.slug = unique_slugify(
@@ -463,6 +502,7 @@ class TaskComment(
UUIDModel,
CreatedUpdatedModel,
):
+ """Model for task comments."""
task = models.ForeignKey(
"teams.TeamTask",
on_delete=models.PROTECT,
@@ -473,7 +513,9 @@ class TaskComment(
class TeamShift(ExportModelOperationsMixin("team_shift"), CampRelatedModel):
+ """Model for team shifts."""
class Meta:
+ """Meta."""
ordering = ("shift_range",)
team = models.ForeignKey(
@@ -490,15 +532,17 @@ class Meta:
people_required = models.IntegerField(default=1)
@property
- def camp(self):
+ def camp(self) -> Camp:
"""All CampRelatedModels must have a camp FK or a camp property."""
return self.team.camp
camp_filter = "team__camp"
def __str__(self) -> str:
+ """Method for returning a string of this model."""
return f"{self.team.name} team shift from {self.shift_range.lower} to {self.shift_range.upper}"
@property
- def users(self):
+ def users(self) -> list[TeamMember]:
+ """Returns a list of team members on this shift."""
return [member.user for member in self.team_members.all()]
diff --git a/src/teams/signal_handlers.py b/src/teams/signal_handlers.py
index b26e39253..1fdc58c5c 100644
--- a/src/teams/signal_handlers.py
+++ b/src/teams/signal_handlers.py
@@ -1,13 +1,21 @@
+"""Signals for the teams application."""
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
+
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
+ from teams.models import TeamMember
+
from django.conf import settings
logger = logging.getLogger(f"bornhack.{__name__}")
-def teammember_saved(sender, instance, created, **kwargs) -> None:
+def teammember_saved(sender: User, instance: TeamMember, created: bool, **_kwargs) -> None:
"""This signal handler is called whenever a TeamMember instance is saved."""
# if this is a new unapproved teammember send a mail to team leads
if created and not instance.approved:
@@ -24,14 +32,14 @@ def teammember_saved(sender, instance, created, **kwargs) -> None:
instance.update_team_lead_permissions()
-def teammember_deleted(sender, instance, **kwargs) -> None:
+def teammember_deleted(sender: User, instance: TeamMember, **_kwargs) -> None:
"""This signal handler is called whenever a TeamMember instance is deleted."""
if instance.team.private_irc_channel_name and instance.team.private_irc_channel_managed:
- # TODO: remove user from private channel ACL
+ # TODO(tyk): remove user from private channel ACL
pass
if instance.team.public_irc_channel_name and instance.team.public_irc_channel_managed:
- # TODO: remove user from public channel ACL
+ # TODO(tyk): remove user from public channel ACL
pass
# make sure the teams group membership is uptodate
@@ -39,7 +47,7 @@ def teammember_deleted(sender, instance, **kwargs) -> None:
instance.update_team_lead_permissions(deleted=True)
-def team_saved(sender, instance, created, **kwargs) -> None:
+def team_saved(sender: User, instance: TeamMember, created: bool, **_kwargs) -> None:
"""This signal handler is called whenever a Team instance is saved."""
# late imports
from django.contrib.auth.models import Permission
diff --git a/src/teams/templates/team_base.html b/src/teams/templates/team_base.html
index a5187cfe2..557eac6d0 100644
--- a/src/teams/templates/team_base.html
+++ b/src/teams/templates/team_base.html
@@ -45,7 +45,7 @@
{{ team.name }} Team
{% endif %}
- {% if request.user in team.responsible_members %}
+ {% if is_team_infopager %}
{% endif %}
-
+
{% for shift in shifts %}
{% ifchanged shift.shift_range.lower|date:'d' %}
+
- |
+ |
{{ shift.shift_range.lower|date:'Y-m-d l' }}
- |
- |
- From
- |
- To
- |
- People required
- |
- People
- |
- Actions
+
+
+ |
+
+ |
+ From |
+
+ To |
+
+ People required |
+
+ People |
+
+ Actions |
+
+
{% endifchanged %}
|
{{ shift.shift_range.lower|date:'H:i' }}
- |
- {{ shift.shift_range.upper|date:'H:i' }}
- |
- {{ shift.people_required }}
- |
- {% for member in shift.team_members.all %}
- {{ member.user.profile.get_public_credit_name }}{% if not forloop.last %},{% endif %}
- {% empty %}
- None!
- {% endfor %}
-
- |
- {% if request.user in team.leads.all %}
-
- Edit
-
-
- Delete
-
- {% endif %}
- {% if user in shift.users %}
-
- Unassign me
-
- {% elif shift.people_required > shift.team_members.count %}
-
- Assign me
-
- {% endif %}
+ |
+
+ {{ shift.shift_range.upper|date:'H:i' }}
+ |
+
+ {{ shift.people_required }}
+ |
+
+ {% for member in shift.team_members.all %}
+ {{ member.user.profile.get_public_credit_name }}{% if not forloop.last %},{% endif %}
+ {% empty %}
+ None!
+ {% endfor %}
+ |
+
+ {% if request.user in team.leads.all %}
+
+ Edit
+
+
+ Delete
+
+ {% endif %}
+ {% if user in shift.users %}
+
+ Unassign me
+
+ {% elif shift.people_required > shift.team_members.count %}
+
+ Assign me
+
+ {% endif %}
+ |
+
{% endfor %}
{% endblock %}
diff --git a/src/teams/templates/team_tasks.html b/src/teams/templates/team_tasks.html
index 5cb959455..7ec1a4cde 100644
--- a/src/teams/templates/team_tasks.html
+++ b/src/teams/templates/team_tasks.html
@@ -20,7 +20,7 @@ Tasks
Create Task
{% endif %}
-
+
| Name |
diff --git a/src/teams/templatetags/__init__.py b/src/teams/templatetags/__init__.py
index e69de29bb..5787d4d5a 100644
--- a/src/teams/templatetags/__init__.py
+++ b/src/teams/templatetags/__init__.py
@@ -0,0 +1 @@
+"""All template tags for teams."""
diff --git a/src/teams/templatetags/teams_tags.py b/src/teams/templatetags/teams_tags.py
index 1be20bb8b..4ab695062 100644
--- a/src/teams/templatetags/teams_tags.py
+++ b/src/teams/templatetags/teams_tags.py
@@ -1,20 +1,30 @@
+"""Template tags for teams."""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from django import template
from django.utils.safestring import mark_safe
from teams.models import TeamMember
+if TYPE_CHECKING:
+ from django.contrib.auth.models import User
+
+ from teams.models import Team
+
register = template.Library()
@register.filter
-def is_team_member(user, team):
+def is_team_member(user: User, team: Team) -> bool:
+ """Template tag to return team member status."""
return TeamMember.objects.filter(team=team, user=user, approved=True).exists()
@register.simple_tag
-def membershipstatus(user, team, showicon=False):
+def membershipstatus(user: User, team: Team, showicon: bool=False) -> str:
+ """Template tag to return membership status."""
if user in team.leads.all():
text = "Lead"
icon = "fa-star"
@@ -29,5 +39,5 @@ def membershipstatus(user, team, showicon=False):
icon = "fa-times"
if showicon:
- return mark_safe(f"")
+ return mark_safe(f"") # noqa: S308
return text
diff --git a/src/teams/tests/__init__.py b/src/teams/tests/__init__.py
new file mode 100644
index 000000000..e69de29bb
diff --git a/src/teams/tests/test_base_views.py b/src/teams/tests/test_base_views.py
new file mode 100644
index 000000000..b18f84d81
--- /dev/null
+++ b/src/teams/tests/test_base_views.py
@@ -0,0 +1,161 @@
+"""Test cases for the base and member views of the teams application."""
+
+from __future__ import annotations
+
+from bs4 import BeautifulSoup
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+
+from camps.models import Permission as CampPermission
+from teams.models import TeamMember
+from utils.tests import BornhackTestBase
+
+
+class TeamBaseMemberViewTest(BornhackTestBase):
+ """Test Team Base and Member Views."""
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ """Setup test data."""
+ # first add users and other basics
+ super().setUpTestData()
+
+ cls.categories = cls.bootstrap.create_camp_info_categories(camp=cls.camp, teams=cls.teams)
+ cls.bootstrap.create_camp_info_items(camp=cls.camp,categories=cls.categories)
+
+ permission_content_type = ContentType.objects.get_for_model(CampPermission)
+ cls.users[4].user_permissions.add(
+ Permission.objects.get(
+ content_type=permission_content_type,
+ codename="noc_team_lead",
+ ),
+ )
+ def test_team_general_view(self) -> None:
+ """Test the team general view."""
+ url = reverse("teams:general", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ def test_team_list_view(self) -> None:
+ """Test the team list view."""
+ url = reverse("teams:list", kwargs={
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ def test_team_manage_view(self) -> None:
+ """Test the team manage view."""
+ self.client.force_login(self.users[4])
+ url = reverse("teams:manage", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ def test_team_join_leave_view(self) -> None:
+ """Test the team member join and leave view."""
+ self.client.force_login(self.users[0])
+ url = reverse("teams:join", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ response = self.client.post(path=url, follow=True)
+
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-success")
+ matches = [s for s in rows if "You request to join the team" in str(s)]
+ self.assertEqual(len(matches), 1, "failed to join a team.")
+
+ # Try to join the team twice.
+ response = self.client.get(path=url, follow=True)
+
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-warning")
+ matches = [s for s in rows if "You are already a member of this team" in str(s)]
+ self.assertEqual(len(matches), 1, "member was able to join twice.")
+
+ # Try to join a team that does not need members
+ url = reverse("teams:join", kwargs={
+ "team_slug": self.teams["orga"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url, follow=True)
+
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-warning")
+ matches = [s for s in rows if "This team does not need members right now" in str(s)]
+ self.assertEqual(len(matches), 1, "member was able to join a team which does not need members.")
+
+ # Test leaving the team.
+ url = reverse("teams:leave", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ response = self.client.post(path=url, follow=True)
+
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-success")
+ matches = [s for s in rows if "You are no longer a member of the team" in str(s)]
+ self.assertEqual(len(matches), 1, "failed to leave a team.")
+
+ def test_team_approve_remove_views(self) -> None:
+ """Test team member approve and remove views."""
+ self.client.force_login(self.users[8])
+ url = reverse("teams:join", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.post(path=url)
+ assert response.status_code == 302
+
+ member = TeamMember.objects.get(team=self.teams["noc"], user=self.users[8])
+
+ self.client.force_login(self.users[4])
+ # Approve the team member
+ url = reverse("teams:member_approve", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ "pk": member.pk,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ response = self.client.post(path=url, follow=True)
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-success")
+ matches = [s for s in rows if "Team member approved" in str(s)]
+ self.assertEqual(len(matches), 1, "failed to approve a team member.")
+
+
+ # Remove the team member
+ url = reverse("teams:member_remove", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ "pk": member.pk,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ response = self.client.post(path=url, follow=True)
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-success")
+ matches = [s for s in rows if "Team member removed" in str(s)]
+ self.assertEqual(len(matches), 1, "failed to remove a team member.")
diff --git a/src/teams/tests/test_guide_views.py b/src/teams/tests/test_guide_views.py
new file mode 100644
index 000000000..2fb8ffb3a
--- /dev/null
+++ b/src/teams/tests/test_guide_views.py
@@ -0,0 +1,41 @@
+"""Test cases for the Maps application."""
+
+from __future__ import annotations
+
+from django.urls import reverse
+
+from utils.tests import BornhackTestBase
+
+
+class TeamGuideViewTest(BornhackTestBase):
+ """Test Team Guide View"""
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ """Setup test data."""
+ # first add users and other basics
+ super().setUpTestData()
+
+
+ def test_team_guide_views_permission(self) -> None:
+ """Test the team guide view."""
+ self.client.force_login(self.users[0])
+
+ url = reverse("teams:guide", kwargs={"team_slug": self.teams["noc"].slug, "camp_slug": self.camp.slug})
+ response = self.client.get(path=url, follow=True)
+ assert response.status_code == 403
+
+ url = reverse("teams:guide_print", kwargs={"team_slug": self.teams["noc"].slug, "camp_slug": self.camp.slug})
+ response = self.client.get(path=url, follow=True)
+ assert response.status_code == 403
+
+ self.client.force_login(self.users[5])
+
+ url = reverse("teams:guide", kwargs={"team_slug": self.teams["noc"].slug, "camp_slug": self.camp.slug})
+ response = self.client.get(path=url, follow=True)
+ assert response.status_code == 200
+
+ url = reverse("teams:guide_print", kwargs={"team_slug": self.teams["noc"].slug, "camp_slug": self.camp.slug})
+ response = self.client.get(path=url, follow=True)
+ assert response.status_code == 200
+
diff --git a/src/teams/tests/test_info_views.py b/src/teams/tests/test_info_views.py
new file mode 100644
index 000000000..f9f49083c
--- /dev/null
+++ b/src/teams/tests/test_info_views.py
@@ -0,0 +1,117 @@
+"""Test cases for the info views of the teams application."""
+
+from __future__ import annotations
+
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+
+from camps.models import Permission as CampPermission
+from utils.tests import BornhackTestBase
+
+
+class TeamInfoViewTest(BornhackTestBase):
+ """Test Team Info Views."""
+
+ categories: dict
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ """Setup test data."""
+ # first add users and other basics
+ super().setUpTestData()
+
+ cls.categories = cls.bootstrap.create_camp_info_categories(camp=cls.camp, teams=cls.teams)
+ cls.bootstrap.create_camp_info_items(camp=cls.camp,categories=cls.categories)
+
+ permission_content_type = ContentType.objects.get_for_model(CampPermission)
+ cls.users[4].user_permissions.add(
+ Permission.objects.get(
+ content_type=permission_content_type,
+ codename="noc_team_infopager",
+ ),
+ )
+
+ def test_team_info_view_permissions(self) -> None:
+ """Test the team info view permissions."""
+ self.client.force_login(self.users[0]) # Non noc team member
+ # Test access control to the views
+ url = reverse("teams:info_categories", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 403
+
+ def test_team_info_views(self) -> None:
+ """Test the team info views."""
+ self.client.force_login(self.users[4]) # Noc teamlead
+
+ # Test info categories page
+ url = reverse("teams:info_categories", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ # Test info categories create page
+ url = reverse("teams:info_item_create", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ "category_anchor": self.categories["noc"].anchor,
+ })
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "category": self.categories["noc"].anchor,
+ "headline": "Test info page",
+ "body": "Some test info",
+ "anchor": "test",
+ "weight": 100,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ # Test info categories edit page
+ url = reverse("teams:info_item_update", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ "category_anchor": self.categories["noc"].anchor,
+ "item_anchor": "test",
+ })
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "category": self.categories["noc"].anchor,
+ "headline": "Test info page",
+ "body": "Some test info",
+ "anchor": "test",
+ "weight": 101,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ # Test info categories delete page
+ url = reverse("teams:info_item_delete", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ "category_anchor": self.categories["noc"].anchor,
+ "item_anchor": "test",
+ })
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ follow=True,
+ )
+ assert response.status_code == 200
diff --git a/src/teams/tests/test_shift_views.py b/src/teams/tests/test_shift_views.py
new file mode 100644
index 000000000..a7fb212af
--- /dev/null
+++ b/src/teams/tests/test_shift_views.py
@@ -0,0 +1,259 @@
+"""Test cases for the shift views of the teams application."""
+
+from __future__ import annotations
+
+from bs4 import BeautifulSoup
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+from django.utils import timezone
+
+from camps.models import Permission as CampPermission
+from teams.models import TeamShift
+from utils.tests import BornhackTestBase
+
+
+class TeamShiftViewTest(BornhackTestBase):
+ """Test Team Shift View"""
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ """Setup test data."""
+ # first add users and other basics
+ super().setUpTestData()
+ permission_content_type = ContentType.objects.get_for_model(CampPermission)
+ cls.users[4].user_permissions.add(
+ Permission.objects.get(
+ content_type=permission_content_type,
+ codename="noc_team_lead",
+ ),
+ )
+
+ 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
+ # Test access control to the views
+ 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,
+ })
+ 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
+
+ # Test creating a shift
+ 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
+
+ response = self.client.post(
+ path=url,
+ data={
+ "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
+ content = response.content.decode()
+
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("table#main_table > tbody > tr")
+ self.assertEqual(len(rows), 1, "team shift list does not return 1 entries after create")
+
+ # Test same start and end.
+ response = self.client.post(
+ path=url,
+ data={
+ "from_datetime": self.camp.buildup.lower,
+ "to_datetime": self.camp.buildup.lower,
+ "people_required": 1,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("ul.list-unstyled.text-danger > li")
+ matches = [s for s in rows if "Start can not be the same as end." in str(s)]
+ self.assertEqual(len(matches), 1, "team shift Start can not be equal to end")
+
+ # Test same end before start.
+ response = self.client.post(
+ path=url,
+ data={
+ "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
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("ul.list-unstyled.text-danger > li")
+ matches = [s for s in rows if "Start can not be after end." in str(s)]
+ 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,
+ })
+
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "from_datetime": self.camp.camp.lower.date(),
+ "shift_length": 60,
+ "number_of_shifts": 10,
+ "people_required": 5,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("table#main_table > tbody > tr")
+ self.assertEqual(len(rows), 11, "team shift list does not return 11 entries after create multiple")
+
+ # Lookup the id of one of the shifts.
+ shift_row = soup.select_one("table#main_table > tbody > tr:nth-of-type(1) td:nth-of-type(5)")
+ shift_link = shift_row.find("a")
+ 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,
+ })
+ from_datetime = self.camp.buildup.lower
+ to_datetime = from_datetime + timezone.timedelta(hours=2)
+
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "from_datetime": from_datetime,
+ "to_datetime": to_datetime,
+ "people_required": 2,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ 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")
+
+ # Test the delete view
+ 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
+
+ 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("table#main_table > tbody > tr")
+ self.assertEqual(len(rows), 10, "team shift list does not return 10 entries after delete")
+
+ def test_team_shift_actions(self) -> None:
+ """Test the team shift actions."""
+ self.client.force_login(self.users[4]) # Noc teamlead
+
+ team_shift_1 = TeamShift(
+ team=self.teams["noc"],
+ shift_range=(
+ self.camp.buildup.lower,
+ self.camp.buildup.lower + timezone.timedelta(hours=1),
+ ),
+ people_required=1,
+ )
+ team_shift_1.save()
+ team_shift_2 = TeamShift(
+ team=self.teams["noc"],
+ shift_range=(
+ self.camp.buildup.lower,
+ self.camp.buildup.lower + timezone.timedelta(hours=1),
+ ),
+ people_required=1,
+ )
+ 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,
+ })
+ response = self.client.get(
+ 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")
+
+ 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(
+ path=url,
+ follow=True,
+ )
+ assert response.status_code == 200
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-danger")
+ 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,
+ })
+
+ response = self.client.get(
+ 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 "Assign me" in str(s)]
+ self.assertEqual(len(matches), 1, "team shift unassign failed")
diff --git a/src/teams/tests/test_task_views.py b/src/teams/tests/test_task_views.py
new file mode 100644
index 000000000..80ca34690
--- /dev/null
+++ b/src/teams/tests/test_task_views.py
@@ -0,0 +1,137 @@
+"""Test cases for the task views of the teams application."""
+
+from __future__ import annotations
+
+from bs4 import BeautifulSoup
+from django.contrib.auth.models import Permission
+from django.contrib.contenttypes.models import ContentType
+from django.urls import reverse
+
+from camps.models import Permission as CampPermission
+from utils.tests import BornhackTestBase
+
+
+class TeamTaskViewTest(BornhackTestBase):
+ """Test Team Task View"""
+
+ @classmethod
+ def setUpTestData(cls) -> None:
+ """Setup test data."""
+ # first add users and other basics
+ super().setUpTestData()
+ permission_content_type = ContentType.objects.get_for_model(CampPermission)
+ cls.users[4].user_permissions.add(
+ Permission.objects.get(
+ content_type=permission_content_type,
+ codename="noc_team_tasker",
+ ),
+ )
+
+ def test_team_task_view_permissions(self) -> None:
+ """Test the team shift view permissions."""
+ self.client.force_login(self.users[0]) # Non noc team member
+ # Test access control to the views
+ url = reverse("teams:task_create", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 403
+
+ def test_team_task_views(self) -> None:
+ """Test the team task views."""
+ self.client.force_login(self.users[4]) # Noc team tasker
+ url = reverse("teams:tasks", kwargs={
+ "camp_slug": self.camp.slug,
+ "team_slug": self.teams["noc"].slug,
+ })
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ # Test creating a shift
+ url = reverse("teams:task_create", kwargs={
+ "team_slug": self.teams["noc"].slug,
+ "camp_slug": self.camp.slug,
+ })
+ response = self.client.get(url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "name": "Test task",
+ "description": "Test task description",
+ "when_0": self.camp.buildup.lower,
+ "when_1": self.camp.buildup.upper,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ task = response.context["task"]
+
+ # Test if the task got to the list.
+ url = reverse("teams:tasks", kwargs={
+ "camp_slug": self.camp.slug,
+ "team_slug": self.teams["noc"].slug,
+ })
+ response = self.client.get(path=url)
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("table#main_table > tbody > tr")
+ self.assertEqual(len(rows), 1, "team task list does not return 1 entries after create")
+
+ # Test updating a task.
+ url = reverse("teams:task_update", kwargs={
+ "camp_slug": self.camp.slug,
+ "team_slug": self.teams["noc"].slug,
+ "slug": task.slug,
+ })
+
+ response = self.client.get(path=url)
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "name": "Test task",
+ "description": "Test updating a task description",
+ "when_0": self.camp.buildup.lower,
+ "when_1": self.camp.buildup.upper,
+ "completed": True,
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ # Test submitting a comment
+ url = reverse("teams:task_detail", kwargs={
+ "camp_slug": self.camp.slug,
+ "team_slug": self.teams["noc"].slug,
+ "slug": task.slug,
+ })
+
+ response = self.client.post(
+ path=url,
+ data={
+ "comment": "Some test comment",
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ response = self.client.post(
+ path=url,
+ data={
+ "comment": "",
+ },
+ follow=True,
+ )
+ assert response.status_code == 200
+
+ # Test if the page returned a failure
+ content = response.content.decode()
+ soup = BeautifulSoup(content, "html.parser")
+ rows = soup.select("div.alert.alert-danger")
+ matches = [s for s in rows if "Something went wrong." in str(s)]
+ self.assertEqual(len(matches), 1, "comment does not return a error msg.")
diff --git a/src/teams/urls.py b/src/teams/urls.py
index b4040b583..39deab476 100644
--- a/src/teams/urls.py
+++ b/src/teams/urls.py
@@ -1,3 +1,4 @@
+"""Django URLs for application Teams."""
from __future__ import annotations
from django.urls import include
diff --git a/src/teams/utils.py b/src/teams/utils.py
index 6013f52ee..15b45a278 100644
--- a/src/teams/utils.py
+++ b/src/teams/utils.py
@@ -1,9 +1,10 @@
+"""Utils for the teams application."""
from __future__ import annotations
from .models import Team
-def get_team_from_irc_channel(channel):
+def get_team_from_irc_channel(channel: str) -> Team|bool:
"""Returns a Team object given an IRC channel name, if possible."""
if not channel:
return False
diff --git a/src/teams/views/__init__.py b/src/teams/views/__init__.py
index e69de29bb..ac0945129 100644
--- a/src/teams/views/__init__.py
+++ b/src/teams/views/__init__.py
@@ -0,0 +1 @@
+"""All views for application Teams."""
diff --git a/src/teams/views/base.py b/src/teams/views/base.py
index af4b735a8..6f6adc7cd 100644
--- a/src/teams/views/base.py
+++ b/src/teams/views/base.py
@@ -1,6 +1,8 @@
+"""Base view for teams."""
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
from django.conf import settings
from django.contrib import messages
@@ -14,28 +16,39 @@
from camps.mixins import CampViewMixin
from teams.models import Team
from teams.models import TeamMember
+from utils.mixins import IsTeamPermContextMixin
from utils.widgets import MarkdownWidget
from .mixins import EnsureTeamLeadMixin
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+ from django.forms import Form
+ from django.http import HttpRequest
+ from django.http import HttpResponse
+ from django.http import HttpResponseRedirect
+
logger = logging.getLogger(f"bornhack.{__name__}")
class TeamListView(CampViewMixin, ListView):
+ """View for the list of teams."""
template_name = "team_list.html"
model = Team
context_object_name = "teams"
- def get_queryset(self, *args, **kwargs):
+ def get_queryset(self, *args, **kwargs) -> QuerySet:
+ """Method for prefetching team members."""
qs = super().get_queryset(*args, **kwargs)
qs = qs.prefetch_related("members")
return qs.prefetch_related("members__profile")
- # FIXME: there is more to be gained here but the templatetag we use to see if
+ # TODO(tyk): there is more to be gained here but the templatetag we use to see if
# the logged-in user is a member of the current team does not benefit from the prefetching,
# also the getting of team leads and their profiles do not use the prefetching
# :( /tyk
- def get_context_data(self, *, object_list=None, **kwargs):
+ def get_context_data(self, *, object_list: list|None =None, **kwargs) -> dict:
+ """Method for adding user_teams to the context."""
context = super().get_context_data(object_list=object_list, **kwargs)
if self.request.user.is_authenticated:
context["user_teams"] = self.request.user.teammember_set.filter(
@@ -44,24 +57,27 @@ def get_context_data(self, *, object_list=None, **kwargs):
return context
-class TeamGeneralView(CampViewMixin, DetailView):
+class TeamGeneralView(CampViewMixin, IsTeamPermContextMixin, DetailView):
+ """General view for a team."""
template_name = "team_general.html"
context_object_name = "team"
model = Team
slug_url_kwarg = "team_slug"
active_menu = "general"
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding ircbot info to context."""
context = super().get_context_data(**kwargs)
context["IRCBOT_SERVER_HOSTNAME"] = settings.IRCBOT_SERVER_HOSTNAME
context["IRCBOT_PUBLIC_CHANNEL"] = settings.IRCBOT_PUBLIC_CHANNEL
return context
-class TeamManageView(CampViewMixin, EnsureTeamLeadMixin, UpdateView):
+class TeamManageView(CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, UpdateView):
+ """View for mananaging team members."""
model = Team
template_name = "team_manage.html"
- fields = [
+ fields = (
"description",
"needs_members",
"public_irc_channel_name",
@@ -73,32 +89,37 @@ class TeamManageView(CampViewMixin, EnsureTeamLeadMixin, UpdateView):
"public_signal_channel_link",
"private_signal_channel_link",
"guide",
- ]
+ )
slug_url_kwarg = "team_slug"
- def get_form(self, *args, **kwargs):
+ def get_form(self, *args, **kwargs) -> Form:
+ """Method for updating form widgets."""
form = super().get_form(*args, **kwargs)
form.fields["guide"].widget = MarkdownWidget()
return form
- def get_success_url(self):
+ def get_success_url(self) -> str:
+ """Method for returning the success url."""
return reverse_lazy(
"teams:general",
kwargs={"camp_slug": self.camp.slug, "team_slug": self.get_object().slug},
)
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method for sending success message if form is valid."""
messages.success(self.request, "Team has been saved")
return super().form_valid(form)
-class FixIrcAclView(LoginRequiredMixin, CampViewMixin, UpdateView):
+class FixIrcAclView(LoginRequiredMixin, CampViewMixin, IsTeamPermContextMixin, UpdateView):
+ """View for fixing IRC ACL's."""
template_name = "fix_irc_acl.html"
model = Team
- fields = []
+ fields = ()
slug_url_kwarg = "team_slug"
- def dispatch(self, request, *args, **kwargs):
+ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Method dispatch."""
# we need to call the super().dispatch() method early so self.camp gets populated by CampViewMixin,
# because the lookups below depend on self.camp being set :)
response = super().dispatch(request, *args, **kwargs)
@@ -128,7 +149,8 @@ def dispatch(self, request, *args, **kwargs):
if not request.user.profile.nickserv_username:
messages.error(
request,
- "Please go to your profile and set your NickServ username first. Make sure the account is registered with NickServ first!",
+ "Please go to your profile and set your NickServ username first. "
+ "Make sure the account is registered with NickServ first!",
)
return redirect(
"teams:general",
@@ -138,8 +160,8 @@ def dispatch(self, request, *args, **kwargs):
return response
- def get(self, request, *args, **kwargs):
- # get membership
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Get membership."""
try:
TeamMember.objects.get(
user=request.user,
@@ -148,10 +170,10 @@ def get(self, request, *args, **kwargs):
irc_channel_acl_ok=True,
)
except TeamMember.DoesNotExist:
- # this membership is already marked as membership.irc_channel_acl_ok=False, no need to do anything
messages.error(
request,
- "No need, this membership is already marked as irc_channel_acl_ok=False, so the bot will fix the ACL soon",
+ "No need, this membership is already marked as irc_channel_acl_ok=False, "
+ "so the bot will fix the ACL soon",
)
return redirect(
"teams:general",
@@ -161,7 +183,8 @@ def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method for adding membership and returning a message."""
membership = TeamMember.objects.get(
user=self.request.user,
team=self.get_object(),
@@ -173,7 +196,8 @@ def form_valid(self, form):
membership.save()
messages.success(
self.request,
- f"OK, hang on while we fix the permissions for your NickServ user '{self.request.user.profile.nickserv_username}' for IRC channel '{form.instance.irc_channel_name}'",
+ f"OK, hang on while we fix the permissions for your NickServ user "
+ f"'{self.request.user.profile.nickserv_username}' for IRC channel '{form.instance.irc_channel_name}'",
)
return redirect(
"teams:general",
diff --git a/src/teams/views/guide.py b/src/teams/views/guide.py
index 4a41d3afc..e2f510a09 100644
--- a/src/teams/views/guide.py
+++ b/src/teams/views/guide.py
@@ -1,3 +1,4 @@
+"""Views for the guide of the teams application."""
from __future__ import annotations
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -7,9 +8,11 @@
from camps.mixins import CampViewMixin
from teams.models import Team
from teams.models import TeamMember
+from utils.mixins import IsTeamPermContextMixin
-class TeamGuideView(LoginRequiredMixin, CampViewMixin, UserPassesTestMixin, DetailView):
+class TeamGuideView(LoginRequiredMixin, CampViewMixin, UserPassesTestMixin, IsTeamPermContextMixin, DetailView):
+ """View for the team guide."""
template_name = "team_guide.html"
context_object_name = "team"
model = Team
@@ -17,7 +20,7 @@ class TeamGuideView(LoginRequiredMixin, CampViewMixin, UserPassesTestMixin, Deta
active_menu = "guide"
def test_func(self) -> bool:
- # Make sure that the user is an approved member of the team
+ """Method to test if the user is approved for this team."""
try:
TeamMember.objects.get(
user=self.request.user,
@@ -31,4 +34,8 @@ def test_func(self) -> bool:
class TeamGuidePrintView(TeamGuideView):
+ """View for printing the team guide.
+
+ Includes permissions from TeamGuideView
+ """
template_name = "team_guide_print.html"
diff --git a/src/teams/views/info.py b/src/teams/views/info.py
index 42e9819cc..06ee4153b 100644
--- a/src/teams/views/info.py
+++ b/src/teams/views/info.py
@@ -1,5 +1,8 @@
+"""View for managing the team info pages."""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from django.http import HttpResponseRedirect
from django.views.generic import CreateView
from django.views.generic import DeleteView
@@ -10,16 +13,21 @@
from info.models import InfoCategory
from info.models import InfoItem
from teams.views.mixins import TeamInfopagerPermissionMixin
+from utils.mixins import IsTeamPermContextMixin
from utils.widgets import MarkdownWidget
from .mixins import TeamViewMixin
+if TYPE_CHECKING:
+ from django.forms import Form
class InfoCategoriesListView(
TeamViewMixin,
TeamInfopagerPermissionMixin,
+ IsTeamPermContextMixin,
ListView,
):
+ """Info Categories list view."""
model = InfoCategory
template_name = "team_info_categories.html"
slug_field = "anchor"
@@ -29,20 +37,24 @@ class InfoCategoriesListView(
class InfoItemCreateView(
TeamViewMixin,
TeamInfopagerPermissionMixin,
+ IsTeamPermContextMixin,
CreateView,
):
+ """Info item create view."""
model = InfoItem
template_name = "team_info_item_form.html"
- fields = ["headline", "body", "anchor", "weight"]
+ fields = ("headline", "body", "anchor", "weight")
slug_field = "anchor"
active_menu = "info_categories"
- def get_form(self, *args, **kwargs):
+ def get_form(self, *args, **kwargs) -> Form:
+ """Method to update widget for body form element."""
form = super().get_form(*args, **kwargs)
form.fields["body"].widget = MarkdownWidget()
return form
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method for updating the category field by the category_anchor."""
info_item = form.save(commit=False)
category = InfoCategory.objects.get(
team__camp=self.camp,
@@ -52,10 +64,12 @@ def form_valid(self, form):
info_item.save()
return HttpResponseRedirect(self.get_success_url())
- def get_success_url(self):
+ def get_success_url(self) -> str:
+ """Method for creating the success/redirect url."""
return self.team.get_absolute_url()
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding category to the context."""
context = super().get_context_data(**kwargs)
context["category"] = InfoCategory.objects.get(
team__camp__slug=self.kwargs["camp_slug"],
@@ -68,24 +82,28 @@ class InfoItemUpdateView(
TeamViewMixin,
TeamInfopagerPermissionMixin,
RevisionMixin,
+ IsTeamPermContextMixin,
UpdateView,
):
+ """Info item update view."""
model = InfoItem
template_name = "team_info_item_form.html"
- fields = ["headline", "body", "anchor", "weight"]
+ fields = ("headline", "body", "anchor", "weight")
slug_field = "anchor"
slug_url_kwarg = "item_anchor"
active_menu = "info_categories"
- def get_form(self, *args, **kwargs):
+ def get_form(self, *args, **kwargs) -> Form:
+ """Method to update widget for body form element."""
form = super().get_form(*args, **kwargs)
form.fields["body"].widget = MarkdownWidget()
return form
- def get_success_url(self):
- next = self.request.GET.get("next")
- if next:
- return next
+ def get_success_url(self) -> str:
+ """Method for creating the success/redirect url."""
+ next_url = self.request.GET.get("next")
+ if next_url:
+ return next_url
return self.team.get_absolute_url()
@@ -93,16 +111,19 @@ class InfoItemDeleteView(
TeamViewMixin,
TeamInfopagerPermissionMixin,
RevisionMixin,
+ IsTeamPermContextMixin,
DeleteView,
):
+ """View for deleting a info item."""
model = InfoItem
template_name = "team_info_item_delete_confirm.html"
slug_field = "anchor"
slug_url_kwarg = "item_anchor"
active_menu = "info_categories"
- def get_success_url(self):
- next = self.request.GET.get("next")
- if next:
- return next
+ def get_success_url(self) -> str:
+ """Method for creating the success/redirect url."""
+ next_url = self.request.GET.get("next")
+ if next_url:
+ return next_url
return self.team.get_absolute_url()
diff --git a/src/teams/views/members.py b/src/teams/views/members.py
index e5e5573b6..a2b3993e3 100644
--- a/src/teams/views/members.py
+++ b/src/teams/views/members.py
@@ -1,6 +1,8 @@
+"""Views for team members."""
from __future__ import annotations
import logging
+from typing import TYPE_CHECKING
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -14,14 +16,22 @@
from teams.email import add_removed_membership_email
from teams.models import Team
from teams.models import TeamMember
+from utils.mixins import IsTeamPermContextMixin
from .mixins import EnsureTeamMemberLeadMixin
from .mixins import TeamViewMixin
+if TYPE_CHECKING:
+ from django.forms import Form
+ from django.http import HttpRequest
+ from django.http import HttpResponse
+ from django.http import HttpResponseRedirect
+
logger = logging.getLogger(f"bornhack.{__name__}")
-class TeamMembersView(CampViewMixin, DetailView):
+class TeamMembersView(CampViewMixin, IsTeamPermContextMixin, DetailView):
+ """List view for team members."""
template_name = "team_members.html"
context_object_name = "team"
model = Team
@@ -30,13 +40,15 @@ class TeamMembersView(CampViewMixin, DetailView):
class TeamJoinView(LoginRequiredMixin, CampViewMixin, UpdateView):
+ """View displayed when joining a team."""
template_name = "team_join.html"
model = Team
- fields = []
+ fields = ()
slug_url_kwarg = "team_slug"
active_menu = "members"
- def get(self, request, *args, **kwargs):
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Get method view."""
if not Profile.objects.get(user=request.user).description:
messages.warning(
request,
@@ -54,7 +66,8 @@ def get(self, request, *args, **kwargs):
return super().get(request, *args, **kwargs)
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method to create team member and show message."""
TeamMember.objects.create(team=self.get_object(), user=self.request.user)
messages.success(
self.request,
@@ -64,20 +77,23 @@ def form_valid(self, form):
class TeamLeaveView(LoginRequiredMixin, CampViewMixin, UpdateView):
+ """View for leaving a team."""
template_name = "team_leave.html"
model = Team
- fields = []
+ fields = ()
slug_url_kwarg = "team_slug"
active_menu = "members"
- def get(self, request, *args, **kwargs):
+ def get(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Get method for leaving a team."""
if request.user not in self.get_object().members.all():
messages.warning(request, "You are not a member of this team")
return redirect("teams:list", camp_slug=self.get_object().camp.slug)
return super().get(request, *args, **kwargs)
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method deletes team member."""
TeamMember.objects.filter(
team=self.get_object(),
user=self.request.user,
@@ -95,12 +111,14 @@ class TeamMemberRemoveView(
EnsureTeamMemberLeadMixin,
UpdateView,
):
+ """View for removing a team member."""
template_name = "teammember_remove.html"
model = TeamMember
- fields = []
+ fields = ()
active_menu = "members"
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method to delete instance and show message.."""
form.instance.delete()
if add_removed_membership_email(form.instance):
messages.success(self.request, "Team member removed")
@@ -125,12 +143,14 @@ class TeamMemberApproveView(
EnsureTeamMemberLeadMixin,
UpdateView,
):
+ """View to approve team member."""
template_name = "teammember_approve.html"
model = TeamMember
- fields = []
+ fields = ()
active_menu = "members"
- def form_valid(self, form):
+ def form_valid(self, form: Form) -> HttpResponseRedirect:
+ """Method to set approve true and show message.."""
form.instance.approved = True
form.instance.save()
if add_added_membership_email(form.instance):
diff --git a/src/teams/views/mixins.py b/src/teams/views/mixins.py
index 0babbb86d..2ffc3388f 100644
--- a/src/teams/views/mixins.py
+++ b/src/teams/views/mixins.py
@@ -1,5 +1,8 @@
+"""Mixins for the teams application views."""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from django.contrib import messages
from django.shortcuts import redirect
from django.views.generic.detail import SingleObjectMixin
@@ -9,11 +12,15 @@
from teams.models import TeamMember
from utils.mixins import RaisePermissionRequiredMixin
+if TYPE_CHECKING:
+ from django.http import HttpRequest
+ from django.http import HttpResponse
class EnsureTeamLeadMixin:
"""Use to make sure request.user has team lead permission for the team specified by kwargs['team_slug']."""
- def dispatch(self, request, *args, **kwargs):
+ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Method to make sure request.user has team lead permission for the team specified by kwargs['team_slug']."""
self.team = Team.objects.get(slug=kwargs["team_slug"], camp=self.camp)
if self.team.lead_permission_set not in request.user.get_all_permissions():
messages.error(request, "No thanks")
@@ -31,7 +38,8 @@ class EnsureTeamMemberLeadMixin(SingleObjectMixin):
model = TeamMember
- def dispatch(self, request, *args, **kwargs):
+ def dispatch(self, request: HttpRequest, *args, **kwargs) -> HttpResponse:
+ """Method to make sure request.user has team lead permission for the team which TeamMember belongs to."""
if self.get_object().team.lead_permission_set not in request.user.get_all_permissions():
messages.error(request, "No thanks")
return redirect(
@@ -44,11 +52,14 @@ def dispatch(self, request, *args, **kwargs):
class TeamViewMixin(CampViewMixin):
+ """View mixin for all Team views."""
def setup(self, *args, **kwargs) -> None:
+ """Method for setting team object."""
super().setup(*args, **kwargs)
self.team = Team.objects.get(slug=kwargs["team_slug"], camp=self.camp)
- def get_context_data(self, *args, **kwargs):
+ def get_context_data(self, *args, **kwargs) -> dict:
+ """Method for setting team object in the context."""
context = super().get_context_data(**kwargs)
context["team"] = self.team
return context
@@ -57,12 +68,14 @@ def get_context_data(self, *args, **kwargs):
class TeamInfopagerPermissionMixin(RaisePermissionRequiredMixin):
"""Permission mixin for views restricted to users with infopager permission for self.team."""
- def get_permission_required(self):
+ def get_permission_required(self) -> list:
+ """Method to restricted to users with infopager permission for self.team."""
return [self.team.infopager_permission_set]
class TeamTaskerPermissionMixin(RaisePermissionRequiredMixin):
"""Permission mixin for views restricted to users with tasker permission for self.team."""
- def get_permission_required(self):
+ def get_permission_required(self) -> list:
+ """Method to restricted to users with tasker permission for self.team."""
return [self.team.tasker_permission_set]
diff --git a/src/teams/views/shifts.py b/src/teams/views/shifts.py
index acfe0af69..5a361d8ae 100644
--- a/src/teams/views/shifts.py
+++ b/src/teams/views/shifts.py
@@ -1,5 +1,8 @@
+"""View for shifts in teams application."""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from django import forms
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -18,22 +21,37 @@
from psycopg2.extras import DateTimeTZRange
from camps.mixins import CampViewMixin
+from teams.exceptions import StartAfterEndError
+from teams.exceptions import StartSameAsEndError
from teams.models import Team
from teams.models import TeamMember
from teams.models import TeamShift
+from utils.mixins import IsTeamPermContextMixin
+
+from .mixins import EnsureTeamLeadMixin
+
+if TYPE_CHECKING:
+ from django.db.models import QuerySet
+ from django.http import HttpRequest
+ from django.http import HttpResponse
+
+ from camps.models import Camp
-class ShiftListView(LoginRequiredMixin, CampViewMixin, ListView):
+class ShiftListView(LoginRequiredMixin, CampViewMixin, IsTeamPermContextMixin, ListView):
+ """Shift list view."""
model = TeamShift
template_name = "team_shift_list.html"
context_object_name = "shifts"
active_menu = "shifts"
- def get_queryset(self):
+ def get_queryset(self) -> QuerySet:
+ """Method to filter by team slug."""
queryset = super().get_queryset()
return queryset.filter(team__slug=self.kwargs["team_slug"])
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for setting team to context."""
context = super().get_context_data(**kwargs)
context["team"] = Team.objects.get(
camp=self.camp,
@@ -42,17 +60,19 @@ def get_context_data(self, **kwargs):
return context
-def date_choices(camp):
+def date_choices(camp: Camp) -> list:
+ """Method for making date/time choices."""
index = 0
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:
- minutes = int(index * SHIFT_MINIMUM_LENGTH)
+ shift_minimum_length = 60
+ while index * shift_minimum_length < 60: # noqa: PLR2004
+ minutes = int(index * shift_minimum_length)
minute_choices.append(minutes)
index += 1
- def get_time_choices(date):
+ def get_time_choices(date: str) -> list:
+ """Method for making a list of time options."""
time_choices = []
for hour in range(24):
for minute in minute_choices:
@@ -73,11 +93,14 @@ def get_time_choices(date):
class ShiftForm(forms.ModelForm):
+ """Form for shifts."""
class Meta:
+ """Meta."""
model = TeamShift
- fields = ["from_datetime", "to_datetime", "people_required"]
+ fields = ("from_datetime", "to_datetime", "people_required")
- def __init__(self, instance=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)
self.fields["from_datetime"].widget = forms.Select(choices=date_choices(camp))
@@ -96,46 +119,57 @@ def __init__(self, instance=None, **kwargs) -> None:
from_datetime = forms.DateTimeField()
to_datetime = forms.DateTimeField()
- def _get_from_datetime(self):
+ def _get_from_datetime(self) -> dict:
+ """Method to convert from_datetime to current timezone."""
current_timezone = timezone.get_current_timezone()
return self.cleaned_data["from_datetime"].astimezone(current_timezone)
- def _get_to_datetime(self):
+ def _get_to_datetime(self) -> dict:
+ """Method to convert to_datetime to current timezone."""
current_timezone = timezone.get_current_timezone()
return self.cleaned_data["to_datetime"].astimezone(current_timezone)
def clean(self) -> None:
+ """Method for cleaning the form data.
+
+ Check lower is bigger then upper
+ Check lower and upper are not the same
+ """
self.lower = self._get_from_datetime()
self.upper = self._get_to_datetime()
if self.lower > self.upper:
- raise forms.ValidationError("Start can not be after end.")
+ raise StartAfterEndError
+ if self.lower == self.upper:
+ raise StartSameAsEndError
- def save(self, commit=True):
+ def save(self, commit=True) -> TeamShift:
+ """Method for saving shift_range from self.lower and self.upper."""
# self has .lower and .upper from .clean()
self.instance.shift_range = DateTimeTZRange(self.lower, self.upper)
return super().save(commit=commit)
-class ShiftCreateView(LoginRequiredMixin, CampViewMixin, CreateView):
+class ShiftCreateView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, CreateView):
+ """View for creating a single shift."""
model = TeamShift
template_name = "team_shift_form.html"
form_class = ShiftForm
active_menu = "shifts"
- def get_form_kwargs(self):
+ def get_form_kwargs(self) -> dict:
+ """Method for adding camp to kwargs."""
kwargs = super().get_form_kwargs()
kwargs["camp"] = self.camp
return kwargs
- def form_valid(self, form):
+ def form_valid(self, form: ShiftForm) -> HttpResponse:
+ """Check if the form is valid add team to the data."""
shift = form.save(commit=False)
shift.team = Team.objects.get(camp=self.camp, slug=self.kwargs["team_slug"])
return super().form_valid(form)
- def get_success_url(self):
- return reverse("teams:shifts", kwargs=self.kwargs)
-
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding camp and team slug to context."""
context = super().get_context_data(**kwargs)
context["team"] = Team.objects.get(
camp=self.camp,
@@ -143,34 +177,44 @@ def get_context_data(self, **kwargs):
)
return context
+ def get_success_url(self) -> str:
+ """Method get success url."""
+ return reverse("teams:shifts", kwargs=self.kwargs)
-class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, UpdateView):
+
+class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, UpdateView):
+ """View for updating a single shift."""
model = TeamShift
template_name = "team_shift_form.html"
form_class = ShiftForm
active_menu = "shifts"
- def get_form_kwargs(self):
+ def get_form_kwargs(self) -> dict:
+ """Method for adding camp to kwargs."""
kwargs = super().get_form_kwargs()
kwargs["camp"] = self.camp
return kwargs
- def get_success_url(self):
- self.kwargs.pop("pk")
- return reverse("teams:shifts", kwargs=self.kwargs)
-
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding team to context."""
context = super().get_context_data(**kwargs)
context["team"] = self.object.team
return context
+ def get_success_url(self) -> str:
+ """Method get success url."""
+ self.kwargs.pop("pk")
+ return reverse("teams:shifts", kwargs=self.kwargs)
+
-class ShiftDeleteView(LoginRequiredMixin, CampViewMixin, DeleteView):
+class ShiftDeleteView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, DeleteView):
+ """View for deleting a shift."""
model = TeamShift
template_name = "team_shift_confirm_delete.html"
active_menu = "shifts"
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding camp and team slug to context."""
context = super().get_context_data(**kwargs)
context["team"] = Team.objects.get(
camp=self.camp,
@@ -178,13 +222,16 @@ def get_context_data(self, **kwargs):
)
return context
- def get_success_url(self):
+ def get_success_url(self) -> str:
+ """Method get success url."""
self.kwargs.pop("pk")
return reverse("teams:shifts", kwargs=self.kwargs)
class MultipleShiftForm(forms.Form):
- def __init__(self, instance=None, **kwargs) -> None:
+ """Form for creating multple shifts."""
+ def __init__(self, instance: dict|None=None, **kwargs) -> None:
+ """Method for form init setting camp to kwargs."""
camp = kwargs.pop("camp")
super().__init__(**kwargs)
self.fields["from_datetime"].widget = forms.Select(choices=date_choices(camp))
@@ -200,17 +247,20 @@ def __init__(self, instance=None, **kwargs) -> None:
people_required = forms.IntegerField()
-class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, FormView):
+class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, EnsureTeamLeadMixin, IsTeamPermContextMixin, FormView):
+ """View for creating multiple shifts."""
template_name = "team_shift_form.html"
form_class = MultipleShiftForm
active_menu = "shifts"
- def get_form_kwargs(self):
+ def get_form_kwargs(self) -> dict:
+ """Method for setting camp to the kwargs."""
kwargs = super().get_form_kwargs()
kwargs["camp"] = self.camp
return kwargs
- def form_valid(self, form):
+ def form_valid(self, form: MultipleShiftForm) -> HttpResponse:
+ """Method for checking if the form data is valid."""
team = Team.objects.get(camp=self.camp, slug=self.kwargs["team_slug"])
current_timezone = timezone.get_current_timezone()
@@ -238,10 +288,12 @@ def form_valid(self, form):
return super().form_valid(form)
- def get_success_url(self):
+ def get_success_url(self) -> str:
+ """Method for returning the success url."""
return reverse("teams:shifts", kwargs=self.kwargs)
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding team to the context."""
context = super().get_context_data(**kwargs)
context["team"] = Team.objects.get(
camp=self.camp,
@@ -251,9 +303,11 @@ def get_context_data(self, **kwargs):
class MemberTakesShift(LoginRequiredMixin, CampViewMixin, View):
- http_methods = ["get"]
+ """View for adding a user to a shift."""
+ http_methods = ("get",)
- def get(self, request, **kwargs):
+ def get(self, request: HttpRequest, **kwargs) -> 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"])
@@ -287,9 +341,11 @@ def get(self, request, **kwargs):
class MemberDropsShift(LoginRequiredMixin, CampViewMixin, View):
- http_methods = ["get"]
+ """View for remove a user from a shift."""
+ http_methods = ("get",)
- def get(self, request, **kwargs):
+ def get(self, request: HttpRequest, **kwargs) -> 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"])
@@ -303,9 +359,11 @@ def get(self, request, **kwargs):
class UserShifts(CampViewMixin, TemplateView):
+ """View for showing shifts for current user."""
template_name = "team_user_shifts.html"
- def get_context_data(self, **kwargs):
+ def get_context_data(self, **kwargs) -> dict:
+ """Method for adding user_teams and user_shits to context."""
context = super().get_context_data(**kwargs)
context["user_teams"] = self.request.user.teammember_set.filter(
team__camp=self.camp,
diff --git a/src/teams/views/tasks.py b/src/teams/views/tasks.py
index afd938000..ffe960275 100644
--- a/src/teams/views/tasks.py
+++ b/src/teams/views/tasks.py
@@ -1,5 +1,8 @@
+"""All views for the teams task application."""
from __future__ import annotations
+from typing import TYPE_CHECKING
+
from django import forms
from django.contrib import messages
from django.contrib.auth.mixins import LoginRequiredMixin
@@ -14,12 +17,16 @@
from teams.models import Team
from teams.models import TeamMember
from teams.models import TeamTask
+from utils.mixins import IsTeamPermContextMixin
from .mixins import TeamTaskerPermissionMixin
from .mixins import TeamViewMixin
+if TYPE_CHECKING:
+ from django.http import HttpRequest
-class TeamTasksView(CampViewMixin, DetailView):
+class TeamTasksView(CampViewMixin, IsTeamPermContextMixin, DetailView):
+ """List view of the team tasks."""
template_name = "team_tasks.html"
context_object_name = "team"
model = Team
@@ -28,23 +35,28 @@ class TeamTasksView(CampViewMixin, DetailView):
class TaskCommentForm(forms.ModelForm):
+ """Form for commenting on a Task."""
class Meta:
+ """Meta."""
model = TaskComment
- fields = ["comment"]
+ fields = ("comment",)
-class TaskDetailView(TeamViewMixin, DetailView):
+class TaskDetailView(TeamViewMixin, IsTeamPermContextMixin, DetailView):
+ """Task detail view."""
template_name = "task_detail.html"
context_object_name = "task"
model = TeamTask
active_menu = "tasks"
- def get_context_data(self, *args, **kwargs):
+ def get_context_data(self, *args, **kwargs) -> dict:
+ """Add the inline form for comments."""
context = super().get_context_data(*args, **kwargs)
context["comment_form"] = TaskCommentForm()
return context
- def post(self, request, **kwargs):
+ def post(self, request: HttpRequest, **kwargs) -> HttpResponseRedirect:
+ """Post endpoint for the comment form."""
task = self.get_object()
if request.user not in task.team.members.all():
return HttpResponseNotAllowed("Nope")
@@ -62,11 +74,14 @@ def post(self, request, **kwargs):
class TaskForm(forms.ModelForm):
+ """Form for creating or edditing Tasks."""
class Meta:
+ """Meta."""
model = TeamTask
- fields = ["name", "description", "when", "completed"]
+ fields = ("name", "description", "when", "completed")
def __init__(self, **kwargs) -> None:
+ """Method for setting up the form fields."""
super().__init__(**kwargs)
self.fields["when"].widget.widgets = [
forms.DateTimeInput(attrs={"placeholder": "Start"}),
@@ -78,20 +93,24 @@ class TaskCreateView(
LoginRequiredMixin,
TeamViewMixin,
TeamTaskerPermissionMixin,
+ IsTeamPermContextMixin,
CreateView,
):
+ """View for creating a team task."""
model = TeamTask
template_name = "task_form.html"
form_class = TaskForm
active_menu = "tasks"
- def get_team(self):
+ def get_team(self) -> Team:
+ """Method to get the team object."""
return Team.objects.get(
camp__slug=self.kwargs["camp_slug"],
slug=self.kwargs["team_slug"],
)
- def form_valid(self, form):
+ def form_valid(self, form: TaskForm) -> HttpResponseRedirect:
+ """Method to set extra fields on save."""
task = form.save(commit=False)
task.team = self.team
if not task.name:
@@ -99,7 +118,8 @@ def form_valid(self, form):
task.save()
return HttpResponseRedirect(task.get_absolute_url())
- def get_success_url(self):
+ def get_success_url(self) -> str:
+ """Method to get the success url."""
return self.get_object().get_absolute_url()
@@ -107,19 +127,23 @@ class TaskUpdateView(
LoginRequiredMixin,
TeamViewMixin,
TeamTaskerPermissionMixin,
+ IsTeamPermContextMixin,
UpdateView,
):
+ """Update task view used for updating tasks."""
model = TeamTask
template_name = "task_form.html"
form_class = TaskForm
active_menu = "tasks"
- def get_context_data(self, *args, **kwargs):
+ def get_context_data(self, *args, **kwargs) -> dict:
+ """Method for adding context data for team."""
context = super().get_context_data(**kwargs)
context["team"] = self.team
return context
- def form_valid(self, form):
+ def form_valid(self, form: TaskForm) -> HttpResponseRedirect:
+ """Method to set extra fields on save."""
task = form.save(commit=False)
task.team = self.team
if not task.name:
@@ -127,5 +151,6 @@ def form_valid(self, form):
task.save()
return HttpResponseRedirect(task.get_absolute_url())
- def get_success_url(self):
+ def get_success_url(self) -> str:
+ """Method to get the success url."""
return self.get_object().get_absolute_url()
diff --git a/src/utils/bootstrap/base.py b/src/utils/bootstrap/base.py
index 0dd3756fb..1ef43a35d 100644
--- a/src/utils/bootstrap/base.py
+++ b/src/utils/bootstrap/base.py
@@ -1469,6 +1469,7 @@ def create_camp_team_memberships(self, camp: Camp, teams: dict, users: dict) ->
approved=True,
lead=True,
)
+ memberships["noc"]["user4"].save()
memberships["noc"]["user1"] = TeamMember.objects.create(
team=teams["noc"],
user=users[1],
@@ -1620,6 +1621,11 @@ def create_camp_info_categories(self, camp: Camp, teams: dict) -> dict:
headline="Where do I sleep?",
anchor="sleep",
)
+ categories["noc"] = InfoCategory.objects.create(
+ team=teams["noc"],
+ headline="Where do I plug in?",
+ anchor="noc",
+ )
return categories
@@ -1681,6 +1687,12 @@ def create_camp_info_items(self, camp: Camp, categories: dict) -> None:
"sleep in tents for some reason. A tent is the cheapest sleeping option (you just need a ticket), "
"but the cabins are there if you want them.",
)
+ InfoItem.objects.create(
+ category=categories["noc"],
+ headline="Switches",
+ anchor="switches",
+ body="We have places for you to get your cable plugged in to a switch"
+ )
def create_camp_feedback(self, camp: Camp, users: dict[User]) -> None:
"""Create camp feedback."""
@@ -2150,16 +2162,14 @@ def bootstrap_tests(self) -> None:
},
]
self.create_camps(camps)
- self.create_users(2)
+ self.create_users(16)
self.create_event_types()
teams = {}
for camp, read_only in self.camps:
year = camp.camp.lower.year
teams[year] = self.create_camp_teams(camp)
- if year == 2025:
- self.add_team_permissions(camp)
-
+ self.create_camp_team_memberships(camp, teams[year], self.users)
camp.read_only = read_only
camp.call_for_participation_open = not read_only
camp.call_for_sponsors_open = not read_only
@@ -2167,6 +2177,8 @@ def bootstrap_tests(self) -> None:
self.camp = self.camps[1][0]
self.teams = teams[self.camp.camp.lower.year]
+ for member in TeamMember.objects.filter(team__camp=self.camp):
+ member.save()
def bootstrap_camp(self, options: dict) -> None:
"""Bootstrap camp related entities."""
@@ -2285,6 +2297,11 @@ def bootstrap_camp(self, options: dict) -> None:
camp.call_for_participation_open = not read_only
camp.call_for_sponsors_open = not read_only
camp.save()
+
+ # Update team permissions.
+ if camp.camp.lower.year == settings.UPCOMING_CAMP_YEAR:
+ for member in TeamMember.objects.filter(team__camp=camp):
+ member.save()
def bootstrap_base(self, options: dict) -> None:
"""Bootstrap the data for the application."""
diff --git a/src/utils/mixins.py b/src/utils/mixins.py
index cd6c80468..7eabe716d 100644
--- a/src/utils/mixins.py
+++ b/src/utils/mixins.py
@@ -3,6 +3,8 @@
import logging
from typing import TYPE_CHECKING
+from django.conf import settings
+
from django.contrib import messages
from django.contrib.auth.mixins import PermissionRequiredMixin
from django.contrib.auth.mixins import UserPassesTestMixin
@@ -37,6 +39,23 @@ class RaisePermissionRequiredMixin(PermissionRequiredMixin):
raise_exception = True
+class IsTeamPermContextMixin:
+ """Mixing for adding is_team_{perm} to context"""
+ def get_context_data(self, **kwargs):
+ context = super().get_context_data(**kwargs)
+ perms = self.request.user.get_all_permissions()
+ # add bools for each of settings.BORNHACK_TEAM_PERMISSIONS
+ for perm in settings.BORNHACK_TEAM_PERMISSIONS:
+ # loop over user permissions and set context
+ for user_perm in perms:
+ if user_perm.startswith("camps.") and user_perm.endswith(
+ f"_team_{perm}",
+ ):
+ context[f"is_team_{perm}"] = True
+ break
+ else:
+ context[f"is_team_{perm}"] = False
+ return context
class BaseTeamPermRequiredMixin:
"""Base class for TeamRequiredMixins."""