Projet

Général

Profil

0001-agendas-PG-constraint-to-prevent-event-overlaping-44.patch

Lauréline Guérin, 20 juillet 2020 15:19

Télécharger (18,9 ko)

Voir les différences:

Subject: [PATCH] agendas: PG constraint to prevent event overlaping (#44676)

 .../0051_event_date_range_constraint.py       | 127 +++++++++++
 .../0052_event_date_range_constraint.py       |  48 ++++
 tests/test_api.py                             |  32 ---
 tests/test_manager.py                         |   4 +-
 tests/test_misc.py                            | 211 ++++++++++++++++++
 5 files changed, 388 insertions(+), 34 deletions(-)
 create mode 100644 chrono/agendas/migrations/0051_event_date_range_constraint.py
 create mode 100644 chrono/agendas/migrations/0052_event_date_range_constraint.py
chrono/agendas/migrations/0051_event_date_range_constraint.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

  
4
from django.db import migrations, transaction
5
from django.db.utils import InternalError, OperationalError, ProgrammingError
6

  
7
sql_forwards = """
8
-- Add technical columns
9
ALTER TABLE agendas_event ADD COLUMN _end_datetime TIMESTAMP WITH TIME ZONE;
10
ALTER TABLE agendas_event ADD COLUMN _ignore_reason CHARACTER VARYING(20);
11

  
12
-- trigger and procedure to maintain _ignore_reason from bookings
13
CREATE OR REPLACE FUNCTION set_ignore_reason() RETURNS TRIGGER AS $$
14
    BEGIN
15
        IF (TG_OP = 'INSERT') THEN
16
            IF NEW.cancellation_datetime IS NOT NULL THEN
17
                UPDATE agendas_event SET _ignore_reason = 'cancel' WHERE id = NEW.event_id;
18
            END IF;
19
            RETURN NEW;
20
        ELSE
21
            PERFORM 1 FROM agendas_booking b WHERE b.event_id = OLD.event_id AND b.cancellation_datetime IS NOT NULL;
22
            IF FOUND THEN
23
                UPDATE agendas_event SET _ignore_reason = 'cancel' WHERE id = OLD.event_id;
24
            ELSE
25
                UPDATE agendas_event SET _ignore_reason = NULL WHERE id = OLD.event_id;
26
            END IF;
27
            IF (TG_OP = 'UPDATE') THEN
28
                RETURN NEW;
29
            ELSE
30
                RETURN OLD;
31
            END IF;
32
        END IF;
33
    END;
34
$$ LANGUAGE plpgsql;
35

  
36
DROP TRIGGER IF EXISTS set_ignore_reason_trg ON agendas_booking;
37
CREATE TRIGGER set_ignore_reason_trg
38
    AFTER INSERT OR UPDATE OR DELETE ON agendas_booking
39
    FOR EACH ROW
40
    EXECUTE PROCEDURE set_ignore_reason();
41

  
42
-- triggers to maintain _end_datetime
43
CREATE OR REPLACE FUNCTION update_duration() RETURNS TRIGGER AS $$
44
    BEGIN
45
        UPDATE agendas_event SET _end_datetime = start_datetime + (NEW.duration ||' minutes')::interval WHERE meeting_type_id = NEW.id;
46
        RETURN NEW;
47
    END;
48
$$ LANGUAGE plpgsql;
49

  
50
DROP TRIGGER IF EXISTS update_duration_trg ON agendas_meetingtype;
51
CREATE TRIGGER update_duration_trg
52
    AFTER UPDATE ON agendas_meetingtype
53
    FOR EACH ROW
54
    WHEN (OLD.duration != NEW.duration)
55
    EXECUTE PROCEDURE update_duration();
56

  
57
CREATE OR REPLACE FUNCTION set_end_datetime() RETURNS TRIGGER AS $$
58
    BEGIN
59
        IF NEW.meeting_type_id IS NULL THEN
60
            NEW._end_datetime = NULL;
61
        ELSE
62
            NEW._end_datetime = NEW.start_datetime + ((
63
                SELECT mt.duration FROM agendas_meetingtype mt WHERE mt.id = NEW.meeting_type_id) ||' minutes')::interval;
64
        END IF;
65
        RETURN NEW;
66
    END;
67
$$ LANGUAGE plpgsql;
68

  
69
DROP TRIGGER IF EXISTS set_end_datetime_trg ON agendas_event;
70
CREATE TRIGGER set_end_datetime_trg
71
    BEFORE INSERT OR UPDATE ON agendas_event
72
    FOR EACH ROW
73
    EXECUTE PROCEDURE set_end_datetime();
74

  
75
-- Init legacy
76
UPDATE agendas_event SET _ignore_reason = 'history';
77
UPDATE
78
    agendas_event
79
SET
80
    _end_datetime = agendas_event.start_datetime + (agendas_meetingtype.duration ||' minutes')::interval
81
FROM
82
    agendas_meetingtype
83
WHERE
84
    agendas_meetingtype.id = agendas_event.meeting_type_id;
85
"""
86

  
87
sql_backwards = """
88
DROP TRIGGER IF EXISTS set_ignore_reason_trg ON agendas_booking;
89
DROP FUNCTION IF EXISTS set_ignore_reason;
90
DROP TRIGGER IF EXISTS update_duration_trg ON agendas_meetingtype;
91
DROP FUNCTION IF EXISTS update_duration;
92
DROP TRIGGER IF EXISTS set_end_datetime_trg ON agendas_event;
93
DROP FUNCTION IF EXISTS set_end_datetime;
94
ALTER TABLE agendas_event DROP COLUMN _end_datetime;
95
ALTER TABLE agendas_event DROP COLUMN _ignore_reason;
96
"""
97

  
98

  
99
class Migration(migrations.Migration):
100

  
101
    dependencies = [
102
        ('agendas', '0050_event_slug'),
103
    ]
104

  
105
    operations = [migrations.RunSQL(sql=sql_forwards, reverse_sql=sql_backwards)]
106

  
107
    def _check_db(self, project_state, schema_editor):
108
        if schema_editor.connection.vendor != 'postgresql':
109
            return project_state
110
        try:
111
            with transaction.atomic():
112
                try:
113
                    # will fail if extension does not exist and can not be created
114
                    schema_editor.execute('CREATE EXTENSION IF NOT EXISTS btree_Gist SCHEMA public')
115
                except (OperationalError, ProgrammingError):
116
                    # if no extension, do not create columns and triggers
117
                    return project_state
118
        except InternalError:
119
            return project_state
120

  
121
    def apply(self, project_state, schema_editor, *args, **kwargs):
122
        result = self._check_db(project_state, schema_editor)
123
        return result or super().apply(project_state, schema_editor, *args, **kwargs)
124

  
125
    def unapply(self, project_state, schema_editor, *args, **kwargs):
126
        result = self._check_db(project_state, schema_editor)
127
        return result or super().unapply(project_state, schema_editor, *args, **kwargs)
chrono/agendas/migrations/0052_event_date_range_constraint.py
1
# -*- coding: utf-8 -*-
2
from __future__ import unicode_literals
3

  
4
from django.db import migrations, transaction
5
from django.db.utils import InternalError, OperationalError, ProgrammingError
6

  
7

  
8
sql_forwards = """
9
ALTER TABLE agendas_event
10
ADD CONSTRAINT tstzrange_constraint
11
EXCLUDE USING GIST(desk_id WITH =, tstzrange(start_datetime, _end_datetime) WITH &&)
12
    WHERE (_ignore_reason IS NULL AND _end_datetime IS NOT NULL and desk_id IS NOT NULL);
13
"""
14

  
15
sql_backwards = """
16
ALTER TABLE agendas_event DROP CONSTRAINT tstzrange_constraint;
17
"""
18

  
19

  
20
class Migration(migrations.Migration):
21

  
22
    dependencies = [
23
        ('agendas', '0051_event_date_range_constraint'),
24
    ]
25

  
26
    operations = [migrations.RunSQL(sql=sql_forwards, reverse_sql=sql_backwards)]
27

  
28
    def _check_db(self, project_state, schema_editor):
29
        if schema_editor.connection.vendor != 'postgresql':
30
            return project_state
31
        try:
32
            with transaction.atomic():
33
                try:
34
                    # will fail if extension does not exist and can not be created
35
                    schema_editor.execute('CREATE EXTENSION IF NOT EXISTS btree_Gist SCHEMA public')
36
                except (OperationalError, ProgrammingError):
37
                    # if no extension, columns and triggers does not exist and constraint can not be added
38
                    return project_state
39
        except InternalError:
40
            return project_state
41

  
42
    def apply(self, project_state, schema_editor, *args, **kwargs):
43
        result = self._check_db(project_state, schema_editor)
44
        return result or super().apply(project_state, schema_editor, *args, **kwargs)
45

  
46
    def unapply(self, project_state, schema_editor, *args, **kwargs):
47
        result = self._check_db(project_state, schema_editor)
48
        return result or super().unapply(project_state, schema_editor, *args, **kwargs)
tests/test_api.py
3074 3074
    assert len([x for x in resp.json['data'] if x['disabled']]) == 2
3075 3075

  
3076 3076

  
3077
def test_datetimes_api_concurrent_bookings(app, user, meetings_agenda):
3078
    meeting_type = MeetingType.objects.get(agenda=meetings_agenda)
3079
    api_url = '/api/agenda/%s/meetings/%s/datetimes/' % (meeting_type.agenda.slug, meeting_type.slug)
3080
    assert Booking.objects.count() == 0
3081
    resp = app.get(api_url)
3082
    assert resp.json['data'][0]['datetime'] == '2017-05-22 10:00:00'
3083

  
3084
    # make a booking
3085
    fillslot_url = resp.json['data'][0]['api']['fillslot_url']
3086
    app.authorization = ('Basic', ('john.doe', 'password'))
3087
    resp = app.post(fillslot_url)
3088
    assert Booking.objects.count() == 1
3089

  
3090
    # make a second one artificially on an overlapping slot
3091
    meeting_type = MeetingType.objects.create(agenda=meetings_agenda, label='Boo', duration=8)
3092
    booking = Booking.objects.first()
3093
    event = booking.event
3094
    new_event = Event.objects.create(
3095
        meeting_type=meeting_type,
3096
        start_datetime=event.start_datetime,
3097
        desk=event.desk,
3098
        places=event.places,
3099
        agenda=meetings_agenda,
3100
    )
3101
    Booking.objects.create(event=new_event)
3102

  
3103
    # should not crash and slot not showing up
3104
    resp = app.get(api_url)
3105
    assert resp.json['data'][0]['datetime'] == '2017-05-22 10:00:00'
3106
    assert resp.json['data'][0]['disabled'] is True
3107

  
3108

  
3109 3077
def test_agenda_detail_routing(app, meetings_agenda):
3110 3078
    api_url = '/api/agenda/%s/' % meetings_agenda.slug
3111 3079
    resp = app.get(api_url)
tests/test_manager.py
503 503
            desk=desk,
504 504
            meeting_type=meetingtype,
505 505
            start_datetime=localtime(now()).replace(
506
                day=middle_day.day - middle_day.weekday() + 2, hour=9, minute=0
506
                day=middle_day.day - middle_day.weekday() + 2, hour=hour, minute=minute
507 507
            ),
508 508
        )
