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 .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',
]

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

View file

@ -37,6 +37,12 @@ Team: {{ team.name }} | {{ block.super }}
</a>
</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 %}
<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 %}">

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,
)
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('<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
)
)