add multiple desk management (#15729)

This commit is contained in:
Josue Kouka 2017-09-01 15:01:07 +02:00
parent 23fc248cf3
commit f851de07df
20 changed files with 708 additions and 123 deletions

View File

@ -0,0 +1,26 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
class Migration(migrations.Migration):
dependencies = [
('agendas', '0015_auto_20170628_1137'),
]
operations = [
migrations.CreateModel(
name='Desk',
fields=[
('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)),
('label', models.CharField(max_length=150, verbose_name='Label')),
('slug', models.SlugField(max_length=150, verbose_name='Slug')),
('agenda', models.ForeignKey(to='agendas.Agenda')),
],
options={
'ordering': ['label'],
},
),
]

View File

@ -0,0 +1,43 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def set_timeperiod_desk(apps, schema_editor):
TimePeriod = apps.get_model('agendas', 'TimePeriod')
Desk = apps.get_model('agendas', 'Desk')
for time_period in TimePeriod.objects.all():
desk, created = Desk.objects.get_or_create(
label='Guichet 1', slug='guichet-1', agenda=time_period.agenda)
time_period.desk = desk
time_period.save()
def unset_timeperiod_desk(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('agendas', '0016_desk'),
]
operations = [
migrations.AddField(
model_name='timeperiod',
name='desk',
field=models.ForeignKey(to='agendas.Desk', null=True),
),
migrations.RunPython(set_timeperiod_desk, unset_timeperiod_desk),
migrations.AlterField(
model_name='timeperiod',
name='desk',
field=models.ForeignKey(to='agendas.Desk'),
),
migrations.RemoveField(
model_name='timeperiod',
name='agenda',
),
]

View File

@ -0,0 +1,37 @@
# -*- coding: utf-8 -*-
from __future__ import unicode_literals
from django.db import migrations, models
def set_event_desk(apps, schema_editor):
Events = apps.get_model('agendas', 'Event')
Desk = apps.get_model('agendas', 'Desk')
for event in Events.objects.all():
if not event.agenda.kind == 'meetings':
continue
desk, created = Desk.objects.get_or_create(
label='Guichet 1', slug='guichet-1', agenda=event.agenda)
event.desk = desk
event.save()
def unset_event_desk(apps, schema_editor):
pass
class Migration(migrations.Migration):
dependencies = [
('agendas', '0017_timeperiod_desk'),
]
operations = [
migrations.AddField(
model_name='event',
name='desk',
field=models.ForeignKey(to='agendas.Desk', null=True),
),
migrations.RunPython(set_event_desk, unset_event_desk)
]

View File

@ -33,6 +33,7 @@ AGENDA_KINDS = (
('meetings', _('Meetings')),
)
class Agenda(models.Model):
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Slug'))
@ -95,7 +96,7 @@ class Agenda(models.Model):
agenda['events'] = [x.export_json() for x in self.event_set.all()]
elif self.kind == 'meetings':
agenda['meetingtypes'] = [x.export_json() for x in self.meetingtype_set.all()]
agenda['timeperiods'] = [x.export_json() for x in self.timeperiod_set.all()]
agenda['desks'] = [desk.export_json() for desk in self.desk_set.all()]
return agenda
@classmethod
@ -106,7 +107,7 @@ class Agenda(models.Model):
events = data.pop('events')
elif data['kind'] == 'meetings':
meetingtypes = data.pop('meetingtypes')
timeperiods = data.pop('timeperiods')
desks = data.pop('desks')
agenda, created = cls.objects.get_or_create(slug=data['slug'], defaults=data)
if data['kind'] == 'events':
if overwrite:
@ -117,40 +118,34 @@ class Agenda(models.Model):
elif data['kind'] == 'meetings':
if overwrite:
MeetingType.objects.filter(agenda=agenda).delete()
TimePeriod.objects.filter(agenda=agenda).delete()
Desk.objects.filter(agenda=agenda).delete()
for type_data in meetingtypes:
type_data['agenda'] = agenda
MeetingType.import_json(type_data).save()
for period_data in timeperiods:
period_data['agenda'] = agenda
TimePeriod.import_json(period_data).save()
for desk in desks:
desk['agenda'] = agenda
Desk.import_json(desk).save()
WEEKDAYS_LIST = sorted(WEEKDAYS.items(), key=lambda x: x[0])
class TimeSlot(object):
def __init__(self, start_datetime, meeting_type):
def __init__(self, start_datetime, meeting_type, desk):
self.start_datetime = start_datetime
self.end_datetime = start_datetime + datetime.timedelta(minutes=meeting_type.duration)
self.meeting_type = meeting_type
self.id = '%s:%s' % (self.meeting_type.id, start_datetime.strftime('%Y-%m-%d-%H%M'))
def intersects(self, timeslot):
if self.start_datetime >= timeslot.end_datetime:
return False
if self.end_datetime <= timeslot.start_datetime:
return False
return True
self.desk = desk
def __unicode__(self):
return date_format(self.start_datetime, format='DATETIME_FORMAT')
class TimePeriod(models.Model):
agenda = models.ForeignKey(Agenda)
weekday = models.IntegerField(_('Week day'), choices=WEEKDAYS_LIST)
start_time = models.TimeField(_('Start'))
end_time = models.TimeField(_('End'))
desk = models.ForeignKey('Desk', on_delete=models.CASCADE)
class Meta:
ordering = ['weekday', 'start_time']
@ -192,7 +187,7 @@ class TimePeriod(models.Model):
if event_datetime > max_datetime:
break
yield TimeSlot(start_datetime=event_datetime, meeting_type=meeting_type)
yield TimeSlot(start_datetime=event_datetime, meeting_type=meeting_type, desk=self.desk)
event_datetime = end_time
@ -242,6 +237,7 @@ class Event(models.Model):
help_text=_('Optional label to identify this date.'))
full = models.BooleanField(default=False)
meeting_type = models.ForeignKey(MeetingType, null=True)
desk = models.ForeignKey('Desk', null=True)
class Meta:
ordering = ['agenda', 'start_datetime', 'label']
@ -334,3 +330,45 @@ class Booking(models.Model):
with transaction.atomic():
self.secondary_booking_set.update(in_waiting_list=False)
self.save()
class Desk(models.Model):
agenda = models.ForeignKey(Agenda)
label = models.CharField(_('Label'), max_length=150)
slug = models.SlugField(_('Slug'), max_length=150)
def __unicode__(self):
return self.label
class Meta:
ordering = ['label']
def save(self, *args, **kwargs):
if not self.slug:
base_slug = slugify(self.label)
slug = base_slug
i = 1
while True:
try:
Desk.objects.get(slug=slug, agenda=self.agenda)
except self.DoesNotExist:
break
slug = '%s-%s' % (base_slug, i)
i += 1
self.slug = slug
super(Desk, self).save(*args, **kwargs)
@classmethod
def import_json(cls, data):
timeperiods = data.pop('timeperiods')
instance, created = cls.objects.get_or_create(**data)
for timeperiod in timeperiods:
timeperiod['desk'] = instance
TimePeriod.import_json(timeperiod).save()
return instance
def export_json(self):
return {'label': self.label,
'slug': self.slug,
'timeperiods': [time_period.export_json() for time_period in self.timeperiod_set.all()]
}

View File

@ -14,7 +14,11 @@
# You should have received a copy of the GNU Affero General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from collections import defaultdict
import datetime
import operator
from intervaltree import IntervalTree
from django.core.urlresolvers import reverse
from django.http import Http404
@ -27,7 +31,38 @@ from rest_framework.generics import GenericAPIView
from rest_framework.response import Response
from rest_framework.views import APIView
from ..agendas.models import Agenda, Event, Booking, MeetingType, TimePeriod
from ..agendas.models import (Agenda, Event, Booking, MeetingType,
TimePeriod, Desk)
def get_open_slots(agenda, meeting_type):
min_datetime = now() + datetime.timedelta(days=agenda.minimal_booking_delay)
max_datetime = now() + datetime.timedelta(days=agenda.maximal_booking_delay)
time_period_filters = {
'min_datetime': min_datetime,
'max_datetime': max_datetime,
'meeting_type': meeting_type
}
open_slots_by_desk = defaultdict(lambda: IntervalTree())
for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
for slot in time_period.get_time_slots(**time_period_filters):
open_slots_by_desk[time_period.desk_id].addi(slot.start_datetime, slot.end_datetime, slot.desk)
for event in agenda.event_set.filter(
agenda=agenda, start_datetime__gte=min_datetime,
start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration)).select_related(
'meeting_type').extra(
select={
'booking_count': """SELECT COUNT(*) FROM agendas_booking
WHERE agendas_booking.event_id = agendas_event.id
AND agendas_booking.cancellation_datetime IS NOT NULL"""}):
if event.booking_count:
continue
open_slots_by_desk[event.desk_id].remove_overlap(event.start_datetime, event.end_datetime)
open_slots = reduce(operator.__or__, open_slots_by_desk.values())
return open_slots
def get_agenda_detail(request, agenda):
@ -151,27 +186,26 @@ class MeetingDatetimes(GenericAPIView):
max_datetime = now() + datetime.timedelta(days=agenda.maximal_booking_delay)
all_time_slots = []
for time_period in TimePeriod.objects.filter(agenda=agenda):
all_time_slots.extend(time_period.get_time_slots(
min_datetime=min_datetime,
max_datetime=max_datetime,
meeting_type=meeting_type))
for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
all_time_slots.extend(time_period.get_time_slots(min_datetime=min_datetime, max_datetime=max_datetime,
meeting_type=meeting_type))
busy_time_slots = Event.objects.filter(agenda=agenda,
start_datetime__gte=min_datetime,
start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration))
busy_time_slots = list(busy_time_slots)
entries = []
# there's room for optimisations here, for a start both lists
# could be presorted and past busy time slots removed along the way.
open_slots = get_open_slots(agenda, meeting_type)
open_entries = {}
closed_entries = {}
for time_slot in all_time_slots:
if time_slot.start_datetime < now_datetime:
continue
time_slot.full = bool(any((x for x in busy_time_slots if x.full and time_slot.intersects(x))))
entries.append(time_slot)
key = '%s-%s' % (time_slot.start_datetime, time_slot.end_datetime)
if open_slots.search(time_slot.start_datetime, time_slot.end_datetime, strict=True):
time_slot.full = False
open_entries[key] = time_slot
else:
time_slot.full = True
closed_entries[key] = time_slot
entries.sort(key=lambda x: x.start_datetime)
closed_entries.update(open_entries)
entries = sorted(closed_entries.values(), key=lambda x: x.start_datetime)
fake_event_pk = '__event_id__'
fillslot_url = request.build_absolute_uri(
@ -251,12 +285,24 @@ class Fillslot(GenericAPIView):
meeting_type_id, start_datetime_str = event_pk.split(':')
start_datetime = make_aware(datetime.datetime.strptime(
start_datetime_str, '%Y-%m-%d-%H%M'))
event, created = Event.objects.get_or_create(agenda=agenda,
event = Event.objects.create(agenda=agenda,
meeting_type_id=meeting_type_id,
start_datetime=start_datetime,
defaults={'full': False, 'places': 1})
if created:
event.save()
full=False, places=1)
available_desk = None
open_slots = get_open_slots(agenda, MeetingType.objects.get(id=meeting_type_id))
slot = open_slots[event.start_datetime:event.end_datetime]
if slot:
available_desk = slot.pop().data
if not available_desk:
return Response({'err': 1, 'reason': 'no more desk available'})
event.desk = available_desk
event.save()
event_pk = event.id
event = Event.objects.filter(id=event_pk)[0]

View File

@ -21,7 +21,7 @@ from django import forms
from django.forms import ValidationError
from django.utils.translation import ugettext_lazy as _
from chrono.agendas.models import Event, MeetingType, TimePeriod
from chrono.agendas.models import Event, MeetingType, TimePeriod, Desk
from . import widgets
@ -44,7 +44,7 @@ class EventForm(forms.ModelForm):
'agenda': forms.HiddenInput(),
'start_datetime': DateTimeWidget(),
}
exclude = ['full', 'meeting_type']
exclude = ['full', 'meeting_type', 'desk']
class NewMeetingTypeForm(forms.ModelForm):
@ -69,9 +69,27 @@ class TimePeriodForm(forms.ModelForm):
class Meta:
model = TimePeriod
widgets = {
'agenda': forms.HiddenInput(),
'start_time': widgets.TimeWidget(),
'end_time': widgets.TimeWidget(),
'desk': forms.HiddenInput(),
}
exclude = []
class NewDeskForm(forms.ModelForm):
class Meta:
model = Desk
widgets = {
'agenda': forms.HiddenInput(),
}
exclude = ['slug']
class DeskForm(forms.ModelForm):
class Meta:
model = Desk
widgets = {
'agenda': forms.HiddenInput(),
}
exclude = []