509 509
        event.resources.add(resource)
......
2715 2715
            desk=desk,
2716 2716
            meeting_type=meetingtype,
2717 2717
            start_datetime=localtime(now()).replace(
2718
                day=middle_day.day - middle_day.weekday() + 2, hour=9, minute=0
2718
                day=middle_day.day - middle_day.weekday() + 2, hour=hour, minute=minute
2719 2719
            ),
2720 2720
        )
2721 2721
        Booking.objects.create(event=event)
tests/test_misc.py
1
import datetime
2

  
3
from django.db import IntegrityError
4
from django.db import ProgrammingError
5
from django.db import connection
6
from django.db import transaction
7
from django.utils.timezone import now
8

  
9

  
10
import pytest
11

  
1 12
from chrono.manager.widgets import DateTimeWidget, TimeWidget
13
from chrono.agendas.models import Agenda
14
from chrono.agendas.models import Booking
15
from chrono.agendas.models import Desk
16
from chrono.agendas.models import Event
17
from chrono.agendas.models import MeetingType
18

  
19
pytestmark = pytest.mark.django_db
2 20

  
3 21

  
4 22
def test_widgets_init():
5 23
    DateTimeWidget()
6 24
    TimeWidget()
25

  
26

  
27
def check_ignore_reason(event, value):
28
    with connection.cursor() as cursor:
