Projet

Général

Profil

0001-start-virtual-agendas-37123.patch

Emmanuel Cazenave, 16 mars 2020 17:47

Télécharger (37,7 ko)

Voir les différences:

Subject: [PATCH 1/8] start virtual agendas (#37123)

 .../migrations/0038_start_virtual_agendas.py  |  59 +++
 chrono/agendas/models.py                      |  72 ++-
 chrono/api/views.py                           | 109 +++--
 tests/test_agendas.py                         |  47 ++
 tests/test_api.py                             | 453 +++++++++++++++++-
 5 files changed, 692 insertions(+), 48 deletions(-)
 create mode 100644 chrono/agendas/migrations/0038_start_virtual_agendas.py
chrono/agendas/migrations/0038_start_virtual_agendas.py
1
# -*- coding: utf-8 -*-
2
# Generated by Django 1.11.18 on 2020-02-20 12:15
3
from __future__ import unicode_literals
4

  
5
from django.db import migrations, models
6
import django.db.models.deletion
7

  
8

  
9
class Migration(migrations.Migration):
10

  
11
    dependencies = [
12
        ('agendas', '0037_timeperiodexceptionsource_ics_file'),
13
    ]
14

  
15
    operations = [
16
        migrations.CreateModel(
17
            name='VirtualMember',
18
            fields=[
19
                (
20
                    'id',
21
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
22
                ),
23
            ],
24
        ),
25
        migrations.AlterField(
26
            model_name='agenda',
27
            name='kind',
28
            field=models.CharField(
29
                choices=[('events', 'Events'), ('meetings', 'Meetings'), ('virtual', 'Virtual')],
30
                default='events',
31
                max_length=20,
32
                verbose_name='Kind',
33
            ),
34
        ),
35
        migrations.AddField(
36
            model_name='virtualmember',
37
            name='real_agenda',
38
            field=models.ForeignKey(
39
                on_delete=django.db.models.deletion.CASCADE,
40
                related_name='virtual_members',
41
                to='agendas.Agenda',
42
                verbose_name='Agenda',
43
            ),
44
        ),
45
        migrations.AddField(
46
            model_name='virtualmember',
47
            name='virtual_agenda',
48
            field=models.ForeignKey(
49
                on_delete=django.db.models.deletion.CASCADE, related_name='real_members', to='agendas.Agenda'
50
            ),
51
        ),
52
        migrations.AddField(
53
            model_name='agenda',
54
            name='real_agendas',
55
            field=models.ManyToManyField(
56
                related_name='virtual_agendas', through='agendas.VirtualMember', to='agendas.Agenda'
57
            ),
58
        ),
59
    ]
chrono/agendas/models.py
43 43
AGENDA_KINDS = (
44 44
    ('events', _('Events')),
45 45
    ('meetings', _('Meetings')),
46
    ('virtual', _('Virtual')),
46 47
)
47 48

  
48 49

  
......
77 78
    maximal_booking_delay = models.PositiveIntegerField(
78 79
        _('Maximal booking delay (in days)'), default=56
79 80
    )  # eight weeks
81
    real_agendas = models.ManyToManyField(
82
        'self',
83
        related_name='virtual_agendas',
84
        symmetrical=False,
85
        through='VirtualMember',
86
        through_fields=('virtual_agenda', 'real_agenda'),
87
    )
80 88
    edit_role = models.ForeignKey(
81 89
        Group,
82 90
        blank=True,
......
119 127
        group_ids = [x.id for x in user.groups.all()]
120 128
        return bool(self.view_role_id in group_ids)
121 129

  
130
    def accept_meetings(self):
131
        if self.kind == 'virtual':
132
            return not self.real_agendas.filter(~Q(kind='meetings')).exists()
133
        return self.kind == 'meetings'
134

  
135
    def get_real_agendas(self):
136
        if self.kind == 'virtual':
137
            return self.real_agendas.all()
138
        return [self]
139

  
140
    def iter_meetingtypes(self):
141
        """ Expose agenda's meetingtypes.
142
        straighforward on a real agenda
143
        On a virtual agenda we expose transient meeting types based on on the
144
        the real ones shared by every real agendas.
145
        """
146
        if self.kind == 'virtual':
147
            queryset = (
148
                MeetingType.objects.filter(agenda__virtual_agendas__in=[self])
149
                .values('slug', 'duration', 'label')
150
                .annotate(total=Count('*'))
151
                .filter(total=self.real_agendas.count())
152
            )
153
            return [
154
                MeetingType(duration=mt['duration'], label=mt['label'], slug=mt['slug'])
155
                for mt in queryset.order_by('slug')
156
            ]
157

  
158
        return self.meetingtype_set.all().order_by('slug')
159

  
160
    def get_meetingtype(self, id_=None, slug=None):
161
        match = id_ or slug
162
        assert match, 'an identifier or a slug should be specified'
163

  
164
        if self.kind == 'virtual':
165
            match = id_ or slug
166
            meeting_type = None
167
            for mt in self.iter_meetingtypes():
168
                if mt.slug == match:
169
                    meeting_type = mt
170
                    break
171
            if meeting_type is None:
172
                raise MeetingType.DoesNotExist()
173
            return meeting_type
174

  
175
        if id_:
176
            return MeetingType.objects.get(id=id_, agenda=self)
177
        return MeetingType.objects.get(slug=slug, agenda=self)
178

  
122 179
    def get_base_meeting_duration(self):
123
        durations = [x.duration for x in MeetingType.objects.filter(agenda=self)]
180
        durations = [x.duration for x in self.iter_meetingtypes()]
124 181
        if not durations:
125 182
            raise ValueError()
126 183
        gcd = durations[0]
......
129 186
        return gcd
130 187

  
131 188
    def export_json(self):
189
        # TODO VIRTUAL
132 190
        agenda = {
133 191
            'label': self.label,
134 192
            'slug': self.slug,
......
185 243
        return created
186 244

  
187 245

  
246
class VirtualMember(models.Model):
247
    virtual_agenda = models.ForeignKey(Agenda, on_delete=models.CASCADE, related_name='real_members')
248
    real_agenda = models.ForeignKey(
249
        Agenda, on_delete=models.CASCADE, related_name='virtual_members', verbose_name='Agenda'
250
    )
251

  
252

  
188 253
WEEKDAYS_LIST = sorted(WEEKDAYS.items(), key=lambda x: x[0])
189 254

  
190 255

  
......
193 258
        self.start_datetime = start_datetime
194 259
        self.end_datetime = start_datetime + datetime.timedelta(minutes=meeting_type.duration)
195 260
        self.meeting_type = meeting_type
196
        self.id = '%s:%s' % (self.meeting_type.id, start_datetime.strftime('%Y-%m-%d-%H%M'))
261
        self.id = '%s:%s' % (meeting_type.id or meeting_type.slug, start_datetime.strftime('%Y-%m-%d-%H%M'))
197 262
        self.desk = desk
198 263

  
199 264
    def __str__(self):
......
276 341
        unique_together = ['agenda', 'slug']
277 342

  
278 343
    def save(self, *args, **kwargs):
344
        assert self.agenda.kind != 'virtual', "a meetingtype can't reference a virtual agenda"
279 345
        if not self.slug:
280 346
            self.slug = generate_slug(self, agenda=self.agenda)
281 347
        super(MeetingType, self).save(*args, **kwargs)
......
330 396
        return date_format(localtime(self.start_datetime), format='DATETIME_FORMAT')
331 397

  
332 398
    def save(self, *args, **kwargs):
399
        assert self.agenda.kind != 'virtual', "an event can't reference a virtual agenda"
333 400
        self.check_full()
334 401
        return super(Event, self).save(*args, **kwargs)
335 402

  
......
521 588
        unique_together = ['agenda', 'slug']
522 589

  
523 590
    def save(self, *args, **kwargs):
591
        assert self.agenda.kind != 'virtual', "a desk can't reference a virtual agenda"
524 592
        if not self.slug:
525 593
            self.slug = generate_slug(self, agenda=self.agenda)
526 594
        super(Desk, self).save(*args, **kwargs)
chrono/api/views.py
63 63
    }
64 64

  
65 65
    base_date = now().date()
66
    open_slots_by_desk = defaultdict(lambda: Intervals())
67
    for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
68
        duration = (
69
            datetime.datetime.combine(base_date, time_period.end_time)
70
            - datetime.datetime.combine(base_date, time_period.start_time)
71
        ).seconds / 60
72
        if duration < meeting_type.duration:
73
            # skip time period that can't even hold a single meeting
74
            continue
75
        for slot in time_period.get_time_slots(**time_period_filters):
76
            slot.full = False
77
            open_slots_by_desk[time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
66

  
67
    agendas = agenda.get_real_agendas()
68

  
69
    open_slots = {}
70
    for agenda in agendas:
71
        open_slots[agenda] = defaultdict(lambda: Intervals())
72

  
73
    for agenda in agendas:
74
        for time_period in TimePeriod.objects.filter(desk__agenda=agenda):
75
            duration = (
76
                datetime.datetime.combine(base_date, time_period.end_time)
77
                - datetime.datetime.combine(base_date, time_period.start_time)
78
            ).seconds / 60
79
            if duration < meeting_type.duration:
80
                # skip time period that can't even hold a single meeting
81
                continue
82
            for slot in time_period.get_time_slots(**time_period_filters):
83
                slot.full = False
84
                open_slots[agenda][time_period.desk_id].add(slot.start_datetime, slot.end_datetime, slot)
78 85

  
79 86
    # remove excluded slot
80
    excluded_slot_by_desk = get_exceptions_by_desk(agenda)
81
    for desk, excluded_interval in excluded_slot_by_desk.items():
82
        for interval in excluded_interval:
83
            begin, end = interval
84
            open_slots_by_desk[desk].remove_overlap(localtime(begin), localtime(end))
85

  
86
    for event in (
87
        agenda.event_set.filter(
88
            agenda=agenda,
89
            start_datetime__gte=min_datetime,
90
            start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration),
91
        )
92
        .select_related('meeting_type')
93
        .exclude(booking__cancellation_datetime__isnull=False)
94
    ):
95
        for slot in open_slots_by_desk[event.desk_id].search_data(event.start_datetime, event.end_datetime):
96
            slot.full = True
87
    for agenda in agendas:
88
        excluded_slot_by_desk = get_exceptions_by_desk(agenda)
89

  
90
        for desk, excluded_interval in excluded_slot_by_desk.items():
91
            for interval in excluded_interval:
92
                begin, end = interval
93
                open_slots[agenda][desk].remove_overlap(localtime(begin), localtime(end))
94

  
95
    for agenda in agendas:
96
        for event in (
97
            agenda.event_set.filter(
98
                agenda=agenda,
99
                start_datetime__gte=min_datetime,
100
                start_datetime__lte=max_datetime + datetime.timedelta(meeting_type.duration),
101
            )
102
            .select_related('meeting_type')
103
            .exclude(booking__cancellation_datetime__isnull=False)
104
        ):
105
            for slot in open_slots[agenda][event.desk_id].search_data(
106
                event.start_datetime, event.end_datetime
107
            ):
108
                slot.full = True
97 109

  
98 110
    slots = []
99
    for desk in open_slots_by_desk:
100
        slots.extend(open_slots_by_desk[desk].iter_data())
111
    for agenda in agendas:
112
        for desk in open_slots[agenda]:
113
            slots.extend(open_slots[agenda][desk].iter_data())
101 114
    slots.sort(key=lambda slot: slot.start_datetime)
102 115
    return slots
103 116

  
......
118 131
                reverse('api-agenda-datetimes', kwargs={'agenda_identifier': agenda.slug})
119 132
            )
120 133
        }
