From e464afef43a9bdda385aba696d13c01e0f5ea243 Mon Sep 17 00:00:00 2001
From: Emmanuel Cazenave <ecazenave@entrouvert.com>
Date: Thu, 6 Feb 2020 17:49:38 +0100
Subject: [PATCH 1/2] start virtual agendas (#37123)

---
 .../migrations/0038_start_virtual_agendas.py  |  58 +++
 chrono/agendas/models.py                      |  70 ++-
 chrono/api/views.py                           | 109 +++--
 tests/test_agendas.py                         |  47 ++
 tests/test_api.py                             | 453 +++++++++++++++++-
 5 files changed, 689 insertions(+), 48 deletions(-)
 create mode 100644 chrono/agendas/migrations/0038_start_virtual_agendas.py

diff --git a/chrono/agendas/migrations/0038_start_virtual_agendas.py b/chrono/agendas/migrations/0038_start_virtual_agendas.py
new file mode 100644
index 0000000..92fecae
--- /dev/null
+++ b/chrono/agendas/migrations/0038_start_virtual_agendas.py
@@ -0,0 +1,58 @@
+# -*- coding: utf-8 -*-
+# Generated by Django 1.11.18 on 2020-02-20 12:15
+from __future__ import unicode_literals
+
+from django.db import migrations, models
+import django.db.models.deletion
+
+
+class Migration(migrations.Migration):
+
+    dependencies = [
+        ('agendas', '0037_timeperiodexceptionsource_ics_file'),
+    ]
+
+    operations = [
+        migrations.CreateModel(
+            name='VirtualMember',
+            fields=[
+                (
+                    'id',
+                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+                ),
+            ],
+        ),
+        migrations.AlterField(
+            model_name='agenda',
+            name='kind',
+            field=models.CharField(
+                choices=[('events', 'Events'), ('meetings', 'Meetings'), ('virtual', 'Virtual')],
+                default='events',
+                max_length=20,
+                verbose_name='Kind',
+            ),
+        ),
+        migrations.AddField(
+            model_name='virtualmember',
+            name='real_agenda',
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE,
+                related_name='virtual_members',
+                to='agendas.Agenda',
+            ),
+        ),
+        migrations.AddField(
+            model_name='virtualmember',
+            name='virtual_agenda',
+            field=models.ForeignKey(
+                on_delete=django.db.models.deletion.CASCADE, related_name='real_members', to='agendas.Agenda'
+            ),
+        ),
+        migrations.AddField(
+            model_name='agenda',
+            name='real_agendas',
+            field=models.ManyToManyField(
+                related_name='virtual_agendas', through='agendas.VirtualMember', to='agendas.Agenda'
+            ),
+        ),
+    ]
diff --git a/chrono/agendas/models.py b/chrono/agendas/models.py
index 79ee1a8..fe89fef 100644
--- a/chrono/agendas/models.py
+++ b/chrono/agendas/models.py
@@ -43,6 +43,7 @@ from ..interval import Intervals
 AGENDA_KINDS = (
     ('events', _('Events')),
     ('meetings', _('Meetings')),
+    ('virtual', _('Virtual')),
 )
 
 
@@ -77,6 +78,13 @@ class Agenda(models.Model):
     maximal_booking_delay = models.PositiveIntegerField(
         _('Maximal booking delay (in days)'), default=56
     )  # eight weeks
