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 %}