121
    elif agenda.kind == 'meetings':
134
    elif agenda.accept_meetings():
122 135
        agenda_detail['api'] = {
123 136
            'meetings_url': request.build_absolute_uri(
124 137
                reverse('api-agenda-meetings', kwargs={'agenda_identifier': agenda.slug})
......
269 282
            if agenda_identifier is None:
270 283
                # legacy access by meeting id
271 284
                meeting_type = MeetingType.objects.get(id=meeting_identifier)
285
                agenda = meeting_type.agenda
272 286
            else:
273
                meeting_type = MeetingType.objects.get(
274
                    slug=meeting_identifier, agenda__slug=agenda_identifier
275
                )
276
        except (ValueError, MeetingType.DoesNotExist):
277
            raise Http404()
287
                agenda = Agenda.objects.get(slug=agenda_identifier)
288
                meeting_type = agenda.get_meetingtype(slug=meeting_identifier)
278 289

  
279
        agenda = meeting_type.agenda
290
        except (ValueError, MeetingType.DoesNotExist, Agenda.DoesNotExist):
291
            raise Http404()
280 292

  
281 293
        now_datetime = now()
282 294

  
......
327 339
            agenda = Agenda.objects.get(slug=agenda_identifier)
328 340
        except Agenda.DoesNotExist:
329 341
            raise Http404()
330
        if agenda.kind != 'meetings':
331
            raise Http404('agenda found, but it was not a meetings agenda')
342
        if not agenda.accept_meetings():
343
            raise Http404('agenda found, but it does not accept meetings')
332 344

  
333 345
        meeting_types = []
334
        for meeting_type in agenda.meetingtype_set.all():
346
        for meeting_type in agenda.iter_meetingtypes():
335 347
            meeting_types.append(
336 348
                {
337 349
                    'text': meeting_type.label,
......
517 529

  
518 530
        available_desk = None
519 531

  
520
        if agenda.kind == 'meetings':
532
        if agenda.accept_meetings():
521 533
            # slots are actually timeslot ids (meeting_type:start_datetime), not events ids.
522 534
            # split them back to get both parts
523 535
            meeting_type_id = slots[0].split(':')[0]
......
548 560
                datetimes.add(make_aware(datetime.datetime.strptime(datetime_str, '%Y-%m-%d-%H%M')))
549 561

  
550 562
            # get all free slots and separate them by desk
551
            all_slots = get_all_slots(agenda, MeetingType.objects.get(id=meeting_type_id))
563
            all_slots = get_all_slots(agenda, agenda.get_meetingtype(id_=meeting_type_id))
552 564
            all_slots = [slot for slot in all_slots if not slot.full]
553 565
            datetimes_by_desk = defaultdict(set)
554 566
            for slot in all_slots:
555 567
                datetimes_by_desk[slot.desk.id].add(slot.start_datetime)
556 568

  
569
            # TODO: fill policy for virtual agendas
557 570
            # search first desk where all requested slots are free
558 571
            for available_desk_id in sorted(datetimes_by_desk.keys()):
559 572
                if datetimes.issubset(datetimes_by_desk[available_desk_id]):
......
572 585
            datetimes = list(datetimes)
573 586
            datetimes.sort()
574 587

  
588
            # get a real meeting_type for virtual agenda
589
            if agenda.kind == 'virtual':
590
                meeting_type_id = MeetingType.objects.get(
591
                    agenda=available_desk.agenda, slug=meeting_type_id
592
                ).pk
593

  
575 594
            # booking requires real Event objects (not lazy Timeslots);
576 595
            # create them now, with data from the slots and the desk we found.
577 596
            events = []
578 597
            for start_datetime in datetimes:
579 598
                events.append(
580 599
                    Event.objects.create(
581
                        agenda=agenda,
600
                        agenda=available_desk.agenda,
582 601
                        meeting_type_id=meeting_type_id,
583 602
                        start_datetime=start_datetime,
584 603
                        full=False,
......
669 688
            response['api']['suspend_url'] = request.build_absolute_uri(
670 689
                reverse('api-suspend-booking', kwargs={'booking_pk': primary_booking.pk})
671 690
            )
672
        if agenda.kind == 'meetings':
691
        if agenda.accept_meetings():
673 692
            response['end_datetime'] = format_response_datetime(events[-1].end_datetime)
674 693
            response['duration'] = (events[-1].end_datetime - events[-1].start_datetime).seconds // 60
675 694
        if available_desk:
tests/test_agendas.py
18 18
    MeetingType,
19 19
    TimePeriodException,
20 20
    TimePeriodExceptionSource,
21
    VirtualMember,
21 22
)
22 23

  
23 24
pytestmark = pytest.mark.django_db
......
547 548
        elif event.label == 'bar':
548 549
            assert event.booked_places_count == 3
549 550
            assert event.waiting_list_count == 0
551

  
552

  
553
def test_virtual_agenda_init():
554
    agenda1 = Agenda.objects.create(label=u'Agenda 1', kind='meetings')
555
    agenda2 = Agenda.objects.create(label=u'Agenda 2', kind='meetings')
556
    virt_agenda = Agenda.objects.create(label=u'Virtual agenda', kind='virtual')
557
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1)
558
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2)
559
    virt_agenda.save()
560

  
561
    assert virt_agenda.real_agendas.count() == 2
562
    assert virt_agenda.real_agendas.get(pk=agenda1.pk)
563
    assert virt_agenda.real_agendas.get(pk=agenda2.pk)
564

  
565
    for agenda in (agenda1, agenda2):
566
        assert agenda.virtual_agendas.count() == 1
567
        assert agenda.virtual_agendas.get() == virt_agenda
568

  
569

  
570
def test_virtual_agenda_base_meeting_duration():
571
    virt_agenda = Agenda.objects.create(label=u'Virtual agenda', kind='virtual')
572

  
573
    with pytest.raises(ValueError):
574
        virt_agenda.get_base_meeting_duration()
575

  
576
    agenda1 = Agenda.objects.create(label='Agenda 1', kind='meetings')
577
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda1)
578

  
579
    with pytest.raises(ValueError):
580
        virt_agenda.get_base_meeting_duration()
581

  
582
    meeting_type = MeetingType(agenda=agenda1, label='Foo', duration=30)
583
    meeting_type.save()
584
    assert virt_agenda.get_base_meeting_duration() == 30
585

  
586
    meeting_type = MeetingType(agenda=agenda1, label='Bar', duration=60)
587
    meeting_type.save()
588
    assert virt_agenda.get_base_meeting_duration() == 30
589

  
590
    agenda2 = Agenda.objects.create(label='Agenda 2', kind='meetings')
591
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=agenda2)
592
    virt_agenda.save()
593

  
594
    meeting_type = MeetingType(agenda=agenda2, label='Bar', duration=60)
595
    meeting_type.save()
596
    assert virt_agenda.get_base_meeting_duration() == 60
tests/test_api.py
11 11
from django.test.utils import CaptureQueriesContext
12 12
from django.utils.timezone import now, make_aware, localtime
13 13

  
14
from chrono.agendas.models import Agenda, Event, Booking, MeetingType, TimePeriod, Desk, TimePeriodException
14
from chrono.agendas.models import (
15
    Agenda,
16
    Event,
17
    Booking,
18
    MeetingType,
19
    TimePeriod,
20
    Desk,
21
    TimePeriodException,
22
    VirtualMember,
23
)
15 24
import chrono.api.views
16 25

  
17 26

  
......
38 47
    settings.TIME_ZONE = request.param
39 48

  
40 49

  
50
# 2017-05-20 -> saturday
41 51
@pytest.fixture(
42 52
    params=[
43 53
        datetime.datetime(2017, 5, 20, 1, 12),
......
109 119
    return agenda
110 120

  
111 121

  
122
@pytest.fixture
123
def virtual_meetings_agenda(meetings_agenda):
124
    agenda = Agenda.objects.create(label='Virtual Agenda', kind='virtual')
125
    VirtualMember.objects.create(virtual_agenda=agenda, real_agenda=meetings_agenda)
126
    return agenda
127

  
128

  
112 129
def test_agendas_api(app, some_data, meetings_agenda):
113 130
    agenda1 = Agenda.objects.filter(label=u'Foo bar')[0]
114 131
    agenda2 = Agenda.objects.filter(label=u'Foo bar 2')[0]
132
    virtual_agenda = Agenda.objects.create(
133
        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=56
134
    )
115 135
    resp = app.get('/api/agenda/')
116 136
    assert resp.json == {
117 137
        'data': [
......
152 172
                    'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % meetings_agenda.slug,
153 173
                },
154 174
            },
175
            {
176
                'text': 'Virtual Agenda',
177
                'id': 'virtual-agenda',
178
                'slug': 'virtual-agenda',
179
                'minimal_booking_delay': 1,
180
                'maximal_booking_delay': 56,
181
                'kind': 'virtual',
182
                'api': {
183
                    'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_agenda.slug,
184
                    'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_agenda.slug,
185
                    'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_agenda.slug,
186
                },
187
            },
155 188
        ]
156 189
    }
157 190

  
......
2252 2285
    api_url = '/api/agenda/%s/' % agenda.slug
2253 2286
    resp = app.get(api_url)
2254 2287
    assert type(resp.json['data']) is dict
2288

  
2289

  
2290
def test_virtual_agenda_detail(app, virtual_meetings_agenda):
2291
    resp = app.get('/api/agenda/%s/' % virtual_meetings_agenda.slug)
2292
    assert resp.json == {
2293
        'data': {
2294
            'text': 'Virtual Agenda',
2295
            'id': 'virtual-agenda',
2296
            'slug': 'virtual-agenda',
2297
            'minimal_booking_delay': 1,
2298
            'maximal_booking_delay': 56,
2299
            'kind': 'virtual',
2300
            'api': {
2301
                'meetings_url': 'http://testserver/api/agenda/%s/meetings/' % virtual_meetings_agenda.slug,
2302
                'desks_url': 'http://testserver/api/agenda/%s/desks/' % virtual_meetings_agenda.slug,
2303
                'fillslots_url': 'http://testserver/api/agenda/%s/fillslots/' % virtual_meetings_agenda.slug,
2304
            },
2305
        },
2306
    }
2307

  
2308

  
2309
def test_virtual_agendas_meetingtypes_api(app):
2310
    virt_agenda = Agenda.objects.create(label=u'Virtual agenda', kind='virtual')
2311

  
2312
    # No meetings because no real agenda
2313
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2314
    assert resp.json == {'data': []}
2315

  
2316
    # One real agenda : every meetings exposed
2317
    foo_agenda = Agenda.objects.create(label=u'Foo', kind='meetings')
2318
    MeetingType.objects.create(agenda=foo_agenda, label='Meeting1', duration=30)
2319
    MeetingType.objects.create(agenda=foo_agenda, label='Meeting2', duration=15)
2320
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
2321
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2322
    assert resp.json == {
2323
        'data': [
2324
            {
2325
                'text': 'Meeting1',
2326
                'id': 'meeting1',
2327
                'duration': 30,
2328
                'api': {
2329
                    'datetimes_url': 'http://testserver/api/agenda/virtual-agenda/meetings/meeting1/datetimes/',
2330
                },
2331
            },
2332
            {
2333
                'text': 'Meeting2',
2334
                'id': 'meeting2',
2335
                'duration': 15,
2336
                'api': {
2337
                    'datetimes_url': 'http://testserver/api/agenda/virtual-agenda/meetings/meeting2/datetimes/',
2338
                },
2339
            },
2340
        ]
2341
    }
2342

  
2343
    # Several real agendas
2344

  
2345
    bar_agenda = Agenda.objects.create(label=u'Bar', kind='meetings')
2346
    MeetingType.objects.create(agenda=bar_agenda, label='Meeting Bar', duration=30)
2347
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
2348

  
2349
    # Bar agenda has no meeting type: no meetings exposed
2350
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2351
    assert resp.json == {'data': []}
2352

  
2353
    # Bar agenda has a meetings wih different label, slug, duration: no meetings exposed
2354
    mt = MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type Bar', duration=15)
2355
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2356
    assert resp.json == {'data': []}
2357

  
2358
    # Bar agenda has a meetings wih same label, but different slug and duration: no meetings exposed
2359
    mt.label = 'Meeting1'
2360
    mt.save()
2361
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2362
    assert resp.json == {'data': []}
2363

  
2364
    # Bar agenda has a meetings wih same label and slug, but different duration: no meetings exposed
2365
    mt.slug = 'meeting1'
2366
    mt.save()
2367
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2368
    assert resp.json == {'data': []}
2369

  
2370
    # Bar agenda has a meetings wih same label, slug and duration: only this meeting exposed
2371
    mt.duration = 30
2372
    mt.save()
2373
    resp = app.get('/api/agenda/%s/meetings/' % virt_agenda.slug)
2374
    assert resp.json == {
2375
        'data': [
2376
            {
2377
                'text': 'Meeting1',
2378
                'id': 'meeting1',
2379
                'duration': 30,
2380
                'api': {
2381
                    'datetimes_url': 'http://testserver/api/agenda/virtual-agenda/meetings/meeting1/datetimes/',
2382
                },
2383
            },
2384
        ]
2385
    }
2386

  
2387

  
2388
def test_virtual_agendas_meetings_datetimes_api(app, virtual_meetings_agenda):
2389
    real_agenda = virtual_meetings_agenda.real_agendas.first()
2390
    meeting_type = real_agenda.meetingtype_set.first()
2391
    default_desk = real_agenda.desk_set.first()
2392
    # Unkown meeting
2393
    app.get('/api/agenda/%s/meetings/xxx/datetimes/' % virtual_meetings_agenda.slug, status=404)
2394

  
2395
    virt_meeting_type = virtual_meetings_agenda.iter_meetingtypes()[0]
2396
    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virtual_meetings_agenda.slug, virt_meeting_type.slug)