+    real_agendas = models.ManyToManyField(
+        'self',
+        related_name='virtual_agendas',
+        symmetrical=False,
+        through='VirtualMember',
+        through_fields=('virtual_agenda', 'real_agenda'),
+    )
     edit_role = models.ForeignKey(
         Group,
         blank=True,
@@ -119,8 +127,57 @@ class Agenda(models.Model):
         group_ids = [x.id for x in user.groups.all()]
         return bool(self.view_role_id in group_ids)
 
+    def accept_meetings(self):
+        if self.kind == 'virtual':
+            return not self.real_agendas.filter(kind='events').exists()
+        return self.kind == 'meetings'
+
+    def get_real_agendas(self):
+        if self.kind == 'virtual':
+            return self.real_agendas.all()
+        return [self]
+
+    def iter_meetingtypes(self):
+        """ Expose agenda's meetingtypes.
+        straighforward on a real agenda
+        On a virtual agenda we expose transient meeting types based on on the
+        the real ones shared by every real agendas.
+        """
+        if self.kind == 'virtual':
+            queryset = (
+                MeetingType.objects.filter(agenda__virtual_agendas__in=[self])
+                .values('slug', 'duration', 'label')
+                .annotate(total=Count('*'))
+                .filter(total=self.real_agendas.count())
+            )
+            return [
+                MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'])
+                for mt in queryset.order_by('slug')
+            ]
+
+        return self.meetingtype_set.all().order_by('slug')
+
+    def get_meetingtype(self, id_=None, slug=None):
+        match = id_ or slug
+        assert match, 'an identifier or a slug should be specified'
+
+        if self.kind == 'virtual':
+            match = id_ or slug
+            meeting_type = None
+            for mt in self.iter_meetingtypes():
+                if mt.slug == match:
+                    meeting_type = mt
+                    break
+            if meeting_type is None:
+                raise MeetingType.DoesNotExist()
+            return meeting_type
+
+        if id_:
+            return MeetingType.objects.get(id=id_, agenda=self)
+        return MeetingType.objects.get(slug=slug, agenda=self)
+
     def get_base_meeting_duration(self):
-        durations = [x.duration for x in MeetingType.objects.filter(agenda=self)]
+        durations = [x.duration for x in self.iter_meetingtypes()]
         if not durations:
             raise ValueError()
         gcd = durations[0]
@@ -129,6 +186,7 @@ class Agenda(models.Model):
         return gcd
 
     def export_json(self):
+        # TODO VIRTUAL
         agenda = {
             'label': self.label,
             'slug': self.slug,
@@ -185,6 +243,11 @@ class Agenda(models.Model):
         return created
 
 
+class VirtualMember(models.Model):
+    virtual_agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE, related_name='real_members')
+    real_agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE, related_name='virtual_members')
+
+
 WEEKDAYS_LIST = sorted(WEEKDAYS.items(), key=lambda x: x[0])
 
 
@@ -193,7 +256,7 @@ class TimeSlot(object):
         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'))
+        self.id = '%s:%s' % (meeting_type.id or meeting_type.slug, start_datetime.strftime('%Y-%m-%d-%H%M'))
         self.desk = desk
 
     def __str__(self):
@@ -276,6 +339,7 @@ class MeetingType(models.Model):
         unique_together = ['agenda', 'slug']
 
     def save(self, *args, **kwargs):
+        assert self.agenda.kind != 'virtual', "a meetingtype can't reference a virtual agenda"
         if not self.slug:
             self.slug = generate_slug(self, agenda=self.agenda)
         super(MeetingType, self).save(*args, **kwargs)
@@ -330,6 +394,7 @@ class Event(models.Model):
         return date_format(localtime(self.start_datetime), format='DATETIME_FORMAT')
 
     def save(self, *args, **kwargs):
+        assert self.agenda.kind != 'virtual', "an event can't reference a virtual agenda"
         self.check_full()
         return super(Event, self).save(*args, **kwargs)
 
@@ -515,6 +580,7 @@ class Desk(models.Model):
         unique_together = ['agenda', 'slug']
 
     def save(self, *args, **kwargs):
+        assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda"
         if not self.slug:
             self.slug = generate_slug(self, agenda=self.agenda)
         super(Desk, self).save(*args, **kwargs)
diff --git a/chrono/api/views.py b/chrono/api/views.py
index 2eaec49..659782a 100644
--- a/chrono/api/views.py
+++ b/chrono/api/views.py
@@ -63,41 +63,54 @@ def get_all_slots(agenda, meeting_type):
     }
 
     base_date = now().date()