29
        cursor.execute("SELECT _ignore_reason FROM agendas_event WHERE id = %s", [event.pk])
30
        row = cursor.fetchone()
31
        assert row[0] == value
32

  
33

  
34
def set_ignore_reason(event, value):
35
    with connection.cursor() as cursor:
36
        cursor.execute("UPDATE agendas_event SET _ignore_reason = %s WHERE id = %s", [value, event.pk])
37

  
38

  
39
def check_end_datetime(event, value):
40
    with connection.cursor() as cursor:
41
        cursor.execute("SELECT _end_datetime FROM agendas_event WHERE id = %s", [event.pk])
42
        row = cursor.fetchone()
43
        assert row[0] == value
44

  
45

  
46
def test_event_ignore_reason():
47
    if connection.vendor != 'postgresql':
48
        pytest.skip('postgresql required')
49

  
50
    agenda = Agenda.objects.create(label='Meetings', kind='meetings')
51
    meeting_type = MeetingType.objects.create(agenda=agenda, label='Foo', duration=60)
52
    desk = Desk.objects.create(agenda=agenda, label='Desk')
53

  
54
    event = Event.objects.create(
55
        start_datetime=now(), meeting_type=meeting_type, places=10, agenda=agenda, desk=desk
56
    )
57

  
58
    try:
59
        check_ignore_reason(event, None)
60
    except ProgrammingError:
61
        pytest.skip('btree_Gist extension required')
62

  
63
    Booking.objects.create(event=event)
64
    check_ignore_reason(event, None)
65
    Booking.objects.create(event=event, cancellation_datetime=now())
66
    check_ignore_reason(event, 'cancel')
67
    Booking.objects.update(cancellation_datetime=None)
68
    check_ignore_reason(event, None)
69
    Booking.objects.update(cancellation_datetime=now())
70
    check_ignore_reason(event, 'cancel')
71
    Booking.objects.first().delete()
72
    check_ignore_reason(event, 'cancel')
73
    Booking.objects.all().delete()
74
    check_ignore_reason(event, None)
75

  
76

  
77
def test_event_end_datetime():
78
    if connection.vendor != 'postgresql':
79
        pytest.skip('postgresql required')
80

  
81
    agenda = Agenda.objects.create(label='Meetings', kind='meetings')
82
    meeting_type1 = MeetingType.objects.create(agenda=agenda, label='Foo', duration=60)
83
    meeting_type2 = MeetingType.objects.create(agenda=agenda, label='Foo', duration=45)
84

  
85
    event = Event.objects.create(start_datetime=now(), places=10, agenda=agenda)
86

  
87
    try:
88
        check_end_datetime(event, None)
89
    except ProgrammingError:
90
        pytest.skip('btree_Gist extension required')
91

  
92
    event.meeting_type = meeting_type1
93
    event.save()
94
    check_end_datetime(event, event.start_datetime + datetime.timedelta(minutes=60))
95
    event.meeting_type = meeting_type2
96
    event.save()
97
    check_end_datetime(event, event.start_datetime + datetime.timedelta(minutes=45))
98
    event.start_datetime = now()
99
    event.save()
100
    check_end_datetime(event, event.start_datetime + datetime.timedelta(minutes=45))
101
    event.meeting_type = None
102
    event.save()
103
    check_end_datetime(event, None)
104
    event.meeting_type = meeting_type2
105
    event.save()
106
    check_end_datetime(event, event.start_datetime + datetime.timedelta(minutes=45))
107

  
108
    event2 = Event.objects.create(start_datetime=now(), meeting_type=meeting_type1, places=10, agenda=agenda)
109
    check_end_datetime(event2, event2.start_datetime + datetime.timedelta(minutes=60))
110
    meeting_type1.duration = 42
111
    meeting_type1.save()
112
    check_end_datetime(event2, event2.start_datetime + datetime.timedelta(minutes=42))
113
    check_end_datetime(event, event.start_datetime + datetime.timedelta(minutes=45))