2397
    resp = app.get(api_url)
2398
    assert len(resp.json['data']) == 144
2399

  
2400
    virtual_meetings_agenda.minimal_booking_delay = 7
2401
    virtual_meetings_agenda.maximal_booking_delay = 28
2402
    virtual_meetings_agenda.save()
2403
    resp = app.get(api_url)
2404
    assert len(resp.json['data']) == 54
2405

  
2406
    virtual_meetings_agenda.minimal_booking_delay = 1
2407
    virtual_meetings_agenda.maximal_booking_delay = 56
2408
    virtual_meetings_agenda.save()
2409
    resp = app.get(api_url)
2410
    assert len(resp.json['data']) == 144
2411

  
2412
    resp = app.get(api_url)
2413
    dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
2414
    ev = Event(
2415
        agenda=real_agenda,
2416
        meeting_type=meeting_type,
2417
        places=1,
2418
        full=False,
2419
        start_datetime=make_aware(dt),
2420
        desk=default_desk,
2421
    )
2422
    ev.save()
2423
    booking = Booking(event=ev)
2424
    booking.save()
2425
    resp2 = app.get(api_url)
2426
    assert len(resp2.json['data']) == 144
2427
    assert resp.json['data'][0] == resp2.json['data'][0]
2428
    assert resp.json['data'][1] == resp2.json['data'][1]
