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