View File

@ -38,3 +38,13 @@ h2 span.identifier {
.time-only-picker tbody td {
width: 200px;
}
.timeperiods {
position: relative;
}
.timeperiod {
float: left;
display: block;
margin: 10px;
}

View File

@ -15,7 +15,7 @@
<a rel="popup" href="{% url 'chrono-manager-agenda-add-event' pk=object.id %}">{% trans 'New Event' %}</a>
{% else %}
<a rel="popup" href="{% url 'chrono-manager-agenda-add-meeting-type' pk=object.id %}">{% trans 'New Meeting Type' %}</a>
<a rel="popup" href="{% url 'chrono-manager-agenda-add-time-period' pk=object.id %}">{% trans 'New Time Period' %}</a>
<a rel="popup" href="{% url 'chrono-manager-agenda-add-desk' pk=object.id %}">{% trans 'New Desk' %}</a>
{% endif %}
{% endif %}
{% endblock %}
@ -105,25 +105,41 @@
{% endif %}
<h3>{% trans 'Time Periods' %}</h3>
{% if object.timeperiod_set.count %}
<div>
<ul class="objects-list single-links">
{% for time_period in object.timeperiod_set.all %}
<li><a rel="popup" href="{% if user_can_manage %}{% url 'chrono-manager-time-period-edit' pk=time_period.id %}{% else %}#{% endif %}">
{{time_period.weekday_str}} / {{time_period.start_time}} → {{time_period.end_time}}
</a>
{% if user_can_manage %}<a rel="popup" class="delete" href="{% url 'chrono-manager-time-period-delete' pk=time_period.id %}">{% trans "remove" %}</a>{% endif %}
</li>
{% endfor %}
</ul>
</div>
{% else %}
<div class="big-msg-info">
{% blocktrans %}
This agenda doesn't have any time period yet. Click on the "New Time Period" button in
the top right of the page to add a first one.
{% endblocktrans %}
</div>
{% if object.desk_set.count %}
<div class="timeperiods">
{% for desk in object.desk_set.all %}
<div class="timeperiod">
{% if object.desk_set.count > 1 %}
<h4>
<a href="{% if user_can_manage %}{% url 'chrono-manager-desk-edit' pk=desk.pk %}{% else %}#{% endif %}">
{{ desk.label }}</a>
</h4>
{% endif %}
{% url 'chrono-manager-agenda-add-time-period' agenda_pk=object.pk pk=desk.pk as add_time_period_url %}
{% if desk.timeperiod_set.count %}
<ul class="objects-list single-links">
{% for time_period in desk.timeperiod_set.all %}
<li><a href="{% if user_can_manage %}{% url 'chrono-manager-time-period-edit' pk=time_period.id %}{% else %}#{% endif %}">
{{time_period.weekday_str}} / {{time_period.start_time}} → {{time_period.end_time}} </a>
</li>
{% endfor %}
<a rel="popup" href="{{add_time_period_url}}">{% trans 'Add' %}</a>
</ul>
{% else %}
<div>
{% trans "This desk doesn't have any time period yet." %} <a rel="popup" href="{{add_time_period_url}}">{% trans "New Time Period" %}</a>
</div>
{% endif %}
</div>
{% endfor %}
</div>
{% else %}
<div>
{% blocktrans %}
This agenda doesn't have any desk yet. Click on the "New Desk" button in
the top right of the page to add a first one.
{% endblocktrans %}
</div>
{% endif %}

View File

@ -0,0 +1,35 @@
{% extends "chrono/manager_agenda_view.html" %}
{% load i18n %}
{% block extrascripts %}
{{ block.super }}
{{ form.media }}
{% endblock %}
{% block breadcrumb %}
{{ block.super }}
{% if object.id %}
<a href="{% url 'chrono-manager-agenda-view' pk=object.agenda.pk %}">{{object.label}}</a>
{% endif %}
{% endblock %}
{% block appbar %}
{% if object.id %}
<h2>{% trans "Edit Desk" %}</h2>
<a rel="popup" href="{% url 'chrono-manager-desk-delete' pk=object.id %}">{% trans 'Delete' %}</a>
{% else %}
<h2>{% trans "New Desk" %}</h2>
{% endif %}
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data">
{% csrf_token %}
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'chrono-manager-agenda-view' pk=agenda.id %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -9,7 +9,7 @@
{% block breadcrumb %}
{{ block.super }}
{% if object.id %}
<a href="{% url 'chrono-manager-agenda-view' pk=object.agenda.id %}">{{object.agenda.label}}</a>
<a href="{% url 'chrono-manager-agenda-view' pk=desk.agenda.id %}">{{object.desk.agenda.label}}</a>
{% endif %}
{% endblock %}
@ -29,7 +29,7 @@
{{ form.as_p }}
<div class="buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{% url 'chrono-manager-agenda-view' pk=agenda.id %}">{% trans 'Cancel' %}</a>
<a class="cancel" href="{% url 'chrono-manager-agenda-view' pk=desk.agenda.id %}">{% trans 'Cancel' %}</a>
</div>
</form>
{% endblock %}

View File

@ -44,13 +44,20 @@ urlpatterns = [
url(r'^meetingtypes/(?P<pk>\w+)/delete$', views.meeting_type_delete,
name='chrono-manager-meeting-type-delete'),
url(r'^agendas/(?P<pk>\w+)/add-time-period$', views.agenda_add_time_period,
url(r'^agendas/(?P<agenda_pk>\d+)/desk/(?P<pk>\d+)/add-time-period$', views.agenda_add_time_period,
name='chrono-manager-agenda-add-time-period'),
url(r'^timeperiods/(?P<pk>\w+)/edit$', views.time_period_edit,
name='chrono-manager-time-period-edit'),
url(r'^timeperiods/(?P<pk>\w+)/delete$', views.time_period_delete,
name='chrono-manager-time-period-delete'),
url(r'^agendas/(?P<pk>\w+)/add-desk$', views.agenda_add_desk,
name='chrono-manager-agenda-add-desk'),
url(r'^desks/(?P<pk>\w+)/edit$', views.desk_edit,
name='chrono-manager-desk-edit'),
url(r'^desks/(?P<pk>\w+)/delete$', views.desk_delete,
name='chrono-manager-desk-delete'),
url(r'^agendas/events.csv$', views.agenda_import_events_sample_csv,
name='chrono-manager-sample-events-csv'),

View File

@ -27,10 +27,11 @@ from django.utils.encoding import force_text
from django.views.generic import (DetailView, CreateView, UpdateView,
ListView, DeleteView, FormView, TemplateView)
from chrono.agendas.models import Agenda, Event, MeetingType, TimePeriod, Booking
from chrono.agendas.models import (Agenda, Event, MeetingType, TimePeriod,
Booking, Desk)
from .forms import (EventForm, NewMeetingTypeForm, MeetingTypeForm,
TimePeriodForm, ImportEventsForm)
TimePeriodForm, ImportEventsForm, NewDeskForm, DeskForm)
class HomepageView(ListView):
@ -57,6 +58,13 @@ class AgendaAddView(CreateView):
raise PermissionDenied()
return super(AgendaAddView, self).dispatch(request, *args, **kwargs)
def form_valid(self, form):
model_form = super(AgendaAddView, self).form_valid(form)
if self.object.kind == 'meetings':
default_desk = Desk(agenda=self.object, label=_('Desk 1'))
default_desk.save()
return model_form
agenda_add = AgendaAddView.as_view()
@ -160,6 +168,50 @@ class ManagedAgendaSubobjectMixin(object):
return self.agenda.get_absolute_url()
class ManagedDeskMixin(object):
desk = None
def dispatch(self, request, *args, **kwargs):
try:
self.desk = Desk.objects.get(id=kwargs.get('pk'))
except Desk.DoesNotExist:
raise Http404()
if not self.desk.agenda.can_be_managed(request.user):
raise PermissionDenied()
return super(ManagedDeskMixin, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(ManagedDeskMixin, self).get_context_data(**kwargs)
context['desk'] = self.desk
return context
def get_initial(self):
initial = super(ManagedDeskMixin, self).get_initial()
initial['desk'] = self.desk
return initial
def get_success_url(self):
return self.desk.agenda.get_absolute_url()
class ManagedDeskSubobjectMixin(object):
desk = None
def dispatch(self, request, *args, **kwargs):
self.desk = self.get_object().desk
if not self.desk.agenda.can_be_managed(request.user):
raise PermissionDenied()
return super(ManagedDeskSubobjectMixin, self).dispatch(request, *args, **kwargs)
def get_context_data(self, **kwargs):
context = super(ManagedDeskSubobjectMixin, self).get_context_data(**kwargs)
context['desk'] = self.object.desk
return context
def get_success_url(self):
return self.desk.agenda.get_absolute_url()
class AgendaAddEventView(ManagedAgendaMixin, CreateView):
template_name = 'chrono/manager_event_form.html'
model = Event
@ -242,15 +294,15 @@ class MeetingTypeDeleteView(ManagedAgendaSubobjectMixin, DeleteView):
meeting_type_delete = MeetingTypeDeleteView.as_view()
class AgendaAddTimePeriodView(ManagedAgendaMixin, CreateView):
class AgendaAddTimePeriodView(ManagedDeskMixin, CreateView):
template_name = 'chrono/manager_time_period_form.html'
model = Event
model = TimePeriod
form_class = TimePeriodForm
agenda_add_time_period = AgendaAddTimePeriodView.as_view()
class TimePeriodEditView(ManagedAgendaSubobjectMixin, UpdateView):
class TimePeriodEditView(ManagedDeskSubobjectMixin, UpdateView):
template_name = 'chrono/manager_time_period_form.html'
model = TimePeriod
form_class = TimePeriodForm
@ -258,13 +310,39 @@ class TimePeriodEditView(ManagedAgendaSubobjectMixin, UpdateView):
time_period_edit = TimePeriodEditView.as_view()
class TimePeriodDeleteView(ManagedAgendaSubobjectMixin, DeleteView):
class TimePeriodDeleteView(ManagedDeskSubobjectMixin, DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = TimePeriod
time_period_delete = TimePeriodDeleteView.as_view()
class AgendaAddDesk(ManagedAgendaMixin, CreateView):
template_name = 'chrono/manager_desk_form.html'
model = Desk
form_class = NewDeskForm
agenda_add_desk = AgendaAddDesk.as_view()
class DeskEditView(ManagedAgendaSubobjectMixin, UpdateView):
template_name = 'chrono/manager_desk_form.html'
model = Desk
form_class = DeskForm
desk_edit = DeskEditView.as_view()
class DeskDeleteView(ManagedAgendaSubobjectMixin, DeleteView):
template_name = 'chrono/manager_confirm_delete.html'
model = Desk
desk_delete = DeskDeleteView.as_view()
def menu_json(request):
response = HttpResponse(content_type='application/json')
label = _('Agendas')

3
debian/control vendored
View File

@ -10,7 +10,8 @@ Package: python-chrono
Architecture: all
Depends: ${misc:Depends}, ${python:Depends},
python-django (>= 1.8),
python-gadjo
python-gadjo,
python-intervaltree
Recommends: python-django-mellon
Description: Agendas System (Python module)

View File

@ -106,6 +106,7 @@ setup(
'gadjo',
'djangorestframework>=3.1',
'django-jsonfield >= 0.9.3',
'intervaltree',
],
zip_safe=False,
cmdclass={

View File

@ -1,8 +1,6 @@
import pytest
import datetime
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from django.utils.timezone import now
from chrono.agendas.models import Agenda, Event, Booking, MeetingType
@ -68,27 +66,6 @@ def test_event_bookable_period():
event.save()
assert event.in_bookable_period() is False
def test_meeting_type_slug_migration():
executor = MigrationExecutor(connection)
migrate_from = [('agendas', '0011_meetingtype_slug')]
migrate_to = [('agendas', '0013_auto_20161028_1603')]
executor.migrate(migrate_from)
executor.loader.build_graph()
old_apps = executor.loader.project_state(migrate_from).apps
OldAgenda = old_apps.get_model('agendas', 'Agenda')
OldMeetingType = old_apps.get_model('agendas', 'MeetingType')
agenda = OldAgenda(label=u'Foo bar')
agenda.save()
meeting_type = OldMeetingType(agenda=agenda, label=u'Baz')
meeting_type.save()
executor.migrate(migrate_to)
assert MeetingType.objects.get(id=meeting_type.id).slug == 'baz'
def test_meeting_type_slugs():
agenda1 = Agenda(label=u'Foo bar')
agenda1.save()

View File

@ -5,10 +5,12 @@ import urlparse
from webtest import TestApp
from django.contrib.auth import get_user_model
from django.db import connection
from django.test import override_settings
from django.test.utils import CaptureQueriesContext
from django.utils.timezone import now, make_aware, localtime
from chrono.agendas.models import Agenda, Event, Booking, MeetingType, TimePeriod
from chrono.agendas.models import Agenda, Event, Booking, MeetingType, TimePeriod, Desk
import chrono.api.views
@ -76,11 +78,13 @@ def meetings_agenda(time_zone, mock_now):
test_1st_weekday = (localtime(now()).weekday() + 2) % 7
test_2nd_weekday = (localtime(now()).weekday() + 3) % 7
time_period = TimePeriod(agenda=agenda, weekday=test_1st_weekday,
start_time=datetime.time(10, 0), end_time=datetime.time(12, 0))
default_desk, created = Desk.objects.get_or_create(agenda=agenda, label='Desk 1')
time_period = TimePeriod(weekday=test_1st_weekday,
start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=default_desk)
time_period.save()
time_period = TimePeriod(agenda=agenda, weekday=test_2nd_weekday,
start_time=datetime.time(10, 0), end_time=datetime.time(17, 0))
time_period = TimePeriod(weekday=test_2nd_weekday,
start_time=datetime.time(10, 0), end_time=datetime.time(17, 0), desk=default_desk)
time_period.save()
return agenda
@ -204,7 +208,7 @@ def test_datetimes_api_meetings_agenda(app, meetings_agenda):
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
ev = Event(agenda=meetings_agenda, meeting_type=meeting_type,
places=1, full=False, start_datetime=make_aware(dt))
places=1, full=False, start_datetime=make_aware(dt), desk=Desk.objects.first())
ev.save()
booking = Booking(event=ev)
booking.save()
@ -224,11 +228,12 @@ def test_datetimes_api_meetings_agenda(app, meetings_agenda):
# skip this part of the test as it would require support for events
# crossing midnight
return
TimePeriod.objects.filter(agenda=meetings_agenda).delete()
default_desk, _ = Desk.objects.get_or_create(agenda=meetings_agenda, slug='desk-1')
TimePeriod.objects.filter(desk=default_desk).delete()
start_time = localtime(now()) - datetime.timedelta(minutes=10)
time_period = TimePeriod(agenda=meetings_agenda, weekday=localtime(now()).weekday(),
time_period = TimePeriod(weekday=localtime(now()).weekday(),
start_time=start_time,
end_time=start_time + datetime.timedelta(hours=1))
end_time=start_time + datetime.timedelta(hours=1), desk=default_desk)
time_period.save()
meetings_agenda.minimal_booking_delay = 0
meetings_agenda.maximal_booking_delay = 10
@ -284,7 +289,7 @@ def test_booking_api_meeting(app, meetings_agenda, user):
# try booking the same timeslot
resp2 = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
assert resp2.json['err'] == 1
assert resp2.json['reason'] == 'sold out'
assert resp2.json['reason'] == 'no more desk available'
# try booking another timeslot
event_id = resp.json['data'][3]['id']
@ -690,3 +695,86 @@ def test_agenda_api_date_range(app, some_data):
assert len(resp.json['data']) == 3
assert resp.json['data'][0]['datetime'] == '2017-05-30 09:00:00'
assert resp.json['data'][-1]['datetime'] == '2017-05-30 11:00:00'
def test_agenda_meeting_api_multiple_desk(app, meetings_agenda, user):
app.authorization = ('Basic', ('john.doe', 'password'))
agenda_id = meetings_agenda.slug
meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
# add booking of another meeting type
meeting_type2 = MeetingType.objects.create(agenda=meetings_agenda, label='Tux kart', duration=60)
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type2.id)
event_id = resp.json['data'][0]['id']
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
cancel_url = resp.json['api']['cancel_url']
# add a second desk
time_period = meetings_agenda.desk_set.first().timeperiod_set.first()
desk2 = Desk.objects.create(label='Desk 2', agenda=meetings_agenda)
TimePeriod.objects.create(
start_time=time_period.start_time, end_time=time_period.end_time,
weekday=time_period.weekday, desk=desk2)
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
event_id = resp.json['data'][1]['id']
resp_booking = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
assert Booking.objects.count() == 2
assert resp_booking.json['datetime'][:16] == localtime(Booking.objects.last().event.start_datetime
).isoformat()[:16]
resp2 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
assert len(resp.json['data']) == len([x for x in resp2.json['data'] if not x['disabled']]) + 1
# try booking the same timeslot and fail
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
assert Booking.objects.count() == 2
assert resp.json['err'] == 1
assert resp.json['reason'] == 'no more desk available'
# cancel first booking and retry
resp = app.post(cancel_url)
# capture number of queries made for datetime endpoint with few bookings
with CaptureQueriesContext(connection) as ctx:
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
queries_count_datetime1 = len(ctx.captured_queries)
assert len(resp2.json['data']) == len([x for x in resp.json['data'] if not x['disabled']])
# capture number of queries made for fillslot endpoint with few bookings
with CaptureQueriesContext(connection) as ctx:
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
queries_count_fillslot1 = len(ctx.captured_queries)
assert resp_booking.json['datetime'][:16] == localtime(Booking.objects.last().event.start_datetime
).isoformat()[:16]
cancel_url = resp.json['api']['cancel_url']
resp3 = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
assert len(resp2.json['data']) == len([x for x in resp3.json['data'] if not x['disabled']]) + 1
# cancel a booking
resp = app.post(cancel_url)
resp = app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
assert len(resp.json['data']) == len(resp2.json['data'])
# try booking the same slot to make sure that cancelled booking has freed the slot
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
assert Booking.objects.count() == 4
assert Booking.objects.exclude(cancellation_datetime__isnull=True).count() == 2
assert resp_booking.json['datetime'][:16] == localtime(Booking.objects.last().event.start_datetime
).isoformat()[:16]
# try booking the same timeslot again and fail
resp = app.post('/api/agenda/%s/fillslot/%s/' % (agenda_id, event_id))
assert resp.json['err'] == 1
assert resp.json['reason'] == 'no more desk available'
# fill the agenda and make sure big O is O(1)
for idx, event_data in enumerate(resp2.json['data'][2:10]):
booking_url = event_data['api']['fillslot_url']
with CaptureQueriesContext(connection) as ctx:
app.post(booking_url)
assert len(ctx.captured_queries) == queries_count_fillslot1
with CaptureQueriesContext(connection) as ctx:
app.get('/api/agenda/meetings/%s/datetimes/' % meeting_type.id)
assert queries_count_datetime1 == len(ctx.captured_queries)

View File

@ -0,0 +1,77 @@
import datetime
import pytest
from django.db import connection
from django.db.migrations.executor import MigrationExecutor
from chrono.agendas.models import MeetingType
pytestmark = pytest.mark.django_db
def test_meeting_type_slug_migration():
executor = MigrationExecutor(connection)
migrate_from = [('agendas', '0011_meetingtype_slug')]
migrate_to = [('agendas', '0013_auto_20161028_1603')]
executor.migrate(migrate_from)
executor.loader.build_graph()
old_apps = executor.loader.project_state(migrate_from).apps
OldAgenda = old_apps.get_model('agendas', 'Agenda')
OldMeetingType = old_apps.get_model('agendas', 'MeetingType')
agenda = OldAgenda(label=u'Foo bar')
agenda.save()
meeting_type = OldMeetingType(agenda=agenda, label=u'Baz')
meeting_type.save()
executor.migrate(migrate_to)
assert MeetingType.objects.get(id=meeting_type.id).slug == 'baz'
def test_timepriod_data_migrations(transactional_db):
executor = MigrationExecutor(connection)
app = 'agendas'
migrate_from = [(app, '0016_desk')]
migrate_to = [(app, '0018_event_desk')]
executor.migrate(migrate_from)
old_apps = executor.loader.project_state(migrate_from).apps
Agenda = old_apps.get_model(app, 'Agenda')
MeetingType = old_apps.get_model(app, 'MeetingType')
TimePeriod = old_apps.get_model(app, 'TimePeriod')
Event = old_apps.get_model(app, 'Event')
agenda = Agenda.objects.create(label='foo', slug='foo', kind='meetings')
agenda2 = Agenda.objects.create(label='bar', slug='bar', kind='events')
TimePeriod.objects.create(agenda=agenda, weekday=1,
start_time=datetime.time(8, 0),
end_time=datetime.time(12, 0))
TimePeriod.objects.create(agenda=agenda, weekday=2,
start_time=datetime.time(8, 0),
end_time=datetime.time(10, 0))
TimePeriod.objects.create(agenda=agenda, weekday=3,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
meeting_type = MeetingType.objects.create(agenda=agenda, label='foo',
slug='foo', duration=60)
Event.objects.create(agenda=agenda, places=1, meeting_type=meeting_type,
start_datetime=datetime.datetime(2017, 5, 22, 9, 30))
Event.objects.create(agenda=agenda, places=1, meeting_type=meeting_type,
start_datetime=datetime.datetime(2017, 5, 22, 10, 0))
Event.objects.create(agenda=agenda2, places=5,
start_datetime=datetime.datetime(2017, 5, 22, 10, 0))
executor.loader.build_graph()
executor.migrate(migrate_to)
new_apps = executor.loader.project_state(migrate_to).apps
Agenda = new_apps.get_model(app, 'Agenda')
agenda = Agenda.objects.get(slug='foo')
TimePeriod = new_apps.get_model(app, 'TimePeriod')
for time_period in TimePeriod.objects.all():
assert time_period.desk.label == 'Guichet 1'
assert time_period.desk.slug == 'guichet-1'
Event = new_apps.get_model(app, 'Event')
for event in Event.objects.filter(agenda=agenda):
assert event.desk.label == 'Guichet 1'
assert event.desk.slug == 'guichet-1'

View File

@ -9,7 +9,7 @@ import tempfile
import pytest
from django.core.management import call_command
from chrono.agendas.models import Agenda, Event, MeetingType, TimePeriod
from chrono.agendas.models import Agenda, Event, MeetingType, TimePeriod, Desk
from chrono.manager.utils import export_site, import_site
from test_api import some_data, meetings_agenda, time_zone, mock_now
@ -51,17 +51,19 @@ def test_import_export(app, some_data, meetings_agenda):
agenda2 = Agenda.objects.get(label=u'Foo bar Meeting')
event = Event(agenda=agenda1, start_datetime= datetime.datetime.now(), places=10)
event.save()
timeperiod = TimePeriod(agenda=agenda2, weekday=2,
desk, _ = Desk.objects.get_or_create(agenda=agenda2, label='Desk A', slug='desk-a')
timeperiod = TimePeriod(weekday=2, desk=desk,
start_time=datetime.time(10, 0), end_time=datetime.time(11, 0))
timeperiod.save()
import_site(json.loads(output), overwrite=True)
assert Event.objects.filter(id=event.id).count() == 0
assert Desk.objects.filter(slug='desk-a').count() == 0
assert TimePeriod.objects.filter(id=timeperiod.id).count() == 0
event = Event(agenda=agenda1, start_datetime= datetime.datetime.now(), places=10)
event.save()
timeperiod = TimePeriod(agenda=agenda2, weekday=2,
timeperiod = TimePeriod(weekday=2, desk=desk,
start_time=datetime.time(10, 0), end_time=datetime.time(11, 0))
timeperiod.save()
import_site(json.loads(output), overwrite=False)

View File

@ -8,7 +8,8 @@ from webtest import TestApp, Upload
from chrono.wsgi import application
from chrono.agendas.models import Agenda, Event, Booking, MeetingType, TimePeriod
from chrono.agendas.models import (Agenda, Event, Booking, MeetingType,
TimePeriod, Desk)
pytestmark = pytest.mark.django_db
@ -569,23 +570,24 @@ def test_meetings_agenda_delete_meeting_type(app, admin_user):
def test_meetings_agenda_add_time_period(app, admin_user):
agenda = Agenda(label=u'Foo bar', kind='meetings')
agenda.save()
desk = Desk.objects.create(agenda=agenda, label='Desk A')
app = login(app)
resp = app.get('/manage/agendas/%s/' % agenda.id, status=200)
assert "This agenda doesn't have any time period yet." in resp.body
assert "This desk doesn't have any time period yet." in resp.body
resp = resp.click('New Time Period')
resp.form['weekday'].select(text='Wednesday')
resp.form['start_time'] = '10:00'
resp.form['end_time'] = '17:00'
resp = resp.form.submit()
assert TimePeriod.objects.get(agenda=agenda).weekday == 2
assert TimePeriod.objects.get(agenda=agenda).start_time.hour == 10
assert TimePeriod.objects.get(agenda=agenda).start_time.minute == 0
assert TimePeriod.objects.get(agenda=agenda).end_time.hour == 17
assert TimePeriod.objects.get(agenda=agenda).end_time.minute == 0
assert TimePeriod.objects.get(desk=desk).weekday == 2
assert TimePeriod.objects.get(desk=desk).start_time.hour == 10
assert TimePeriod.objects.get(desk=desk).start_time.minute == 0
assert TimePeriod.objects.get(desk=desk).end_time.hour == 17
assert TimePeriod.objects.get(desk=desk).end_time.minute == 0
resp = resp.follow()
# add a second time period
resp = resp.click('New Time Period')
resp = resp.click('Add')
resp.form['weekday'].select(text='Monday')
resp.form['start_time'] = '10:00'
resp.form['end_time'] = '13:00'
@ -600,13 +602,15 @@ def test_meetings_agenda_add_time_period(app, admin_user):
assert 'Edit Time Period' in resp.body
resp.form['start_time'] = '9:00'
resp = resp.form.submit()
assert TimePeriod.objects.get(agenda=agenda, weekday=2).start_time.hour == 9
assert TimePeriod.objects.get(desk=desk, weekday=2).start_time.hour == 9
def test_meetings_agenda_delete_time_period(app, admin_user):
agenda = Agenda(label=u'Foo bar', kind='meetings')
agenda.save()
time_period = TimePeriod(agenda=agenda, weekday=2,
desk = Desk.objects.create(agenda=agenda, label='Desk A')
time_period = TimePeriod(desk=desk, weekday=2,
start_time=datetime.time(10, 0),
end_time=datetime.time(18, 0))
time_period.save()
@ -618,3 +622,83 @@ def test_meetings_agenda_delete_time_period(app, admin_user):
resp = resp.form.submit()
assert resp.location == 'http://testserver/manage/agendas/%s/' % agenda.id
assert TimePeriod.objects.count() == 0
def test_meetings_agenda_add_time_period_on_missing_desk(app, admin_user):
app = login(app)
agenda = Agenda(label=u'Foo bar', kind='meetings')
agenda.save()
app.get('/manage/agendas/1/desk/777/add-time-period', status=404)
def test_meetings_agenda_add_time_period_as_manager(app, manager_user):
agenda = Agenda(label='Foo bar', kind='meetings')
agenda.view_role = manager_user.groups.all()[0]
agenda.save()
desk = Desk.objects.create(agenda=agenda, label='Desk A')
app = login(app, username='manager', password='manager')
resp = app.get('/manage/agendas/%d/' % agenda.id)
assert 'New Meeting Type' in resp.content
assert 'New Time Period' in resp.content
assert 'This desk doesn\'t have any time period yet.' in resp.content
app.get('/manage/agendas/%d/desk/%d/add-time-period' % (agenda.id, desk.id), status=403)
time_period = TimePeriod(desk=desk, weekday=0, start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
time_period.save()
app.get('/manage/timeperiods/%d/edit' % time_period.id, status=403)
# grant edit right to manager
agenda.edit_role = manager_user.groups.all()[0]
agenda.save()
app.get('/manage/agendas/%d/desk/%d/add-time-period' % (agenda.id, desk.id), status=200)
app.get('/manage/timeperiods/%d/edit' % time_period.id, status=200)
def test_meetings_agenda_add_desk(app, admin_user):
app = login(app)
resp = app.get('/manage/', status=200)
resp = resp.click('New')
resp.form['label'] = 'Foo bar'
resp.form['kind'] = 'meetings'
resp = resp.form.submit()
assert Desk.objects.count() == 1
assert str(Desk.objects.first()) == 'Desk 1'
agenda = Agenda.objects.get(slug='foo-bar')
resp = app.get('/manage/agendas/%s/' % agenda.id, status=200)
resp = resp.click('New Desk')
resp.form['label'] = 'Desk A'
resp = resp.form.submit().follow()
assert Desk.objects.count() == 2
resp = resp.click('New Desk')
resp.form['label'] = 'Desk A'
resp = resp.form.submit().follow()
assert Desk.objects.count() == 3
assert Desk.objects.filter(slug='desk-a-1').count() == 1
assert 'Desk A' in resp.text
resp = resp.click('Desk A', index=1)
resp.form['label'] = 'Desk B'
resp = resp.form.submit().follow()
assert 'Desk A' in resp.text
assert 'Desk B' in resp.text
def test_meetings_agenda_delete_desk(app, admin_user):
app = login(app)
resp = app.get('/manage/', status=200)
resp = resp.click('New')
resp.form['label'] = 'Foo bar'
resp.form['kind'] = 'meetings'
resp = resp.form.submit()
assert Desk.objects.count() == 1
agenda = Agenda.objects.get(slug='foo-bar')
resp = app.get('/manage/agendas/%s/' % agenda.id, status=200)
resp = resp.click('New Desk')
resp.form['label'] = 'Desk A'
resp = resp.form.submit().follow()
assert Desk.objects.count() == 2
resp = resp.click('Desk A', index=0)
resp = resp.click('Delete')
resp = resp.form.submit()
assert resp.location == 'http://testserver/manage/agendas/%s/' % agenda.id
assert Desk.objects.count() == 1

View File

@ -3,7 +3,7 @@ import pytest
from django.utils.timezone import make_aware
from chrono.agendas.models import Agenda, TimePeriod, MeetingType
from chrono.agendas.models import Agenda, TimePeriod, MeetingType, Desk
pytestmark = pytest.mark.django_db
@ -11,7 +11,8 @@ pytestmark = pytest.mark.django_db
def test_timeperiod_time_slots():
agenda = Agenda(label=u'Foo bar', slug='bar')
agenda.save()
timeperiod = TimePeriod(agenda=agenda, weekday=0,
desk = Desk.objects.create(label='Desk 1', agenda=agenda)
timeperiod = TimePeriod(desk=desk, weekday=0,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
events = timeperiod.get_time_slots(
@ -28,7 +29,7 @@ def test_timeperiod_time_slots():
assert len(events) == 12
# another start before the timeperiod
timeperiod = TimePeriod(agenda=agenda, weekday=1,
timeperiod = TimePeriod(desk=desk, weekday=1,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
events = timeperiod.get_time_slots(
@ -41,7 +42,7 @@ def test_timeperiod_time_slots():
assert len(events) == 12
# a start on the day of the timeperiod
timeperiod = TimePeriod(agenda=agenda, weekday=3,
timeperiod = TimePeriod(desk=desk, weekday=3,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
events = timeperiod.get_time_slots(
@ -54,7 +55,7 @@ def test_timeperiod_time_slots():
assert len(events) == 15
# a start after the day of the timeperiod
timeperiod = TimePeriod(agenda=agenda, weekday=4,
timeperiod = TimePeriod(desk=desk, weekday=4,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
events = timeperiod.get_time_slots(
@ -67,7 +68,7 @@ def test_timeperiod_time_slots():
assert len(events) == 15
# another start after the day of the timeperiod
timeperiod = TimePeriod(agenda=agenda, weekday=5,
timeperiod = TimePeriod(desk=desk, weekday=5,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
events = timeperiod.get_time_slots(
@ -80,7 +81,7 @@ def test_timeperiod_time_slots():
assert len(events) == 12
# shorter duration -> double the events
timeperiod = TimePeriod(agenda=agenda, weekday=5,
timeperiod = TimePeriod(desk=desk, weekday=5,
start_time=datetime.time(9, 0),
end_time=datetime.time(12, 0))
events = timeperiod.get_time_slots(