2429
    assert resp.json['data'][2] != resp2.json['data'][2]
2430
    assert resp.json['data'][2]['disabled'] is False
2431
    assert resp2.json['data'][2]['disabled'] is True
2432
    assert resp.json['data'][3] == resp2.json['data'][3]
2433

  
2434
    # test with a timeperiod overlapping current moment, it should get one
2435
    # datetime for the current timeperiod + two from the next week.
2436
    if now().time().hour == 23:
2437
        # skip this part of the test as it would require support for events
2438
        # crossing midnight
2439
        return
2440

  
2441
    TimePeriod.objects.filter(desk=default_desk).delete()
2442
    start_time = localtime(now()) - datetime.timedelta(minutes=10)
2443
    time_period = TimePeriod(
2444
        weekday=localtime(now()).weekday(),
2445
        start_time=start_time,
2446
        end_time=start_time + datetime.timedelta(hours=1),
2447
        desk=default_desk,
2448
    )
2449
    time_period.save()
2450
    virtual_meetings_agenda.minimal_booking_delay = 0
2451
    virtual_meetings_agenda.maximal_booking_delay = 10
2452
    virtual_meetings_agenda.save()
2453
    resp = app.get(api_url)
2454
    assert len(resp.json['data']) == 3
2455

  
2456

  
2457
def test_virtual_agendas_meetings_exception(app, user, virtual_meetings_agenda):
2458
    app.authorization = ('Basic', ('john.doe', 'password'))
