diff --git a/src/teams/admin.py b/src/teams/admin.py index 0539b6d0..59646a99 100644 --- a/src/teams/admin.py +++ b/src/teams/admin.py @@ -1,5 +1,5 @@ from django.contrib import admin -from .models import Team, TeamMember, TeamTask +from .models import Team, TeamMember, TeamTask, TeamShift from .email import add_added_membership_email, add_removed_membership_email from camps.utils import CampPropertyListFilter @@ -90,3 +90,10 @@ class TeamMemberAdmin(admin.ModelAdmin): ) remove_member.description = 'Remove a user from the team.' + + +@admin.register(TeamShift) +class TeamShiftAdmin(admin.ModelAdmin): + list_filter = [ + 'team', + ] diff --git a/src/teams/migrations/0043_auto_20180702_1338.py b/src/teams/migrations/0043_auto_20180702_1338.py new file mode 100644 index 00000000..ad64e0dc --- /dev/null +++ b/src/teams/migrations/0043_auto_20180702_1338.py @@ -0,0 +1,43 @@ +# Generated by Django 2.0.4 on 2018-07-02 18:38 + +import django.contrib.postgres.fields.ranges +from django.db import migrations, models +import django.db.models.deletion + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0042_auto_20180413_1933'), + ] + + operations = [ + migrations.CreateModel( + name='TeamShift', + fields=[ + ('id', models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID')), + ('created', models.DateTimeField(auto_now_add=True)), + ('updated', models.DateTimeField(auto_now=True)), + ('shift_range', django.contrib.postgres.fields.ranges.DateTimeRangeField()), + ('people_required', models.IntegerField(default=1)), + ], + options={ + 'abstract': False, + }, + ), + migrations.AddField( + model_name='team', + name='shifts_enabled', + field=models.BooleanField(default=False, help_text='Does this team have shifts? This enables defining shifts for this team.'), + ), + migrations.AddField( + model_name='teamshift', + name='team', + field=models.ForeignKey(help_text='The team this shift belongs to', on_delete=django.db.models.deletion.PROTECT, related_name='shifts', to='teams.Team'), + ), + migrations.AddField( + model_name='teamshift', + name='team_members', + field=models.ManyToManyField(to='teams.TeamMember'), + ), + ] diff --git a/src/teams/migrations/0044_auto_20180702_1507.py b/src/teams/migrations/0044_auto_20180702_1507.py new file mode 100644 index 00000000..cb19b4ac --- /dev/null +++ b/src/teams/migrations/0044_auto_20180702_1507.py @@ -0,0 +1,18 @@ +# Generated by Django 2.0.4 on 2018-07-02 20:07 + +from django.db import migrations, models + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0043_auto_20180702_1338'), + ] + + operations = [ + migrations.AlterField( + model_name='teamshift', + name='team_members', + field=models.ManyToManyField(blank=True, to='teams.TeamMember'), + ), + ] diff --git a/src/teams/migrations/0045_merge_20180805_1131.py b/src/teams/migrations/0045_merge_20180805_1131.py new file mode 100644 index 00000000..5eb121ee --- /dev/null +++ b/src/teams/migrations/0045_merge_20180805_1131.py @@ -0,0 +1,14 @@ +# Generated by Django 2.0.4 on 2018-08-05 09:31 + +from django.db import migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('teams', '0044_auto_20180702_1507'), + ('teams', '0043_auto_20180804_1641'), + ] + + operations = [ + ] diff --git a/src/teams/models.py b/src/teams/models.py index 83d9a043..f376fc85 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -1,14 +1,16 @@ +import logging + from django.db import models -from django.db.models.signals import post_save -from django.dispatch import receiver from django.utils.text import slugify -from utils.models import CampRelatedModel from django.core.exceptions import ValidationError from django.contrib.postgres.fields import DateTimeRangeField from django.contrib.auth.models import User from django.urls import reverse_lazy from django.conf import settings -import logging +from django.contrib.postgres.fields import DateTimeRangeField + +from utils.models import CampRelatedModel + logger = logging.getLogger("bornhack.%s" % __name__) @@ -103,6 +105,11 @@ class Team(CampRelatedModel): help_text='Used to indicate to the IRC bot that this teams private IRC channel is in need of a permissions and ACL fix.' ) + shifts_enabled = models.BooleanField( + default=False, + help_text="Does this team have shifts? This enables defining shifts for this team." + ) + class Meta: ordering = ['name'] unique_together = (('name', 'camp'), ('slug', 'camp')) @@ -312,3 +319,40 @@ class TeamTask(CampRelatedModel): self.slug = slugify(self.name) super().save(**kwargs) + +class TeamShift(CampRelatedModel): + + class Meta: + ordering = ("shift_range",) + + team = models.ForeignKey( + 'teams.Team', + related_name='shifts', + on_delete=models.PROTECT, + help_text='The team this shift belongs to', + ) + + shift_range = DateTimeRangeField() + + team_members = models.ManyToManyField( + TeamMember, + blank=True, + ) + + people_required = models.IntegerField( + default=1 + ) + + @property + def camp(self): + """ All CampRelatedModels must have a camp FK or a camp property """ + return self.team.camp + + camp_filter = 'team__camp' + + def __str__(self): + return "{} team shift from {} to {}".format( + self.team.name, + self.shift_range.lower, + self.shift_range.upper + ) diff --git a/src/teams/templates/team_base.html b/src/teams/templates/team_base.html index 8ef87216..3b36ede8 100644 --- a/src/teams/templates/team_base.html +++ b/src/teams/templates/team_base.html @@ -37,6 +37,12 @@ Team: {{ team.name }} | {{ block.super }} +
  • + + Shifts + +
  • + {% if request.user in team.responsible_members.all %}
  • diff --git a/src/teams/templates/team_shift_confirm_delete.html b/src/teams/templates/team_shift_confirm_delete.html new file mode 100644 index 00000000..16a9ece2 --- /dev/null +++ b/src/teams/templates/team_shift_confirm_delete.html @@ -0,0 +1,10 @@ +{% extends 'team_base.html' %} + +{% block team_content %} + +
    {% csrf_token %} +

    Are you sure you want to delete {{ object }}?

    + +
    + +{% endblock %} diff --git a/src/teams/templates/team_shift_form.html b/src/teams/templates/team_shift_form.html new file mode 100644 index 00000000..a8f9da76 --- /dev/null +++ b/src/teams/templates/team_shift_form.html @@ -0,0 +1,16 @@ +{% extends 'team_base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + + +{% block team_content %} + +
    + {% csrf_token %} + {% bootstrap_form form %} + +
    + +{% endblock %} diff --git a/src/teams/templates/team_shift_list.html b/src/teams/templates/team_shift_list.html new file mode 100644 index 00000000..42f2ddb3 --- /dev/null +++ b/src/teams/templates/team_shift_list.html @@ -0,0 +1,74 @@ +{% extends 'team_base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + + +{% block team_content %} + +{% if request.user in team.responsible_members.all %} +
    + Create a single shift + + + Create multiple shifts + +{% 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 + {% 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 }}{% if not forloop.last %},{% endif %} + {% empty %} + None! + {% endfor %} + + + {% if request.user in team.responsible_members.all %} + + Edit + + + Delete + + {% endif %} + {% if shift.people_required > shift.team_members.count %} + + Take it! + + {% endif %} + {% endfor %} +
    +{% endblock %} diff --git a/src/teams/urls.py b/src/teams/urls.py index b4def544..d2c116c2 100644 --- a/src/teams/urls.py +++ b/src/teams/urls.py @@ -29,6 +29,15 @@ from teams.views.tasks import ( TaskUpdateView, ) +from teams.views.shifts import ( + ShiftListView, + ShiftCreateView, + ShiftCreateMultipleView, + ShiftUpdateView, + ShiftDeleteView, + MemberTakesShift, +) + app_name = 'teams' urlpatterns = [ @@ -144,7 +153,41 @@ urlpatterns = [ ]) ) ]) - ) + ), + path('shifts/', include([ + path( + '', + ShiftListView.as_view(), + name="shifts" + ), + path( + 'create/', + ShiftCreateView.as_view(), + name="shift_create" + ), + path( + 'create_multiple/', + ShiftCreateMultipleView.as_view(), + name="shift_create_multiple" + ), + path('/', include([ + path( + '', + ShiftUpdateView.as_view(), + name="shift_update" + ), + path( + 'delete', + ShiftDeleteView.as_view(), + name="shift_delete" + ), + path( + 'take', + MemberTakesShift.as_view(), + name="shift_member_take" + ), + ])), + ])) ]), ), ] diff --git a/src/teams/views/shifts.py b/src/teams/views/shifts.py new file mode 100644 index 00000000..29d47115 --- /dev/null +++ b/src/teams/views/shifts.py @@ -0,0 +1,306 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.http import HttpResponseRedirect +from django.views.generic import ( + View, + CreateView, + UpdateView, + ListView, + FormView, + DeleteView, +) +from django import forms +from django.contrib.postgres.forms.ranges import RangeWidget +from django.utils import timezone +from django.urls import reverse + +from psycopg2.extras import DateTimeTZRange + +from camps.mixins import CampViewMixin + +from ..models import ( + Team, + TeamMember, + TeamShift, +) + + +class ShiftListView(LoginRequiredMixin, CampViewMixin, ListView): + model = TeamShift + template_name = "team_shift_list.html" + context_object_name = "shifts" + + def get_queryset(self): + queryset = super().get_queryset() + return queryset.filter(team__slug=self.kwargs['team_slug']) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['team'] = Team.objects.get( + camp=self.camp, + slug=self.kwargs['team_slug'] + ) + return context + + +def date_choices(camp): + + 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) + minute_choices.append(minutes) + index += 1 + + def get_time_choices(date): + time_choices = [] + for hour in range(0, 24): + for minute in minute_choices: + time_label = "{hour:02d}:{minutes:02d}".format( + hour=hour, + minutes=minute + ) + choice_value = "{} {}".format(date, time_label) + time_choices.append((choice_value, choice_value)) + return time_choices + + choices = [] + + current_date = camp.camp.lower.date() + while current_date != camp.camp.upper.date(): + choices.append( + ( + current_date, + get_time_choices(current_date.strftime("%Y-%m-%d")) + ) + ) + current_date += timezone.timedelta(days=1) + return choices + + +class ShiftForm(forms.ModelForm): + class Meta: + model = TeamShift + fields = [ + 'from_datetime', + 'to_datetime', + 'people_required' + ] + + def __init__(self, instance=None, **kwargs): + camp = kwargs.pop('camp') + super().__init__(instance=instance, **kwargs) + self.fields['from_datetime'].widget = forms.Select(choices=date_choices(camp)) + self.fields['to_datetime'].widget = forms.Select(choices=date_choices(camp)) + if instance: + current_tz = timezone.get_current_timezone() + lower = instance.shift_range.lower.astimezone(current_tz).strftime("%Y-%m-%d %H:%M") + upper = instance.shift_range.upper.astimezone(current_tz).strftime("%Y-%m-%d %H:%M") + self.fields['from_datetime'].initial = lower + self.fields['to_datetime'].initial = upper + + from_datetime = forms.DateTimeField() + to_datetime = forms.DateTimeField() + + def _get_from_datetime(self): + current_timezone = timezone.get_current_timezone() + return ( + self.cleaned_data['from_datetime'] + .astimezone(current_timezone) + ) + + def _get_to_datetime(self): + current_timezone = timezone.get_current_timezone() + return ( + self.cleaned_data['to_datetime'] + .astimezone(current_timezone) + ) + + def clean(self): + 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.') + + def save(self, commit=True): + # 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): + model = TeamShift + template_name = "team_shift_form.html" + form_class = ShiftForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['camp'] = self.camp + return kwargs + + def form_valid(self, form): + 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): + context = super().get_context_data(**kwargs) + context['team'] = Team.objects.get( + camp=self.camp, + slug=self.kwargs['team_slug'] + ) + return context + + +class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, UpdateView): + model = TeamShift + template_name = "team_shift_form.html" + form_class = ShiftForm + + def get_form_kwargs(self): + 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): + context = super().get_context_data(**kwargs) + context['team'] = self.object.team + return context + + +class ShiftDeleteView(LoginRequiredMixin, CampViewMixin, DeleteView): + model = TeamShift + template_name = "team_shift_confirm_delete.html" + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['team'] = Team.objects.get( + camp=self.camp, + slug=self.kwargs['team_slug'] + ) + return context + + def get_success_url(self): + self.kwargs.pop('pk') + return reverse( + 'teams:shifts', + kwargs=self.kwargs + ) + + +class MultipleShiftForm(forms.Form): + + def __init__(self, instance=None, **kwargs): + camp = kwargs.pop('camp') + super().__init__(**kwargs) + self.fields['from_datetime'].widget = forms.Select(choices=date_choices(camp)) + + from_datetime = forms.DateTimeField() + + number_of_shifts = forms.IntegerField( + help_text="How many shifts?" + ) + + shift_length = forms.IntegerField( + help_text="How long should a shift be in minutes?" + ) + + people_required = forms.IntegerField() + + +class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, FormView): + template_name = "team_shift_form.html" + form_class = MultipleShiftForm + + def get_form_kwargs(self): + kwargs = super().get_form_kwargs() + kwargs['camp'] = self.camp + return kwargs + + def form_valid(self, form): + team = Team.objects.get( + camp=self.camp, + slug=self.kwargs['team_slug'] + ) + current_timezone = timezone.get_current_timezone() + + start_datetime = ( + form.cleaned_data['from_datetime'] + .astimezone(current_timezone) + ) + number_of_shifts = form.cleaned_data['number_of_shifts'] + shift_length = form.cleaned_data['shift_length'] + people_required = form.cleaned_data['people_required'] + + shifts = [] + for index in range(number_of_shifts + 1): + shift_range = DateTimeTZRange( + start_datetime, + start_datetime + timezone.timedelta(minutes=shift_length), + ) + shifts.append(TeamShift( + team=team, + people_required=people_required, + shift_range=shift_range + )) + start_datetime += timezone.timedelta(minutes=shift_length) + + TeamShift.objects.bulk_create(shifts) + + return super().form_valid(form) + + def get_success_url(self): + return reverse( + 'teams:shifts', + kwargs=self.kwargs + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['team'] = Team.objects.get( + camp=self.camp, + slug=self.kwargs['team_slug'] + ) + return context + + +class MemberTakesShift(CampViewMixin, View): + + http_methods = ['get'] + + def get(self, request, **kwargs): + shift = TeamShift.objects.get(id=kwargs['pk']) + team = Team.objects.get( + camp=self.camp, + slug=kwargs['team_slug'] + ) + + team_member = TeamMember.objects.get(team=team, user=request.user) + + shift.team_members.add(team_member) + + kwargs.pop('pk') + + return HttpResponseRedirect( + reverse( + 'teams:shifts', + kwargs=kwargs + ) + )