114

  
115

  
116
def test_meeting_event_exclusion_constraint():
117
    if connection.vendor != 'postgresql':
118
        pytest.skip('postgresql required')
119

  
120
    agenda = Agenda.objects.create(label='Meetings', kind='meetings')
121
    meeting_type1 = MeetingType.objects.create(agenda=agenda, label='Foo 1', duration=60)
122
    meeting_type2 = MeetingType.objects.create(agenda=agenda, label='Foo 2', duration=30)
123
    desk1 = Desk.objects.create(agenda=agenda, label='Desk 1')
124
    desk2 = Desk.objects.create(agenda=agenda, label='Desk 2')
125

  
126
    # create an event
127
    event1 = Event.objects.create(
128
        start_datetime=now(), meeting_type=meeting_type1, places=10, agenda=agenda, desk=desk1
129
    )
130

  
131
    try:
132
        check_ignore_reason(event1, None)
133
    except ProgrammingError:
134
        pytest.skip('btree_Gist extension required')
135

  
136
    # create a event with the same date range on other desk => no error
137
    Event.objects.create(
138
        start_datetime=event1.start_datetime, meeting_type=meeting_type1, places=10, agenda=agenda, desk=desk2
139
    )
140
    # no check if no meeting_type set
141
    Event.objects.create(start_datetime=event1.start_datetime, places=10, agenda=agenda, desk=desk1)
142
    # no check if no desk set
143
    Event.objects.create(
144
        start_datetime=event1.start_datetime, meeting_type=meeting_type1, places=10, agenda=agenda
145
    )
146

  
147
    # create an event just after the first one => ok
148
    Event.objects.create(
149
        start_datetime=event1.start_datetime + datetime.timedelta(minutes=60),
150
        meeting_type=meeting_type1,
151
        places=10,
152
        agenda=agenda,
153
        desk=desk1,
154
    )
155

  
156
    # try to create an event with the same date range and the same desk => error
157
    with pytest.raises(IntegrityError):
158
        with transaction.atomic():
159
            Event.objects.create(
160
                start_datetime=event1.start_datetime,
161
                meeting_type=meeting_type1,
162
                places=10,
163
                agenda=agenda,
164
                desk=desk1,
165
            )
166
    # try to create an event with the same start date, other duration and the same desk => error
167
    with pytest.raises(IntegrityError):
168
        with transaction.atomic():
169
            Event.objects.create(
170
                start_datetime=event1.start_datetime,
171
                meeting_type=meeting_type2,
172
                places=10,
173
                agenda=agenda,
174
                desk=desk1,
175
            )
176
    # try to create an event overlaping the first one on the same desk => error
177
    with pytest.raises(IntegrityError):
178
        with transaction.atomic():
179
            Event.objects.create(
180
                start_datetime=event1.start_datetime + datetime.timedelta(minutes=10),
181
                meeting_type=meeting_type1,
182
                places=10,
183
                agenda=agenda,
184
                desk=desk1,
185
            )
186
    with pytest.raises(IntegrityError):
187
        with transaction.atomic():
188
            Event.objects.create(
189
                start_datetime=event1.start_datetime + datetime.timedelta(minutes=10),
190
                meeting_type=meeting_type2,
191
                places=10,
192
                agenda=agenda,
193
                desk=desk1,
194
            )
195
    with pytest.raises(IntegrityError):
196
        with transaction.atomic():
197
            Event.objects.create(
198
                start_datetime=event1.start_datetime - datetime.timedelta(minutes=10),
199
                meeting_type=meeting_type1,
200
                places=10,
201
                agenda=agenda,
202
                desk=desk1,
203
            )
204
    with pytest.raises(IntegrityError):
205
        with transaction.atomic():
206
            Event.objects.create(
207
                start_datetime=event1.start_datetime - datetime.timedelta(minutes=10),
208
                meeting_type=meeting_type2,
209
                places=10,
210
                agenda=agenda,
211
                desk=desk1,
212
            )
213
    # but if event1 is cancelled it's ok
214
    Booking.objects.create(event=event1, cancellation_datetime=now())
215
    Event.objects.create(
216
        start_datetime=event1.start_datetime, meeting_type=meeting_type1, places=10, agenda=agenda, desk=desk1
217
    )
7
-