2459
    real_agenda = virtual_meetings_agenda.real_agendas.first()
2460
    desk = real_agenda.desk_set.first()
2461
    virt_meeting_type = virtual_meetings_agenda.iter_meetingtypes()[0]
2462
    datetimes_url = '/api/agenda/%s/meetings/%s/datetimes/' % (
2463
        virtual_meetings_agenda.slug,
2464
        virt_meeting_type.slug,
2465
    )
2466
    resp = app.get(datetimes_url)
2467

  
2468
    # test exception at the lowest limit
2469
    excp1 = TimePeriodException.objects.create(
2470
        desk=desk,
2471
        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 10, 0)),
2472
        end_datetime=make_aware(datetime.datetime(2017, 5, 22, 12, 0)),
2473
    )
2474
    resp2 = app.get(datetimes_url)
2475
    assert len(resp.json['data']) == len(resp2.json['data']) + 4
2476

  
2477
    # test exception at the highest limit
2478
    excp1.end_datetime = make_aware(datetime.datetime(2017, 5, 22, 11, 0))
2479
    excp1.save()
2480
    resp2 = app.get(datetimes_url)
2481
    assert len(resp.json['data']) == len(resp2.json['data']) + 2
2482

  
2483
    # add an exception with an end datetime less than excp1 end datetime
