Merge pull request #250 from bornhack/feature/shift_planning

Shift planning
This commit is contained in:
Víðir Valberg Guðmundsson 2018-08-05 18:22:12 +02:00 committed by GitHub
commit d132b5d2f2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
11 changed files with 587 additions and 6 deletions

View file

@ -1,5 +1,5 @@
from django.contrib import admin 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 .email import add_added_membership_email, add_removed_membership_email
from camps.utils import CampPropertyListFilter from camps.utils import CampPropertyListFilter
@ -90,3 +90,10 @@ class TeamMemberAdmin(admin.ModelAdmin):
) )
remove_member.description = 'Remove a user from the team.' remove_member.description = 'Remove a user from the team.'
@admin.register(TeamShift)
class TeamShiftAdmin(admin.ModelAdmin):
list_filter = [
'team',
]

View file

@ -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'),
),
]

View file

@ -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'),
),
]

View file

@ -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 = [
]

View file

@ -1,14 +1,16 @@
import logging
from django.db import models 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 django.utils.text import slugify
from utils.models import CampRelatedModel
from django.core.exceptions import ValidationError from django.core.exceptions import ValidationError
from django.contrib.postgres.fields import DateTimeRangeField from django.contrib.postgres.fields import DateTimeRangeField
from django.contrib.auth.models import User from django.contrib.auth.models import User
from django.urls import reverse_lazy from django.urls import reverse_lazy
from django.conf import settings 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__) 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.' 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: class Meta:
ordering = ['name'] ordering = ['name']
unique_together = (('name', 'camp'), ('slug', 'camp')) unique_together = (('name', 'camp'), ('slug', 'camp'))
@ -312,3 +319,40 @@ class TeamTask(CampRelatedModel):
self.slug = slugify(self.name) self.slug = slugify(self.name)
super().save(**kwargs) 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
)

View file

@ -37,6 +37,12 @@ Team: {{ team.name }} | {{ block.super }}
</a> </a>
</li> </li>
<li {% if view.active_menu == "shifts" %}class="active"{% endif %}>
<a href="{% url "teams:shifts" camp_slug=team.camp.slug team_slug=team.slug %}">
Shifts
</a>
</li>
{% if request.user in team.responsible_members.all %} {% if request.user in team.responsible_members.all %}
<li {% if view.active_menu == "info_categories" %}class="active"{% endif %}> <li {% if view.active_menu == "info_categories" %}class="active"{% endif %}>
<a href="{% url "teams:info_categories" camp_slug=team.camp.slug team_slug=team.slug %}"> <a href="{% url "teams:info_categories" camp_slug=team.camp.slug team_slug=team.slug %}">

View file

@ -0,0 +1,10 @@
{% extends 'team_base.html' %}
{% block team_content %}
<form method="post">{% csrf_token %}
<p>Are you sure you want to delete {{ object }}?</p>
<input type="submit" class="btn btn-danger" value="Confirm" />
</form>
{% endblock %}

View file

@ -0,0 +1,16 @@
{% extends 'team_base.html' %}
{% load commonmark %}
{% load bootstrap3 %}
{% block team_content %}
<form method="POST">
{% csrf_token %}
{% bootstrap_form form %}
<button type="submit" class="btn btn-success">
{% if object.pk %}Update{% else %}Create{% endif %}
</button>
</form>
{% endblock %}

View file

@ -0,0 +1,74 @@
{% extends 'team_base.html' %}
{% load commonmark %}
{% load bootstrap3 %}
{% block team_content %}
{% if request.user in team.responsible_members.all %}
<a class="btn btn-success"
href="{% url 'teams:shift_create' camp_slug=camp.slug team_slug=team.slug %}">
Create a single shift
</a>
<a class="btn btn-success"
href="{% url 'teams:shift_create_multiple' camp_slug=camp.slug team_slug=team.slug %}">
Create multiple shifts
</a>
{% endif %}
<table class="table table-condensed">
<tbody>
{% for shift in shifts %}
{% ifchanged shift.shift_range.lower|date:'d' %}
<tr>
<td colspan=4>
<h4>
{{ shift.shift_range.lower|date:'Y-m-d l' }}
</h4>
<tr>
<th>
From
<th>
To
<th>
People required
<th>
People
<th>
Actions
{% endifchanged %}
<tr>
<td>
{{ shift.shift_range.lower|date:'H:i' }}
<td>
{{ shift.shift_range.upper|date:'H:i' }}
<td>
{{ shift.people_required }}
<td>
{% for member in shift.team_members.all %}
{{ member.user }}{% if not forloop.last %},{% endif %}
{% empty %}
None!
{% endfor %}
<td>
{% if request.user in team.responsible_members.all %}
<a class="btn btn-info"
href="{% url 'teams:shift_update' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}">
<i class="fas fa-edit"></i> Edit
</a>
<a class="btn btn-danger"
href="{% url 'teams:shift_delete' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}">
<i class="fas fa-trash"></i> Delete
</a>
{% endif %}
{% if shift.people_required > shift.team_members.count %}
<a class="btn btn-success"
href="{% url 'teams:shift_member_take' camp_slug=camp.slug team_slug=team.slug pk=shift.pk %}">
<i class="fas fa-thumbs-up"></i> Take it!
</a>
{% endif %}
{% endfor %}
</table>
{% endblock %}

View file

@ -29,6 +29,15 @@ from teams.views.tasks import (
TaskUpdateView, TaskUpdateView,
) )
from teams.views.shifts import (
ShiftListView,
ShiftCreateView,
ShiftCreateMultipleView,
ShiftUpdateView,
ShiftDeleteView,
MemberTakesShift,
)
app_name = 'teams' app_name = 'teams'
urlpatterns = [ 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('<int:pk>/', 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"
),
])),
]))
]), ]),
), ),
] ]

306
src/teams/views/shifts.py Normal file
View file

@ -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
)
)