-    open_slots_by_desk = defaultdict(lambda: Intervals())
-    for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
-        duration = (
-            datetime.datetime.combine(base_date, time_period.end_time)
-            - datetime.datetime.combine(base_date, time_period.start_time)
-        ).seconds / 60
-        if duration < meeting_type.duration:
-            # skip time period that can't even hold a single meeting
-            continue
-        for slot in time_period.get_time_slots(**time_period_filters):
-            slot.full = False
-            open_slots_by_desk[time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
+
+    agendas = agenda.get_real_agendas()
+
+    open_slots = {}
+    for agenda in agendas:
+        open_slots[agenda] = defaultdict(lambda: Intervals())
+
+    for agenda in agendas:
+        for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
+            duration = (
+                datetime.datetime.combine(base_date, time_period.end_time)
+                - datetime.datetime.combine(base_date, time_period.start_time)
+            ).seconds / 60
+            if duration < meeting_type.duration:
+                # skip time period that can't even hold a single meeting
+                continue
+            for slot in time_period.get_time_slots(**time_period_filters):
+                slot.full = False
+                open_slots[agenda][time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
 
     # remove excluded slot
-    excluded_slot_by_desk = get_exceptions_by_desk(agenda)
-    for desk, excluded_interval in excluded_slot_by_desk.items():
-        for interval in excluded_interval:
-            begin, end = interval
-            open_slots_by_desk[desk].remove_overlap(localtime(begin), localtime(end))
-
-    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')
-        .exclude(booking__cancellation_datetime__isnull=False)
-    ):
-        for slot in open_slots_by_desk[event.desk_id].search_data(event.start_datetime, event.end_datetime):
-            slot.full = True
+    for agenda in agendas:
+        excluded_slot_by_desk = get_exceptions_by_desk(agenda)
+
+        for desk, excluded_interval in excluded_slot_by_desk.items():
+            for interval in excluded_interval:
+                begin, end = interval
+                open_slots[agenda][desk].remove_overlap(localtime(begin), localtime(end))
+
+    for agenda in agendas:
+        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')
+            .exclude(booking__cancellation_datetime__isnull=False)
+        ):
+            for slot in open_slots[agenda][event.desk_id].search_data(
+                event.start_datetime, event.end_datetime
+            ):
+                slot.full = True
 
     slots = []
-    for desk in open_slots_by_desk:
-        slots.extend(open_slots_by_desk[desk].iter_data())
+    for agenda in agendas:
+        for desk in open_slots[agenda]:
+            slots.extend(open_slots[agenda][desk].iter_data())
     slots.sort(key=lambda slot: slot.start_datetime)
     return slots
 
@@ -118,7 +131,7 @@ def get_agenda_detail(request, agenda):
                 reverse('api-agenda-datetimes', kwargs={'agenda_identifier': agenda.slug})
             )
         }