2484
    # and make sure that excp1 end datetime preveil
2485
    excp1.end_datetime = make_aware(datetime.datetime(2017, 5, 23, 11, 0))
2486
    excp1.save()
2487

  
2488
    TimePeriodException.objects.create(
2489
        desk=excp1.desk,
2490
        start_datetime=make_aware(datetime.datetime(2017, 5, 22, 15, 0)),
2491
        end_datetime=make_aware(datetime.datetime(2017, 5, 23, 9, 0)),
2492
    )
2493

  
2494
    resp2 = app.get(datetimes_url)
2495
    assert len(resp.json['data']) == len(resp2.json['data']) + 6
2496

  
2497
    # with a second desk
2498
    desk2 = Desk.objects.create(label='Desk 2', agenda=real_agenda)
2499
    time_period = desk.timeperiod_set.first()
2500
    TimePeriod.objects.create(
2501
        desk=desk2,
2502
        start_time=time_period.start_time,
2503
        end_time=time_period.end_time,
2504
        weekday=time_period.weekday,
2505
    )
2506
    resp3 = app.get(datetimes_url)
2507
    assert len(resp.json['data']) == len(resp3.json['data']) + 2  # +2 because excp1 changed
2508

  
2509

  
2510
def test_virtual_agendas_meetings_datetimes_multiple_agendas(app, time_zone, mock_now):
2511
    foo_agenda = Agenda.objects.create(
2512
        label='Foo Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
2513
    )
