From 102dfa7330ea665d5a3c247781614d416b20bd34 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Mon, 2 Jul 2018 23:52:52 +0200 Subject: [PATCH 1/6] Initial work on shift planning. --- src/teams/admin.py | 9 +- .../migrations/0043_auto_20180702_1338.py | 43 +++++++ .../migrations/0044_auto_20180702_1507.py | 18 +++ src/teams/models.py | 42 +++++- src/teams/templates/shifts/shift_form.html | 20 +++ src/teams/templates/shifts/shift_list.html | 53 ++++++++ src/teams/urls.py | 19 ++- src/teams/views/shifts.py | 120 ++++++++++++++++++ 8 files changed, 317 insertions(+), 7 deletions(-) create mode 100644 src/teams/migrations/0043_auto_20180702_1338.py create mode 100644 src/teams/migrations/0044_auto_20180702_1507.py create mode 100644 src/teams/templates/shifts/shift_form.html create mode 100644 src/teams/templates/shifts/shift_list.html create mode 100644 src/teams/views/shifts.py 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/models.py b/src/teams/models.py index b145c44b..5d445508 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -1,13 +1,14 @@ +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.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__) @@ -102,6 +103,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')) @@ -301,3 +307,29 @@ class TeamTask(CampRelatedModel): self.slug = slugify(self.name) super().save(**kwargs) + +class TeamShift(CampRelatedModel): + 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' diff --git a/src/teams/templates/shifts/shift_form.html b/src/teams/templates/shifts/shift_form.html new file mode 100644 index 00000000..b4bbb7fa --- /dev/null +++ b/src/teams/templates/shifts/shift_form.html @@ -0,0 +1,20 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + + +{% block content %} + +{% if form.errors %} +{{ form.errors }} +{% endif %} + +
+ {% csrf_token %} + {% bootstrap_form form %} + +
+ +{% endblock %} diff --git a/src/teams/templates/shifts/shift_list.html b/src/teams/templates/shifts/shift_list.html new file mode 100644 index 00000000..41aa1ac7 --- /dev/null +++ b/src/teams/templates/shifts/shift_list.html @@ -0,0 +1,53 @@ +{% extends 'base.html' %} +{% load commonmark %} +{% load bootstrap3 %} + + +{% block content %} + + + Back to team detail + + +
+ +{% if request.user in team.responsible_members.all %} + + Create shift + +{% 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 + {% endifchanged %} + +
+ {{ shift.shift_range.lower|date:'H:i' }} + + {{ shift.shift_range.upper|date:'H:i' }} + + {{ shift.people_required }} + + {{ shift.team_members}} + {% endfor %} +
+{% endblock %} diff --git a/src/teams/urls.py b/src/teams/urls.py index 330adfb9..efc1cf72 100644 --- a/src/teams/urls.py +++ b/src/teams/urls.py @@ -18,6 +18,11 @@ from teams.views.tasks import ( TaskUpdateView, ) +from teams.views.shifts import ( + ShiftListView, + ShiftCreateView, +) + app_name = 'teams' urlpatterns = [ @@ -113,7 +118,19 @@ urlpatterns = [ ]), ), ]) - ) + ), + path('shifts/', include([ + path( + '', + ShiftListView.as_view(), + name="shift_list" + ), + path( + 'create/', + ShiftCreateView.as_view(), + name="shift_create" + ), + ])) ]), ), ] diff --git a/src/teams/views/shifts.py b/src/teams/views/shifts.py new file mode 100644 index 00000000..2a112173 --- /dev/null +++ b/src/teams/views/shifts.py @@ -0,0 +1,120 @@ +from django.contrib.auth.mixins import LoginRequiredMixin +from django.views.generic import CreateView, UpdateView, ListView +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, + TeamShift, +) + + +class ShiftListView(LoginRequiredMixin, CampViewMixin, ListView): + model = TeamShift + template_name = "shifts/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 time_choices(): + index = 0 + minute_choices = [] + SHIFT_MINIMUM_LENGTH = 15 # TODO: Maybe this should be configurable per team? + while index * SHIFT_MINIMUM_LENGTH < 60: + minutes = int(index * SHIFT_MINIMUM_LENGTH) + minute_choices.append(minutes) + index += 1 + + time_choices = [] + for hour in range(0, 24): + for minute in minute_choices: + choice_label = "{hour:02d}:{minutes:02d}".format(hour=hour, minutes=minute) + time_choices.append((choice_label, choice_label)) + + return time_choices + + +class ShiftForm(forms.ModelForm): + class Meta: + model = TeamShift + fields = [ + 'from_date', + 'from_time', + 'to_date', + 'to_time', + 'people_required' + ] + + from_date = forms.DateField( + help_text="Format is YYYY-MM-DD - ie. 2018-08-15" + ) + + from_time = forms.ChoiceField( + choices=time_choices + ) + + to_date = forms.DateField( + help_text="Format is YYYY-MM-DD - ie. 2018-08-15" + ) + + to_time = forms.ChoiceField( + choices=time_choices + ) + + def save(self, commit=True): + from_string = "{} {}".format( + self.cleaned_data['from_date'], + self.cleaned_data['from_time'] + ) + to_string = "{} {}".format( + self.cleaned_data['to_date'], + self.cleaned_data['to_time'] + ) + datetime_format = '%Y-%m-%d %H:%M' + self.instance.shift_range = DateTimeTZRange( + timezone.datetime.strptime(from_string, datetime_format), + timezone.datetime.strptime(to_string, datetime_format) + ) + return super().save(commit=commit) + + +class ShiftCreateView(LoginRequiredMixin, CampViewMixin, CreateView): + model = TeamShift + template_name = "shifts/shift_form.html" + form_class = ShiftForm + + 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:shift_list', + kwargs=self.kwargs + ) + + +class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, UpdateView): + model = TeamShift + template_name = "shifts/shift_form.html" + From c103400046f8f5ff65c304273d767a803fda90ac Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Tue, 3 Jul 2018 07:15:42 +0200 Subject: [PATCH 2/6] Enable editing of shifts. With timezone rabbit hole. --- src/teams/models.py | 4 ++ src/teams/templates/shifts/shift_form.html | 9 +++- src/teams/templates/shifts/shift_list.html | 14 +++++- src/teams/urls.py | 6 +++ src/teams/views/shifts.py | 57 ++++++++++++++++++++-- 5 files changed, 83 insertions(+), 7 deletions(-) diff --git a/src/teams/models.py b/src/teams/models.py index 5d445508..eb6dcebb 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -309,6 +309,10 @@ class TeamTask(CampRelatedModel): class TeamShift(CampRelatedModel): + + class Meta: + ordering = ("shift_range",) + team = models.ForeignKey( 'teams.Team', related_name='shifts', diff --git a/src/teams/templates/shifts/shift_form.html b/src/teams/templates/shifts/shift_form.html index b4bbb7fa..34ffca83 100644 --- a/src/teams/templates/shifts/shift_form.html +++ b/src/teams/templates/shifts/shift_form.html @@ -5,6 +5,13 @@ {% block content %} + + Cancel + + +
+ {% if form.errors %} {{ form.errors }} {% endif %} @@ -13,7 +20,7 @@ {% csrf_token %} {% bootstrap_form form %} diff --git a/src/teams/templates/shifts/shift_list.html b/src/teams/templates/shifts/shift_list.html index 41aa1ac7..374d987c 100644 --- a/src/teams/templates/shifts/shift_list.html +++ b/src/teams/templates/shifts/shift_list.html @@ -37,6 +37,8 @@ People required People + + Actions {% endifchanged %} @@ -47,7 +49,17 @@ {{ shift.people_required }} - {{ shift.team_members}} + {{ shift.team_members }} + + {% if request.user in team.responsible_members.all %} + + Edit + + + Delete + + {% endif %} {% endfor %} {% endblock %} diff --git a/src/teams/urls.py b/src/teams/urls.py index efc1cf72..e48a6b27 100644 --- a/src/teams/urls.py +++ b/src/teams/urls.py @@ -21,6 +21,7 @@ from teams.views.tasks import ( from teams.views.shifts import ( ShiftListView, ShiftCreateView, + ShiftUpdateView, ) app_name = 'teams' @@ -130,6 +131,11 @@ urlpatterns = [ ShiftCreateView.as_view(), name="shift_create" ), + path( + '/', + ShiftUpdateView.as_view(), + name="shift_update" + ) ])) ]), ), diff --git a/src/teams/views/shifts.py b/src/teams/views/shifts.py index 2a112173..334b911a 100644 --- a/src/teams/views/shifts.py +++ b/src/teams/views/shifts.py @@ -4,6 +4,7 @@ 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 @@ -61,8 +62,26 @@ class ShiftForm(forms.ModelForm): 'people_required' ] + def __init__(self, instance=None, **kwargs): + super().__init__(instance=instance, **kwargs) + if instance: + current_tz = timezone.get_current_timezone() + + lower = instance.shift_range.lower.astimezone(current_tz) + upper = instance.shift_range.upper.astimezone(current_tz) + + from_date = lower.strftime('%Y-%m-%d') + from_time = lower.strftime('%H:%M') + to_date = upper.strftime('%Y-%m-%d') + to_time = upper.strftime('%H:%M') + + self.fields['from_date'].initial = from_date + self.fields['from_time'].initial = from_time + self.fields['to_date'].initial = to_date + self.fields['to_time'].initial = to_time + from_date = forms.DateField( - help_text="Format is YYYY-MM-DD - ie. 2018-08-15" + help_text="Format is YYYY-MM-DD" ) from_time = forms.ChoiceField( @@ -70,7 +89,7 @@ class ShiftForm(forms.ModelForm): ) to_date = forms.DateField( - help_text="Format is YYYY-MM-DD - ie. 2018-08-15" + help_text="Format is YYYY-MM-DD" ) to_time = forms.ChoiceField( @@ -87,10 +106,18 @@ class ShiftForm(forms.ModelForm): self.cleaned_data['to_time'] ) datetime_format = '%Y-%m-%d %H:%M' - self.instance.shift_range = DateTimeTZRange( - timezone.datetime.strptime(from_string, datetime_format), - timezone.datetime.strptime(to_string, datetime_format) + current_timezone = timezone.get_current_timezone() + lower = ( + timezone.datetime + .strptime(from_string, datetime_format) + .astimezone(current_timezone) ) + upper = ( + timezone.datetime + .strptime(to_string, datetime_format) + .astimezone(current_timezone) + ) + self.instance.shift_range = DateTimeTZRange(lower, upper) return super().save(commit=commit) @@ -113,8 +140,28 @@ class ShiftCreateView(LoginRequiredMixin, CampViewMixin, CreateView): 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 = "shifts/shift_form.html" + form_class = ShiftForm + def get_success_url(self): + self.kwargs.pop('pk') + return reverse( + 'teams:shift_list', + kwargs=self.kwargs + ) + + def get_context_data(self, **kwargs): + context = super().get_context_data(**kwargs) + context['team'] = self.object.team + return context From 4f77b21a6070bf83cc93ea3009f20ef289968352 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Tue, 17 Jul 2018 19:28:16 +0200 Subject: [PATCH 3/6] Add a way to create multiple shifts - needs a bit more work though. --- src/teams/templates/shifts/shift_list.html | 6 +- src/teams/urls.py | 6 ++ src/teams/views/shifts.py | 96 +++++++++++++++++++++- 3 files changed, 106 insertions(+), 2 deletions(-) diff --git a/src/teams/templates/shifts/shift_list.html b/src/teams/templates/shifts/shift_list.html index 374d987c..3e1ce28b 100644 --- a/src/teams/templates/shifts/shift_list.html +++ b/src/teams/templates/shifts/shift_list.html @@ -15,7 +15,11 @@ {% if request.user in team.responsible_members.all %} - Create shift + Create a single shift + + + Create multiple shifts {% endif %} diff --git a/src/teams/urls.py b/src/teams/urls.py index e48a6b27..658d64a7 100644 --- a/src/teams/urls.py +++ b/src/teams/urls.py @@ -21,6 +21,7 @@ from teams.views.tasks import ( from teams.views.shifts import ( ShiftListView, ShiftCreateView, + ShiftCreateMultipleView, ShiftUpdateView, ) @@ -131,6 +132,11 @@ urlpatterns = [ ShiftCreateView.as_view(), name="shift_create" ), + path( + 'create_multiple/', + ShiftCreateMultipleView.as_view(), + name="shift_create_multiple" + ), path( '/', ShiftUpdateView.as_view(), diff --git a/src/teams/views/shifts.py b/src/teams/views/shifts.py index 334b911a..54edd600 100644 --- a/src/teams/views/shifts.py +++ b/src/teams/views/shifts.py @@ -1,5 +1,5 @@ from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import CreateView, UpdateView, ListView +from django.views.generic import CreateView, UpdateView, ListView, FormView from django import forms from django.contrib.postgres.forms.ranges import RangeWidget from django.utils import timezone @@ -121,6 +121,7 @@ class ShiftForm(forms.ModelForm): return super().save(commit=commit) + class ShiftCreateView(LoginRequiredMixin, CampViewMixin, CreateView): model = TeamShift template_name = "shifts/shift_form.html" @@ -165,3 +166,96 @@ class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, UpdateView): context = super().get_context_data(**kwargs) context['team'] = self.object.team return context + + +class MultipleShiftForm(forms.Form): + + date = forms.DateField( + help_text="Format is YYYY-MM-DD" + ) + + number_of_shifts = forms.IntegerField( + help_text="How many shifts in a day?" + ) + + start_time = forms.TimeField( + help_text="When the first shift should start? Defaults to 00:00.", + required=False, + initial="00:00" + ) + + end_time = forms.TimeField( + help_text="When the last shift should end? Defaults to 00:00 (next day).", + required=False, + initial="00:00" + ) + + people_required = forms.IntegerField() + + +class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, FormView): + template_name = "shifts/shift_form.html" + form_class = MultipleShiftForm + + def form_valid(self, form): + team = Team.objects.get( + camp=self.camp, + slug=self.kwargs['team_slug'] + ) + date = form.cleaned_data['date'] + number_of_shifts = form.cleaned_data['number_of_shifts'] + start_time = form.cleaned_data['start_time'] + end_time = form.cleaned_data['end_time'] + people_required = form.cleaned_data['people_required'] + + current_timezone = timezone.get_current_timezone() + + # create start datetime + start_datetime = ( + timezone.datetime.combine(date, start_time) + .astimezone(current_timezone) + ) + # create end datetime + if end_time == "00:00": + # if end time is midnight, we want midnight for next day + date = date + timezone.timedelta(days=1) + + end_datetime = ( + timezone.datetime.combine(date, end_time) + .astimezone(current_timezone) + ) + # figure out minutes between start and end datetime + total_minutes = (end_datetime - start_datetime).total_seconds() / 60 + # divide by number of shifts -> duration for each shift + shift_duration = total_minutes / number_of_shifts + + shifts = [] + for index in range(number_of_shifts + 1): + shift_range = DateTimeTZRange( + start_datetime, + start_datetime + timezone.timedelta(minutes=shift_duration), + ) + shifts.append(TeamShift( + team=team, + people_required=people_required, + shift_range=shift_range + )) + start_datetime += timezone.timedelta(minutes=shift_duration) + + TeamShift.objects.bulk_create(shifts) + + return super().form_valid(form) + + def get_success_url(self): + return reverse( + 'teams:shift_list', + 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 From 2bdd172b921f2377d6f26c349bf31c8bf660e9c4 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Thu, 19 Jul 2018 22:46:26 +0200 Subject: [PATCH 4/6] Fix multiple shifts create. Add deletion. Add a way to take a shift. --- .../shifts/shift_confirm_delete.html | 16 ++ src/teams/templates/shifts/shift_form.html | 4 - src/teams/templates/shifts/shift_list.html | 16 +- src/teams/urls.py | 24 +- src/teams/views/shifts.py | 239 +++++++++++------- 5 files changed, 191 insertions(+), 108 deletions(-) create mode 100644 src/teams/templates/shifts/shift_confirm_delete.html diff --git a/src/teams/templates/shifts/shift_confirm_delete.html b/src/teams/templates/shifts/shift_confirm_delete.html new file mode 100644 index 00000000..57ee688b --- /dev/null +++ b/src/teams/templates/shifts/shift_confirm_delete.html @@ -0,0 +1,16 @@ +{% extends 'base.html' %} + +{% block content %} + + Cancel + + +
+ +
{% csrf_token %} +

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

+ +
+ +{% endblock %} diff --git a/src/teams/templates/shifts/shift_form.html b/src/teams/templates/shifts/shift_form.html index 34ffca83..511ba5ce 100644 --- a/src/teams/templates/shifts/shift_form.html +++ b/src/teams/templates/shifts/shift_form.html @@ -12,10 +12,6 @@
-{% if form.errors %} -{{ form.errors }} -{% endif %} -
{% csrf_token %} {% bootstrap_form form %} diff --git a/src/teams/templates/shifts/shift_list.html b/src/teams/templates/shifts/shift_list.html index 3e1ce28b..c01710db 100644 --- a/src/teams/templates/shifts/shift_list.html +++ b/src/teams/templates/shifts/shift_list.html @@ -53,17 +53,29 @@ {{ shift.people_required }} - {{ shift.team_members }} + {% 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 658d64a7..6c848d07 100644 --- a/src/teams/urls.py +++ b/src/teams/urls.py @@ -23,6 +23,8 @@ from teams.views.shifts import ( ShiftCreateView, ShiftCreateMultipleView, ShiftUpdateView, + ShiftDeleteView, + MemberTakesShift, ) app_name = 'teams' @@ -137,11 +139,23 @@ urlpatterns = [ ShiftCreateMultipleView.as_view(), name="shift_create_multiple" ), - path( - '/', - ShiftUpdateView.as_view(), - name="shift_update" - ) + 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 index 54edd600..64db3840 100644 --- a/src/teams/views/shifts.py +++ b/src/teams/views/shifts.py @@ -1,5 +1,13 @@ from django.contrib.auth.mixins import LoginRequiredMixin -from django.views.generic import CreateView, UpdateView, ListView, FormView +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 @@ -11,6 +19,7 @@ from camps.mixins import CampViewMixin from ..models import ( Team, + TeamMember, TeamShift, ) @@ -33,100 +42,103 @@ class ShiftListView(LoginRequiredMixin, CampViewMixin, ListView): return context -def time_choices(): +def date_choices(camp): + index = 0 minute_choices = [] - SHIFT_MINIMUM_LENGTH = 15 # TODO: Maybe this should be configurable per team? + # 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 - time_choices = [] - for hour in range(0, 24): - for minute in minute_choices: - choice_label = "{hour:02d}:{minutes:02d}".format(hour=hour, minutes=minute) - time_choices.append((choice_label, choice_label)) + 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 - 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_date', - 'from_time', - 'to_date', - 'to_time', + '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 - lower = instance.shift_range.lower.astimezone(current_tz) - upper = instance.shift_range.upper.astimezone(current_tz) + from_datetime = forms.DateTimeField() + to_datetime = forms.DateTimeField() - from_date = lower.strftime('%Y-%m-%d') - from_time = lower.strftime('%H:%M') - to_date = upper.strftime('%Y-%m-%d') - to_time = upper.strftime('%H:%M') + def _get_from_datetime(self): + current_timezone = timezone.get_current_timezone() + return ( + self.cleaned_data['from_datetime'] + .astimezone(current_timezone) + ) - self.fields['from_date'].initial = from_date - self.fields['from_time'].initial = from_time - self.fields['to_date'].initial = to_date - self.fields['to_time'].initial = to_time + def _get_to_datetime(self): + current_timezone = timezone.get_current_timezone() + return ( + self.cleaned_data['to_datetime'] + .astimezone(current_timezone) + ) - from_date = forms.DateField( - help_text="Format is YYYY-MM-DD" - ) - - from_time = forms.ChoiceField( - choices=time_choices - ) - - to_date = forms.DateField( - help_text="Format is YYYY-MM-DD" - ) - - to_time = forms.ChoiceField( - choices=time_choices - ) + 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): - from_string = "{} {}".format( - self.cleaned_data['from_date'], - self.cleaned_data['from_time'] - ) - to_string = "{} {}".format( - self.cleaned_data['to_date'], - self.cleaned_data['to_time'] - ) - datetime_format = '%Y-%m-%d %H:%M' - current_timezone = timezone.get_current_timezone() - lower = ( - timezone.datetime - .strptime(from_string, datetime_format) - .astimezone(current_timezone) - ) - upper = ( - timezone.datetime - .strptime(to_string, datetime_format) - .astimezone(current_timezone) - ) - self.instance.shift_range = DateTimeTZRange(lower, 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): model = TeamShift template_name = "shifts/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( @@ -155,6 +167,11 @@ class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, UpdateView): template_name = "shifts/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( @@ -168,26 +185,41 @@ class ShiftUpdateView(LoginRequiredMixin, CampViewMixin, UpdateView): return context +class ShiftDeleteView(LoginRequiredMixin, CampViewMixin, DeleteView): + model = TeamShift + template_name = "shifts/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:shift_list', + kwargs=self.kwargs + ) + + class MultipleShiftForm(forms.Form): - date = forms.DateField( - help_text="Format is YYYY-MM-DD" - ) + 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 in a day?" + help_text="How many shifts?" ) - start_time = forms.TimeField( - help_text="When the first shift should start? Defaults to 00:00.", - required=False, - initial="00:00" - ) - - end_time = forms.TimeField( - help_text="When the last shift should end? Defaults to 00:00 (next day).", - required=False, - initial="00:00" + shift_length = forms.IntegerField( + help_text="How long should a shift be in minutes?" ) people_required = forms.IntegerField() @@ -197,50 +229,38 @@ class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, FormView): template_name = "shifts/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'] ) - date = form.cleaned_data['date'] - number_of_shifts = form.cleaned_data['number_of_shifts'] - start_time = form.cleaned_data['start_time'] - end_time = form.cleaned_data['end_time'] - people_required = form.cleaned_data['people_required'] - current_timezone = timezone.get_current_timezone() - # create start datetime start_datetime = ( - timezone.datetime.combine(date, start_time) + form.cleaned_data['from_datetime'] .astimezone(current_timezone) ) - # create end datetime - if end_time == "00:00": - # if end time is midnight, we want midnight for next day - date = date + timezone.timedelta(days=1) - - end_datetime = ( - timezone.datetime.combine(date, end_time) - .astimezone(current_timezone) - ) - # figure out minutes between start and end datetime - total_minutes = (end_datetime - start_datetime).total_seconds() / 60 - # divide by number of shifts -> duration for each shift - shift_duration = total_minutes / number_of_shifts + 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_duration), + 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_duration) + start_datetime += timezone.timedelta(minutes=shift_length) TeamShift.objects.bulk_create(shifts) @@ -259,3 +279,28 @@ class ShiftCreateMultipleView(LoginRequiredMixin, CampViewMixin, FormView): 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:shift_list', + kwargs=kwargs + ) + ) From 214026dfd7a2a9291deaf4a7b190925e77750f2e Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?V=C3=AD=C3=B0ir=20Valberg=20Gu=C3=B0mundsson?= Date: Sun, 5 Aug 2018 12:18:10 +0200 Subject: [PATCH 5/6] Integrate into new teams structure. --- .../migrations/0045_merge_20180805_1131.py | 14 +++++++++++++ .../shifts/shift_confirm_delete.html | 16 --------------- src/teams/templates/team_base.html | 6 ++++++ .../templates/team_shift_confirm_delete.html | 10 ++++++++++ .../shift_form.html => team_shift_form.html} | 11 ++-------- .../shift_list.html => team_shift_list.html} | 11 ++-------- src/teams/urls.py | 2 +- src/teams/views/shifts.py | 20 +++++++++---------- 8 files changed, 45 insertions(+), 45 deletions(-) create mode 100644 src/teams/migrations/0045_merge_20180805_1131.py delete mode 100644 src/teams/templates/shifts/shift_confirm_delete.html create mode 100644 src/teams/templates/team_shift_confirm_delete.html rename src/teams/templates/{shifts/shift_form.html => team_shift_form.html} (55%) rename src/teams/templates/{shifts/shift_list.html => team_shift_list.html} (91%) 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/templates/shifts/shift_confirm_delete.html b/src/teams/templates/shifts/shift_confirm_delete.html deleted file mode 100644 index 57ee688b..00000000 --- a/src/teams/templates/shifts/shift_confirm_delete.html +++ /dev/null @@ -1,16 +0,0 @@ -{% extends 'base.html' %} - -{% block content %} - - Cancel - - -
- -{% csrf_token %} -

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

- - - -{% endblock %} 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..51b3992c --- /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/shifts/shift_form.html b/src/teams/templates/team_shift_form.html similarity index 55% rename from src/teams/templates/shifts/shift_form.html rename to src/teams/templates/team_shift_form.html index 511ba5ce..a8f9da76 100644 --- a/src/teams/templates/shifts/shift_form.html +++ b/src/teams/templates/team_shift_form.html @@ -1,16 +1,9 @@ -{% extends 'base.html' %} +{% extends 'team_base.html' %} {% load commonmark %} {% load bootstrap3 %} -{% block content %} - -
    - Cancel - - -
    +{% block team_content %}
    {% csrf_token %} diff --git a/src/teams/templates/shifts/shift_list.html b/src/teams/templates/team_shift_list.html similarity index 91% rename from src/teams/templates/shifts/shift_list.html rename to src/teams/templates/team_shift_list.html index c01710db..42f2ddb3 100644 --- a/src/teams/templates/shifts/shift_list.html +++ b/src/teams/templates/team_shift_list.html @@ -1,16 +1,9 @@ -{% extends 'base.html' %} +{% extends 'team_base.html' %} {% load commonmark %} {% load bootstrap3 %} -{% block content %} - - - Back to team detail - - -
    +{% block team_content %} {% if request.user in team.responsible_members.all %} Date: Sun, 5 Aug 2018 18:21:30 +0200 Subject: [PATCH 6/6] Small representation fix. --- src/teams/models.py | 7 +++++++ src/teams/templates/team_shift_confirm_delete.html | 2 +- 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/src/teams/models.py b/src/teams/models.py index 5200cd14..f376fc85 100644 --- a/src/teams/models.py +++ b/src/teams/models.py @@ -349,3 +349,10 @@ class TeamShift(CampRelatedModel): 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_shift_confirm_delete.html b/src/teams/templates/team_shift_confirm_delete.html index 51b3992c..16a9ece2 100644 --- a/src/teams/templates/team_shift_confirm_delete.html +++ b/src/teams/templates/team_shift_confirm_delete.html @@ -3,7 +3,7 @@ {% block team_content %} {% csrf_token %} -

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

    +

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