-    elif agenda.kind == 'meetings':
+    elif agenda.accept_meetings():
         agenda_detail['api'] = {
             'meetings_url': request.build_absolute_uri(
                 reverse('api-agenda-meetings', kwargs={'agenda_identifier': agenda.slug})
@@ -270,14 +283,13 @@ class MeetingDatetimes(APIView):
             if agenda_identifier is None:
                 # legacy access by meeting id
                 meeting_type = MeetingType.objects.get(id=meeting_identifier)
+                agenda = meeting_type.agenda
             else:
-                meeting_type = MeetingType.objects.get(
-                    slug=meeting_identifier, agenda__slug=agenda_identifier
-                )
-        except (ValueError, MeetingType.DoesNotExist):
-            raise Http404()
+                agenda = Agenda.objects.get(slug=agenda_identifier)
+                meeting_type = agenda.get_meetingtype(slug=meeting_identifier)
 
-        agenda = meeting_type.agenda
+        except (ValueError, MeetingType.DoesNotExist, Agenda.DoesNotExist):
+            raise Http404()
 
         now_datetime = now()
 
@@ -328,11 +340,11 @@ class MeetingList(APIView):
             agenda = Agenda.objects.get(slug=agenda_identifier)
         except Agenda.DoesNotExist:
             raise Http404()
-        if agenda.kind != 'meetings':
-            raise Http404('agenda found, but it was not a meetings agenda')
+        if not agenda.accept_meetings():
+            raise Http404('agenda found, but it does not accept meetings')
 
         meeting_types = []
-        for meeting_type in agenda.meetingtype_set.all():
+        for meeting_type in agenda.iter_meetingtypes():
             meeting_types.append(
                 {
                     'text': meeting_type.label,
@@ -517,7 +529,7 @@ class Fillslots(APIView):
 
         available_desk = None
 
-        if agenda.kind == 'meetings':
+        if agenda.accept_meetings():
             # slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
             # split them back to get both parts
             meeting_type_id = slots[0].split(':')[0]
@@ -548,12 +560,13 @@ class Fillslots(APIView):
                 datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
 
             # get all free slots and separate them by desk
-            all_slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id))
+            all_slots = get_all_slots(agenda, agenda.get_meetingtype(id_=meeting_type_id))
             all_slots = [slot for slot in all_slots if not slot.full]
             datetimes_by_desk = defaultdict(set)
             for slot in all_slots:
                 datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
 
+            # TODO: fill policy for virtual agendas
             # search first desk where all requested slots are free
             for available_desk_id in sorted(datetimes_by_desk.keys()):
                 if datetimes.issubset(datetimes_by_desk[available_desk_id]):
@@ -572,13 +585,19 @@ class Fillslots(APIView):
             datetimes = list(datetimes)
             datetimes.sort()
 
+            # get a real meeting_type for virtual agenda
+            if agenda.kind == 'virtual':
+                meeting_type_id = MeetingType.objects.get(
+                    agenda=available_desk.agenda, slug=meeting_type_id
+                ).pk
+
             # booking requires real Event objects (not lazy Timeslots);
             # create them now, with data from the slots and the desk we found.
             events = []
             for start_datetime in datetimes:
                 events.append(
                     Event.objects.create(
-                        agenda=agenda,
+                        agenda=available_desk.agenda,
                         meeting_type_id=meeting_type_id,
                         start_datetime=start_datetime,
                         full=False,
@@ -658,7 +677,7 @@ class Fillslots(APIView):
             response['api']['accept_url'] = request.build_absolute_uri(
                 reverse('api-accept-booking', kwargs={'booking_pk': primary_booking.id})
             )
-        if agenda.kind == 'meetings':
+        if agenda.accept_meetings():
             response['end_datetime'] = format_response_datetime(events[-1].end_datetime)
             response['duration'] = (events[-1].end_datetime - events[-1].start_datetime).seconds // 60
         if available_desk:
diff --git a/tests/test_agendas.py b/tests/test_agendas.py
index 5e99c24..e1a22ce 100644
--- a/tests/test_agendas.py
+++ b/tests/test_agendas.py
@@ -18,6 +18,7 @@ from chrono.agendas.models import (
     MeetingType,
     TimePeriodException,
     TimePeriodExceptionSource,
+    VirtualMember,
 )
 
 pytestmark = pytest.mark.django_db
@@ -537,3 +538,49 @@ def test_event_bookings_annotation():
         elif event.label == 'bar':
             assert event.booked_places_count == 3
             assert event.waiting_list_count == 0
+
+
+def test_virtual_agenda_init():
+    agenda1 = Agenda.objects.create(label=u'Agenda 1', kind='meetings')
+    agenda2 = Agenda.objects.create(label=u'Agenda 2', kind='meetings')
+    virt_agenda = Agenda.objects.create(label=u'Virtual agenda', kind='virtual')
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2)
+    virt_agenda.save()
+
+    assert virt_agenda.real_agendas.count() == 2
+    assert virt_agenda.real_agendas.get(pk=agenda1.pk)
+    assert virt_agenda.real_agendas.get(pk=agenda2.pk)
+
+    for agenda in (agenda1, agenda2):
+        assert agenda.virtual_agendas.count() == 1
+        assert agenda.virtual_agendas.get() == virt_agenda
+
+
+def test_virtual_agenda_base_meeting_duration():
+    virt_agenda = Agenda.objects.create(label=u'Virtual agenda', kind='virtual')
+
+    with pytest.raises(ValueError):
+        virt_agenda.get_base_meeting_duration()
+
+    agenda1 = Agenda.objects.create(label='Agenda 1', kind='meetings')
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1)
+
+    with pytest.raises(ValueError):
+        virt_agenda.get_base_meeting_duration()
+
+    meeting_type = MeetingType(agenda=agenda1, label='Foo', duration=30)
+    meeting_type.save()
+    assert virt_agenda.get_base_meeting_duration() == 30
+
+    meeting_type = MeetingType(agenda=agenda1, label='Bar', duration=60)
+    meeting_type.save()
+    assert virt_agenda.get_base_meeting_duration() == 30
+
+    agenda2 = Agenda.objects.create(label='Agenda 2', kind='meetings')
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2)
+    virt_agenda.save()
+
+    meeting_type = MeetingType(agenda=agenda2, label='Bar', duration=60)
+    meeting_type.save()
+    assert virt_agenda.get_base_meeting_duration() == 60
diff --git a/tests/test_api.py b/tests/test_api.py
index 4155fb9..195651c 100644
--- a/tests/test_api.py
+++ b/tests/test_api.py
@@ -11,7 +11,16 @@ 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, Desk, TimePeriodException
+from chrono.agendas.models import (
+    Agenda,
+    Event,
+    Booking,
+    MeetingType,
+    TimePeriod,
+    Desk,
+    TimePeriodException,
+    VirtualMember,
+)
 import chrono.api.views
 
 
@@ -38,6 +47,7 @@ def time_zone(request, settings):
     settings.TIME_ZONE = request.param
 
 
+# 2017-05-20 -> saturday
 @pytest.fixture(
     params=[
         datetime.datetime(2017, 5, 20, 1, 12),
@@ -109,9 +119,19 @@ def meetings_agenda(time_zone, mock_now):
     return agenda
 
 
+@pytest.fixture
+def virtual_meetings_agenda(meetings_agenda):
+    agenda = Agenda.objects.create(label='Virtual Agenda', kind='virtual')
+    VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=meetings_agenda)
+    return agenda
+
+
 def test_agendas_api(app, some_data, meetings_agenda):
     agenda1 = Agenda.objects.filter(label=u'Foo bar')[0]
     agenda2 = Agenda.objects.filter(label=u'Foo bar2')[0]
+    virtual_agenda = Agenda.objects.create(
+        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=56
+    )
     resp = app.get('/api/agenda/')
     assert resp.json == {
         'data': [
@@ -152,6 +172,19 @@ def test_agendas_api(app, some_data, meetings_agenda):
                     'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % agenda2.slug,
                 },
             },
+            {
+                'text': 'Virtual Agenda',
+                'id': 'virtual-agenda',
+                'slug': 'virtual-agenda',
+                'minimal_booking_delay': 1,
+                'maximal_booking_delay': 56,
+                'kind': 'virtual',
+                'api': {
+                    'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_agenda.slug,
+                    'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_agenda.slug,
+                    'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_agenda.slug,
+                },
+            },
         ]
     }
 