2514
    foo_meeting_type = MeetingType.objects.create(agenda=foo_agenda, label='Meeting Type', duration=30)
2515
    foo_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 1')
2516

  
2517
    test_1st_weekday = (localtime(now()).weekday() + 2) % 7
2518
    test_2nd_weekday = (localtime(now()).weekday() + 3) % 7
2519
    test_3rd_weekday = (localtime(now()).weekday() + 4) % 7
2520
    test_4th_weekday = (localtime(now()).weekday() + 5) % 7
2521

  
2522
    def create_time_perdiods(desk, end=12):
2523
        TimePeriod.objects.create(
2524
            weekday=test_1st_weekday,
2525
            start_time=datetime.time(10, 0),
2526
            end_time=datetime.time(end, 0),
2527
            desk=desk,
2528
        )
2529
        TimePeriod.objects.create(
2530
            weekday=test_2nd_weekday,
2531
            start_time=datetime.time(10, 0),
2532
            end_time=datetime.time(end, 0),
2533
            desk=desk,
2534
        )
2535

  
2536
    create_time_perdiods(foo_desk_1)
2537
    virt_agenda = Agenda.objects.create(
2538
        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=5
2539
    )
2540
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
2541
    virt_meeting_type = virt_agenda.iter_meetingtypes()[0]
2542

  
2543
    # We are saturday and we can book for next monday and tuesday, 4 slots available each day
2544
    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, virt_meeting_type.slug)
2545
    resp = app.get(api_url)
2546
    assert len(resp.json['data']) == 8
2547
    assert resp.json['data'][0]['id'] == 'meeting-type:2017-05-22-1000'
2548

  
2549
    virt_agenda.maximal_booking_delay = 9  # another monday comes in
2550
    virt_agenda.save()
2551
    resp = app.get(api_url)
2552
    assert len(resp.json['data']) == 12
2553

  
2554
    # Back to next monday and tuesday restriction
2555
    virt_agenda.maximal_booking_delay = 5
2556
    virt_agenda.save()
2557

  
2558
    # Add another agenda
2559
    bar_agenda = Agenda.objects.create(
2560
        label='Bar Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
2561
    )
2562
    bar_meeting_type = MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type', duration=30)
2563
    bar_desk_1 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 1')
2564
    create_time_perdiods(bar_desk_1, end=13)  # bar_agenda has two more slots each day
2565
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
2566
    resp = app.get(api_url)
2567
    assert len(resp.json['data']) == 12
2568

  
2569
    # simulate booking
2570
    dt = datetime.datetime.strptime(resp.json['data'][2]['id'].split(':')[1], '%Y-%m-%d-%H%M')
2571
    ev = Event.objects.create(
2572
        agenda=foo_agenda,
2573
        meeting_type=foo_meeting_type,
2574
        places=1,
2575
        full=False,
2576
        start_datetime=make_aware(dt),
2577
        desk=foo_desk_1,
2578
    )
2579
    booking1 = Booking.objects.create(event=ev)
2580

  
2581
    resp = app.get(api_url)
2582
    assert len(resp.json['data']) == 12
2583
    # No disabled slot, because the booked slot is still available in second agenda
2584
    for slot in resp.json['data']:
2585
        assert slot['disabled'] is False
2586

  
2587
    ev = Event.objects.create(
2588
        agenda=bar_agenda,
2589
        meeting_type=bar_meeting_type,
2590
        places=1,
2591
        full=False,
2592
        start_datetime=make_aware(dt),
2593
        desk=bar_desk_1,
2594
    )
2595
    booking2 = Booking.objects.create(event=ev)
2596

  
2597
    resp = app.get(api_url)
2598
    assert len(resp.json['data']) == 12
2599
    # now one slot is disabled
2600
    for i, slot in enumerate(resp.json['data']):
2601
        if i == 2:
2602
            assert slot['disabled']
2603
        else:
2604
            assert slot['disabled'] is False
2605

  
2606
    # Cancel booking, every slot available
2607
    booking1.cancel()
2608
    booking2.cancel()
2609
    resp = app.get(api_url)
2610
    assert len(resp.json['data']) == 12
2611
    for slot in resp.json['data']:
2612
        assert slot['disabled'] is False
2613

  
2614
    # Add new desk on foo_agenda, open on wednesday
2615
    foo_desk_2 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 2')
2616
    TimePeriod.objects.create(
2617
        weekday=test_3rd_weekday,
2618
        start_time=datetime.time(10, 0),
2619
        end_time=datetime.time(12, 0),
2620
        desk=foo_desk_2,
2621
    )
2622
    resp = app.get(api_url)
2623
    assert len(resp.json['data']) == 16
2624

  
2625
    # Add new desk on bar_agenda, open on thursday
2626
    bar_desk_2 = Desk.objects.create(agenda=bar_agenda, label='Bar desk 2')
2627
    TimePeriod.objects.create(
2628
        weekday=test_4th_weekday,
2629
        start_time=datetime.time(10, 0),
2630
        end_time=datetime.time(12, 0),
2631
        desk=bar_desk_2,
2632
    )
2633
    resp = app.get(api_url)
2634
    assert len(resp.json['data']) == 20
2635

  
2636

  
2637
def test_virtual_agendas_meetings_booking(app, mock_now, user):
2638
    foo_agenda = Agenda.objects.create(
2639
        label='Foo Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
2640
    )
2641
    MeetingType.objects.create(agenda=foo_agenda, label='Meeting Type', duration=30)
2642
    foo_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Foo desk 1')
2643

  
2644
    TimePeriod.objects.create(
2645
        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
2646
    )
2647
    TimePeriod.objects.create(
2648
        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=foo_desk_1,
2649
    )
2650
    bar_agenda = Agenda.objects.create(
2651
        label='Bar Meeting', kind='meetings', minimal_booking_delay=1, maximal_booking_delay=5
2652
    )
2653
    MeetingType.objects.create(agenda=bar_agenda, label='Meeting Type', duration=30)
2654
    bar_desk_1 = Desk.objects.create(agenda=foo_agenda, label='Bar desk 1')
2655

  
2656
    TimePeriod.objects.create(
2657
        weekday=0, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_1,
2658
    )
2659
    TimePeriod.objects.create(
2660
        weekday=1, start_time=datetime.time(10, 0), end_time=datetime.time(12, 0), desk=bar_desk_1,
2661
    )
2662

  
2663
    virt_agenda = Agenda.objects.create(
2664
        label='Virtual Agenda', kind='virtual', minimal_booking_delay=1, maximal_booking_delay=5
2665
    )
2666
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=foo_agenda)
2667
    VirtualMember.objects.create(virtual_agenda=virt_agenda, real_agenda=bar_agenda)
2668
    virt_meeting_type = virt_agenda.iter_meetingtypes()[0]
2669
    # We are saturday and we can book for next monday and tuesday, 4 slots available each day
2670
    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (virt_agenda.slug, virt_meeting_type.slug)
2671
    resp = app.get(api_url)
2672
    assert len(resp.json['data']) == 8
2673

  
2674
    # make a booking
2675
    fillslot_url = resp.json['data'][0]['api']['fillslot_url']
2676
    app.authorization = ('Basic', ('john.doe', 'password'))
2677
    resp_booking = app.post(fillslot_url)
2678
    assert Booking.objects.count() == 1
2679
    booking = Booking.objects.get(pk=resp_booking.json['booking_id'])
2680
    assert (
2681
        resp_booking.json['datetime']
2682
        == localtime(booking.event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
2683
        == resp.json['data'][0]['datetime']
2684
    )
2685

  
2686
    assert resp_booking.json['end_datetime'] == localtime(
2687
        Booking.objects.all()[0].event.end_datetime
2688
    ).strftime('%Y-%m-%d %H:%M:%S')
2689
    assert resp_booking.json['duration'] == 30
2690

  
2691
    # second booking on the same slot (available on the second real agenda)
2692
    resp_booking = app.post(fillslot_url)
2693
    assert Booking.objects.count() == 2
2694
    booking = Booking.objects.get(pk=resp_booking.json['booking_id'])
2695
    assert (
2696
        resp_booking.json['datetime']
2697
        == localtime(booking.event.start_datetime).strftime('%Y-%m-%d %H:%M:%S')
2698
        == resp.json['data'][0]['datetime']
2699
    )
2700

  
2701
    # try booking the same timeslot a third time: full
2702
    resp_booking = app.post(fillslot_url)
2703
    assert resp_booking.json['err'] == 1
2704
    assert resp_booking.json['err_class'] == 'no more desk available'
2705
    assert resp_booking.json['err_desc'] == 'no more desk available'
2255
-