@@ -2138,3 +2171,421 @@ def test_agenda_detail_routing(app, meetings_agenda):
     api_url = '/api/agenda/%s/' % agenda.slug
     resp = app.get(api_url)
     assert type(resp.json['data']) is dict
+
+
+def test_virtual_agenda_detail(app, virtual_meetings_agenda):
+    resp = app.get('/api/agenda/%s/' % virtual_meetings_agenda.slug)
+    assert resp.json == {
+        'data': {
+            'text': 'Virtual Agenda',
+            'id': 'virtual-agenda',
+            'slug': 'virtual-agenda',
+            'minimal_booking_delay': 1,
+            'maximal_booking_delay': 56,
+            'kind': 'virtual',
+            'api': {
+                'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_meetings_agenda.slug,
+                'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_meetings_agenda.slug,
+                'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_meetings_agenda.slug,
+            },
+        },
+    }
+
+
+def test_virtual_agendas_meetingtypes_api(app):
+    virt_agenda = Agenda.objects.create(label=u'Virtual agenda', kind='virtual')
+
+    # No meetings because no real agenda
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {'data': []}
+
+    # One real agenda : every meetings exposed
+    foo_agenda = Agenda.objects.create(label=u'Foo', kind='meetings')
+    MeetingType.objects.create(agenda=foo_agenda, label='Meeting1', duration=30)
+    MeetingType.objects.create(agenda=foo_agenda, label='Meeting2', duration=15)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {
+        'data': [
+            {
+                'text': 'Meeting1',
+                'id': 'meeting1',
+                'duration': 30,
+                'api': {
+                    'datetimes_url': 'http://testserver/api/agenda/virtual-agenda/meetings/meeting1/datetimes/',
+                },
+            },
+            {
+                'text': 'Meeting2',
+                'id': 'meeting2',
+                'duration': 15,
+                'api': {
+                    'datetimes_url': 'http://testserver/api/agenda/virtual-agenda/meetings/meeting2/datetimes/',
+                },
+            },
+        ]
+    }
+
+    # Several real agendas
+
+    bar_agenda = Agenda.objects.create(label=u'Bar', kind='meetings')
+    MeetingType.objects.create(agenda=bar_agenda, label='Meeting Bar', duration=30)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
+
+    # Bar agenda has no meeting type: no meetings exposed
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {'data': []}
+
+    # Bar agenda has a meetings wih different label, slug, duration: no meetings exposed
+    mt = MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type Bar', duration=15)
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {'data': []}
+
+    # Bar agenda has a meetings wih same label, but different slug and duration: no meetings exposed
+    mt.label = 'Meeting1'
+    mt.save()
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {'data': []}
+
+    # Bar agenda has a meetings wih same label and slug, but different duration: no meetings exposed
+    mt.slug = 'meeting1'
+    mt.save()
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {'data': []}
+
+    # Bar agenda has a meetings wih same label, slug and duration: only this meeting exposed
+    mt.duration = 30
+    mt.save()
+    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
+    assert resp.json == {
+        'data': [
+            {
+                'text': 'Meeting1',
+                'id': 'meeting1',
+                'duration': 30,
+                'api': {
+                    'datetimes_url': 'http://testserver/api/agenda/virtual-agenda/meetings/meeting1/datetimes/',
+                },
+            },
+        ]
+    }
+
+
+def test_virtual_agendas_meetings_datetimes_api(app, virtual_meetings_agenda):
+    real_agenda = virtual_meetings_agenda.real_agendas.first()
+    meeting_type = real_agenda.meetingtype_set.first()
+    default_desk = real_agenda.desk_set.first()
+    # Unkown meeting
+    app.get('/api/agenda/%s/meetings/xxx/datetimes/' % virtual_meetings_agenda.slug, status=404)
+
+    virt_meeting_type = virtual_meetings_agenda.iter_meetingtypes()[0]
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virtual_meetings_agenda.slug, virt_meeting_type.slug)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 144
+
+    virtual_meetings_agenda.minimal_booking_delay = 7
+    virtual_meetings_agenda.maximal_booking_delay = 28
+    virtual_meetings_agenda.save()
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 54
+
+    virtual_meetings_agenda.minimal_booking_delay = 1
+    virtual_meetings_agenda.maximal_booking_delay = 56
+    virtual_meetings_agenda.save()
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 144
+
+    resp = app.get(api_url)
+    dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
+    ev = Event(
+        agenda=real_agenda,
+        meeting_type=meeting_type,
+        places=1,
+        full=False,
+        start_datetime=make_aware(dt),
+        desk=default_desk,
+    )
+    ev.save()
+    booking = Booking(event=ev)
+    booking.save()
+    resp2 = app.get(api_url)
+    assert len(resp2.json['data']) == 144
+    assert resp.json['data'][0] == resp2.json['data'][0]
+    assert resp.json['data'][1] == resp2.json['data'][1]
+    assert resp.json['data'][2] != resp2.json['data'][2]
+    assert resp.json['data'][2]['disabled'] is False
+    assert resp2.json['data'][2]['disabled'] is True
+    assert resp.json['data'][3] == resp2.json['data'][3]
+
+    # test with a timeperiod overlapping current moment, it should get one
+    # datetime for the current timeperiod + two from the next week.
+    if now().time().hour == 23:
+        # skip this part of the test as it would require support for events
+        # crossing midnight
+        return
+
+    TimePeriod.objects.filter(desk=default_desk).delete()
+    start_time = localtime(now()) - datetime.timedelta(minutes=10)
+    time_period = TimePeriod(
+        weekday=localtime(now()).weekday(),
+        start_time=start_time,
+        end_time=start_time + datetime.timedelta(hours=1),
+        desk=default_desk,
+    )
+    time_period.save()
+    virtual_meetings_agenda.minimal_booking_delay = 0
+    virtual_meetings_agenda.maximal_booking_delay = 10
+    virtual_meetings_agenda.save()
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 3
+
+
+def test_virtual_agendas_meetings_exception(app, user, virtual_meetings_agenda):
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    real_agenda = virtual_meetings_agenda.real_agendas.first()
+    desk = real_agenda.desk_set.first()
+    virt_meeting_type = virtual_meetings_agenda.iter_meetingtypes()[0]
+    datetimes_url = '/api/agenda/%s/meetings/%s/datetimes/' % (
+        virtual_meetings_agenda.slug,
+        virt_meeting_type.slug,
+    )
+    resp = app.get(datetimes_url)
+
+    # test exception at the lowest limit
+    excp1 = TimePeriodException.objects.create(
+        desk=desk,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 22, 12, 0)),
+    )
+    resp2 = app.get(datetimes_url)
+    assert len(resp.json['data']) == len(resp2.json['data']) + 4
+
+    # test exception at the highest limit
+    excp1.end_datetime = make_aware(datetime.datetime(2017, 5, 22, 11, 0))
+    excp1.save()
+    resp2 = app.get(datetimes_url)
+    assert len(resp.json['data']) == len(resp2.json['data']) + 2
+
+    # add an exception with an end datetime less than excp1 end datetime
+    # and make sure that excp1 end datetime preveil
+    excp1.end_datetime = make_aware(datetime.datetime(2017, 5, 23, 11, 0))
+    excp1.save()
+
+    TimePeriodException.objects.create(
+        desk=excp1.desk,
+        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 15, 0)),
+        end_datetime=make_aware(datetime.datetime(2017, 5, 23, 9, 0)),
+    )
+
+    resp2 = app.get(datetimes_url)
+    assert len(resp.json['data']) == len(resp2.json['data']) + 6
+
+    # with a second desk
+    desk2 = Desk.objects.create(label='Desk 2', agenda=real_agenda)
+    time_period = desk.timeperiod_set.first()
+    TimePeriod.objects.create(
+        desk=desk2,
+        start_time=time_period.start_time,
+        end_time=time_period.end_time,
+        weekday=time_period.weekday,
+    )
+    resp3 = app.get(datetimes_url)
+    assert len(resp.json['data']) == len(resp3.json['data']) + 2  # +2 because excp1 changed
+
+
+def test_virtual_agendas_meetings_datetimes_multiple_agendas(app, time_zone, mock_now):
+    foo_agenda = Agenda.objects.create(
+        label='Foo Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
+    )
+    foo_meeting_type = MeetingType.objects.create(agenda=foo_agenda, label='Meeting Type', duration=30)
+    foo_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 1')
+
+    test_1st_weekday = (localtime(now()).weekday() + 2) % 7
+    test_2nd_weekday = (localtime(now()).weekday() + 3) % 7
+    test_3rd_weekday = (localtime(now()).weekday() + 4) % 7
+    test_4th_weekday = (localtime(now()).weekday() + 5) % 7
+
+    def create_time_perdiods(desk, end=12):
+        TimePeriod.objects.create(
+            weekday=test_1st_weekday,
+            start_time=datetime.time(10, 0),
+            end_time=datetime.time(end, 0),
+            desk=desk,
+        )
+        TimePeriod.objects.create(
+            weekday=test_2nd_weekday,
+            start_time=datetime.time(10, 0),
+            end_time=datetime.time(end, 0),
+            desk=desk,
+        )
+
+    create_time_perdiods(foo_desk_1)
+    virt_agenda = Agenda.objects.create(
+        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=5
+    )
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
+    virt_meeting_type = virt_agenda.iter_meetingtypes()[0]
+
+    # We are saturday and we can book for next monday and tuesday, 4 slots available each day
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, virt_meeting_type.slug)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 8
+    assert resp.json['data'][0]['id'] == 'meeting-type:2017-05-22-1000'
+
+    virt_agenda.maximal_booking_delay = 9  # another monday comes in
+    virt_agenda.save()
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 12
+
+    # Back to next monday and tuesday restriction
+    virt_agenda.maximal_booking_delay = 5
+    virt_agenda.save()
+
+    # Add another agenda
+    bar_agenda = Agenda.objects.create(
+        label='Bar Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
+    )
+    bar_meeting_type = MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type', duration=30)
+    bar_desk_1 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 1')
+    create_time_perdiods(bar_desk_1, end=13)  # bar_agenda has two more slots each day
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 12
+
+    # simulate booking
+    dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
+    ev = Event.objects.create(
+        agenda=foo_agenda,
+        meeting_type=foo_meeting_type,
+        places=1,
+        full=False,
+        start_datetime=make_aware(dt),
+        desk=foo_desk_1,
+    )
+    booking1 = Booking.objects.create(event=ev)
+
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 12
+    # No disabled slot, because the booked slot is still available in second agenda
+    for slot in resp.json['data']:
+        assert slot['disabled'] is False
+
+    ev = Event.objects.create(
+        agenda=bar_agenda,
+        meeting_type=bar_meeting_type,
+        places=1,
+        full=False,
+        start_datetime=make_aware(dt),
+        desk=bar_desk_1,
+    )
+    booking2 = Booking.objects.create(event=ev)
+
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 12
+    # now one slot is disabled
+    for i, slot in enumerate(resp.json['data']):
+        if i == 2:
+            assert slot['disabled']
+        else:
+            assert slot['disabled'] is False
+
+    # Cancel booking, every slot available
+    booking1.cancel()
+    booking2.cancel()
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 12
+    for slot in resp.json['data']:
+        assert slot['disabled'] is False
+
+    # Add new desk on foo_agenda, open on wednesday
+    foo_desk_2 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 2')
+    TimePeriod.objects.create(
+        weekday=test_3rd_weekday,
+        start_time=datetime.time(10, 0),
+        end_time=datetime.time(12, 0),
+        desk=foo_desk_2,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 16
+
+    # Add new desk on bar_agenda, open on thursday
+    bar_desk_2 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 2')
+    TimePeriod.objects.create(
+        weekday=test_4th_weekday,
+        start_time=datetime.time(10, 0),
+        end_time=datetime.time(12, 0),
+        desk=bar_desk_2,
+    )
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 20
+
+
+def test_virtual_agendas_meetings_booking(app, mock_now, user):
+    foo_agenda = Agenda.objects.create(
+        label='Foo Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
+    )
+    MeetingType.objects.create(agenda=foo_agenda, label='Meeting Type', duration=30)
+    foo_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 1')
+
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
+    )
+    bar_agenda = Agenda.objects.create(
+        label='Bar Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
+    )
+    MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type', duration=30)
+    bar_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Bar desk 1')
+
+    TimePeriod.objects.create(
+        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_1,
+    )
+    TimePeriod.objects.create(
+        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_1,
+    )
+
+    virt_agenda = Agenda.objects.create(
+        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=5
+    )
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
+    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
+    virt_meeting_type = virt_agenda.iter_meetingtypes()[0]
+    # We are saturday and we can book for next monday and tuesday, 4 slots available each day
+    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, virt_meeting_type.slug)
+    resp = app.get(api_url)
+    assert len(resp.json['data']) == 8
+
+    # make a booking
+    fillslot_url = resp.json['data'][0]['api']['fillslot_url']
+    app.authorization = ('Basic', ('john.doe', 'password'))
+    resp_booking = app.post(fillslot_url)
+    assert Booking.objects.count() == 1
+    booking = Booking.objects.get(pk=resp_booking.json['booking_id'])
+    assert (
+        resp_booking.json['datetime']
+        == localtime(booking.event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
+        == resp.json['data'][0]['datetime']
+    )
+
+    assert resp_booking.json['end_datetime'] == localtime(
+        Booking.objects.all()[0].event.end_datetime
+    ).strftime('%Y-%m-%d %H:%M:%S')
+    assert resp_booking.json['duration'] == 30
+
+    # second booking on the same slot (available on the second real agenda)
+    resp_booking = app.post(fillslot_url)
+    assert Booking.objects.count() == 2
+    booking = Booking.objects.get(pk=resp_booking.json['booking_id'])
+    assert (
+        resp_booking.json['datetime']
+        == localtime(booking.event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
+        == resp.json['data'][0]['datetime']
+    )
+
+    # try booking the same timeslot a third time: full
+    resp_booking = app.post(fillslot_url)
+    assert resp_booking.json['err'] == 1
+    assert resp_booking.json['err_class'] == 'no more desk available'
+    assert resp_booking.json['err_desc'] == 'no more desk available'
-- 
2.20.1

