Projet

Général

Profil

0001-datasource-collect-agendas-48282.patch

Lauréline Guérin, 16 février 2021 16:00

Télécharger (21 ko)

Voir les différences:

Subject: [PATCH 1/5] datasource: collect agendas (#48282)

 tests/conftest.py               |   5 +
 tests/test_datasource_chrono.py | 328 ++++++++++++++++++++++++++++++++
 wcs/admin/data_sources.py       |  10 +-
 wcs/ctl/check_hobos.py          |   2 +
 wcs/data_sources.py             | 117 +++++++++++-
 5 files changed, 446 insertions(+), 16 deletions(-)
 create mode 100644 tests/test_datasource_chrono.py
tests/conftest.py
29 29
    return value
30 30

  
31 31

  
32
@pytest.fixture
33
def chrono_url(request, pub):
34
    return site_options(request, pub, 'options', 'chrono_url', 'http://chrono.example.net/')
35

  
36

  
32 37
@pytest.fixture
33 38
def fargo_url(request, pub):
34 39
    return site_options(request, pub, 'options', 'fargo_url', 'http://fargo.example.net/')
tests/test_datasource_chrono.py
1
# -*- coding: utf-8 -*-
2

  
3
import pytest
4
import json
5
import shutil
6

  
7
from django.utils.six import StringIO
8

  
9
from quixote import cleanup
10
from wcs import fields
11
from wcs.data_sources import NamedDataSource, build_agenda_datasources, collect_agenda_data
12
from wcs.formdef import FormDef
13
from wcs.qommon.misc import ConnectionError
14
from wcs.qommon.http_request import HTTPRequest
15

  
16
import mock
17
from utilities import create_temporary_pub
18

  
19

  
20
def setup_module(module):
21
    cleanup()
22

  
23
    global pub
24

  
25
    pub = create_temporary_pub()
26
    pub.cfg['debug'] = {'logger': True}
27
    pub.write_cfg()
28
    pub.set_config()
29

  
30

  
31
def teardown_module(module):
32
    shutil.rmtree(pub.APP_DIR)
33

  
34

  
35
@pytest.fixture
36
def pub(request):
37
    req = HTTPRequest(None, {'SERVER_NAME': 'example.net', 'SCRIPT_NAME': ''})
38
    pub.set_app_dir(req)
39
    pub._set_request(req)
40
    return pub
41

  
42

  
43
AGENDA_EVENTS_DATA = [
44
    {
45
        "api": {
46
            "datetimes_url": "http://chrono.example.net/api/agenda/events-A/datetimes/",
47
        },
48
        "id": "events-A",
49
        "kind": "events",
50
        "text": "Events A",
51
    },
52
    {
53
        "api": {
54
            "datetimes_url": "http://chrono.example.net/api/agenda/events-B/datetimes/",
55
        },
56
        "id": "events-B",
57
        "kind": "events",
58
        "text": "Events B",
59
    },
60
]
61

  
62

  
63
AGENDA_MEETINGS_DATA = [
64
    {
65
        "api": {"meetings_url": "http://chrono.example.net/api/agenda/meetings-A/meetings/"},
66
        "id": "meetings-A",
67
        "kind": "meetings",
68
        "text": "Meetings A",
69
    },
70
    {
71
        "api": {
72
            "meetings_url": "http://chrono.example.net/api/agenda/virtual-B/meetings/",
73
        },
74
        "id": "virtual-B",
75
        "kind": "virtual",
76
        "text": "Virtual B",
77
    },
78
]
79

  
80

  
81
AGENDA_MEETING_TYPES_DATA = {
82
    'meetings-A': [
83
        {
84
            "api": {
85
                "datetimes_url": "http://chrono.example.net/api/agenda/meetings-A/meetings/mt-1/datetimes/"
86
            },
87
            "id": "mt-1",
88
            "text": "MT 1",
89
        },
90
        {
91
            "api": {
92
                "datetimes_url": "http://chrono.example.net/api/agenda/meetings-A/meetings/mt-2/datetimes/"
93
            },
94
            "id": "mt-2",
95
            "text": "MT 2",
96
        },
97
    ],
98
    'virtual-B': [
99
        {
100
            "api": {
101
                "datetimes_url": "http://chrono.example.net/api/agenda/virtual-B/meetings/mt-3/datetimes/"
102
            },
103
            "id": "mt-3",
104
            "text": "MT 3",
105
        },
106
    ],
107
}
108

  
109

  
110
@mock.patch('wcs.qommon.misc.urlopen')
111
def test_collect_agenda_data(urlopen, pub, chrono_url):
112
    pub.load_site_options()
113
    NamedDataSource.wipe()
114

  
115
    urlopen.side_effect = lambda *args: StringIO('{"data": []}')
116
    assert collect_agenda_data(pub) == []
117
    assert urlopen.call_args_list == [mock.call('http://chrono.example.net/api/agenda/')]
118

  
119
    urlopen.side_effect = ConnectionError
120
    urlopen.reset_mock()
121
    assert collect_agenda_data(pub) is None
122
    assert urlopen.call_args_list == [mock.call('http://chrono.example.net/api/agenda/')]
123

  
124
    # events agenda
125
    urlopen.side_effect = lambda *args: StringIO(json.dumps({"data": AGENDA_EVENTS_DATA}))
126
    urlopen.reset_mock()
127
    assert collect_agenda_data(pub) == [
128
        {'text': 'Events A', 'url': 'http://chrono.example.net/api/agenda/events-A/datetimes/'},
129
        {'text': 'Events B', 'url': 'http://chrono.example.net/api/agenda/events-B/datetimes/'},
130
    ]
131
    assert urlopen.call_args_list == [mock.call('http://chrono.example.net/api/agenda/')]
132

  
133
    # meetings agenda
134
    urlopen.side_effect = [
135
        StringIO(json.dumps({"data": AGENDA_MEETINGS_DATA})),
136
        StringIO(json.dumps({"data": AGENDA_MEETING_TYPES_DATA['meetings-A']})),
137
        StringIO(json.dumps({"data": AGENDA_MEETING_TYPES_DATA['virtual-B']})),
138
    ]
139
    urlopen.reset_mock()
140
    assert collect_agenda_data(pub) == [
141
        {
142
            'text': 'Meetings A - Slot types',
143
            'url': 'http://chrono.example.net/api/agenda/meetings-A/meetings/',
144
        },
145
        {
146
            'text': 'Meetings A - Slots of type MT 1',
147
            'url': 'http://chrono.example.net/api/agenda/meetings-A/meetings/mt-1/datetimes/',
148
        },
149
        {
150
            'text': 'Meetings A - Slots of type MT 2',
151
            'url': 'http://chrono.example.net/api/agenda/meetings-A/meetings/mt-2/datetimes/',
152
        },
153
        {'text': 'Virtual B - Slot types', 'url': 'http://chrono.example.net/api/agenda/virtual-B/meetings/'},
154
        {
155
            'text': 'Virtual B - Slots of type MT 3',
156
            'url': 'http://chrono.example.net/api/agenda/virtual-B/meetings/mt-3/datetimes/',
157
        },
158
    ]
159
    assert urlopen.call_args_list == [
160
        mock.call('http://chrono.example.net/api/agenda/'),
161
        mock.call('http://chrono.example.net/api/agenda/meetings-A/meetings/'),
162
        mock.call('http://chrono.example.net/api/agenda/virtual-B/meetings/'),
163
    ]
164

  
165
    # if meeting types could not be collected
166
    urlopen.side_effect = [
167
        StringIO(json.dumps({"data": AGENDA_MEETINGS_DATA})),
168
        StringIO(json.dumps({"data": AGENDA_MEETING_TYPES_DATA['meetings-A']})),
169
        ConnectionError,
170
    ]
171
    urlopen.reset_mock()
172
    assert collect_agenda_data(pub) is None
173
    assert urlopen.call_args_list == [
174
        mock.call('http://chrono.example.net/api/agenda/'),
175
        mock.call('http://chrono.example.net/api/agenda/meetings-A/meetings/'),
176
        mock.call('http://chrono.example.net/api/agenda/virtual-B/meetings/'),
177
    ]
178

  
179
    urlopen.side_effect = [
180
        StringIO(json.dumps({"data": AGENDA_MEETINGS_DATA})),
181
        ConnectionError,
182
    ]
183
    urlopen.reset_mock()
184
    assert collect_agenda_data(pub) is None
185
    assert urlopen.call_args_list == [
186
        mock.call('http://chrono.example.net/api/agenda/'),
187
        mock.call('http://chrono.example.net/api/agenda/meetings-A/meetings/'),
188
    ]
189

  
190

  
191
@mock.patch('wcs.data_sources.collect_agenda_data')
192
def test_build_agenda_datasources_without_chrono(mock_collect, pub):
193
    NamedDataSource.wipe()
194
    build_agenda_datasources(pub)
195
    assert mock_collect.call_args_list == []
196
    assert NamedDataSource.count() == 0
197

  
198

  
199
@mock.patch('wcs.data_sources.collect_agenda_data')
200
def test_build_agenda_datasources(mock_collect, pub, chrono_url):
201
    pub.load_site_options()
202
    NamedDataSource.wipe()
203

  
204
    # create some datasource, with same urls, but not external
205
    ds = NamedDataSource(name='Foo A')
206
    ds.data_source = {'type': 'json', 'value': 'http://chrono.example.net/api/agenda/events-A/datetimes/'}
207
    ds.store()
208
    ds = NamedDataSource(name='Foo B')
209
    ds.data_source = {'type': 'json', 'value': 'http://chrono.example.net/api/agenda/events-B/datetimes/'}
210
    ds.store()
211

  
212
    # error during collect
213
    mock_collect.return_value = None
214
    build_agenda_datasources(pub)
215
    assert NamedDataSource.count() == 2  # no changes
216

  
217
    # no agenda datasource found in chrono
218
    mock_collect.return_value = []
219
    build_agenda_datasources(pub)
220
    assert NamedDataSource.count() == 2  # no changes
221

  
222
    # 2 agenda datasources found
223
    mock_collect.return_value = [
224
        {'text': 'Events A', 'url': 'http://chrono.example.net/api/agenda/events-A/datetimes/'},
225
        {'text': 'Events B', 'url': 'http://chrono.example.net/api/agenda/events-B/datetimes/'},
226
    ]
227

  
228
    # agenda datasources does not exist, create them
229
    build_agenda_datasources(pub)
230
    assert NamedDataSource.count() == 2 + 2
231
    datasource1 = NamedDataSource.get(2 + 1)
232
    datasource2 = NamedDataSource.get(2 + 2)
233
    assert datasource1.name == 'Events A'
234
    assert datasource1.external == 'agenda'
235
    assert datasource1.external_status is None
236
    assert datasource1.data_source == {
237
        'type': 'json',
238
        'value': 'http://chrono.example.net/api/agenda/events-A/datetimes/',
239
    }
240
    assert datasource2.name == 'Events B'
241
    assert datasource2.external == 'agenda'
242
    assert datasource2.external_status is None
243
    assert datasource2.data_source == {
244
        'type': 'json',
245
        'value': 'http://chrono.example.net/api/agenda/events-B/datetimes/',
246
    }
247

  
248
    # again, datasources already exist, but name is wrong => change it
249
    datasource1.name = 'wrong'
250
    datasource1.store()
251
    datasource2.name = 'wrong again'
252
    datasource2.store()
253
    build_agenda_datasources(pub)
254
    assert NamedDataSource.count() == 2 + 2
255
    datasource1 = NamedDataSource.get(2 + 1)
256
    datasource2 = NamedDataSource.get(2 + 2)
257
    assert datasource1.name == 'Events A'
258
    assert datasource2.name == 'Events B'
259

  
260
    # all datasources does not exist, one is unknown
261
    datasource1.data_source['value'] = 'http://chrono.example.net/api/agenda/events-FOOBAR/datetimes/'
262
    datasource1.store()
263

  
264
    build_agenda_datasources(pub)
265
    assert NamedDataSource.count() == 2 + 2
266
    # first datasource was deleted, because not found and not used
267
    datasource2 = NamedDataSource.get(2 + 2)
268
    datasource3 = NamedDataSource.get(2 + 3)
269
    assert datasource2.name == 'Events B'
270
    assert datasource2.external == 'agenda'
271
    assert datasource2.external_status is None
272
    assert datasource2.data_source == {
273
        'type': 'json',
274
        'value': 'http://chrono.example.net/api/agenda/events-B/datetimes/',
275
    }
276
    assert datasource3.name == 'Events A'
277
    assert datasource3.external == 'agenda'
278
    assert datasource3.external_status is None
279
    assert datasource3.data_source == {
280
        'type': 'json',
281
        'value': 'http://chrono.example.net/api/agenda/events-A/datetimes/',
282
    }
283

  
284
    # all datasources does not exist, one is unknown but used
285
    FormDef.wipe()
286
    formdef = FormDef()
287
    formdef.name = 'foobar'
288
    formdef.fields = [
289
        fields.ItemField(id='0', label='string', type='item', data_source={'type': datasource3.slug}),
290
    ]
291
    formdef.store()
292
    assert datasource3.is_used_in_formdef(formdef)
293
    datasource3.data_source['value'] = 'http://chrono.example.net/api/agenda/events-FOOBAR/datetimes/'
294
    datasource3.store()
295
    build_agenda_datasources(pub)
296
    assert NamedDataSource.count() == 2 + 3
297
    datasource2 = NamedDataSource.get(2 + 2)
298
    datasource3 = NamedDataSource.get(2 + 3)
299
    datasource4 = NamedDataSource.get(2 + 4)
300
    assert datasource2.name == 'Events B'
301
    assert datasource2.external == 'agenda'
302
    assert datasource2.external_status is None
303
    assert datasource2.data_source == {
304
        'type': 'json',
305
        'value': 'http://chrono.example.net/api/agenda/events-B/datetimes/',
306
    }
307
    assert datasource3.name == 'Events A'
308
    assert datasource3.external == 'agenda'
309
    assert datasource3.external_status == 'not-found'
310
    assert datasource3.data_source == {
311
        'type': 'json',
312
        'value': 'http://chrono.example.net/api/agenda/events-FOOBAR/datetimes/',
313
    }
314
    assert datasource4.name == 'Events A'
315
    assert datasource4.external == 'agenda'
316
    assert datasource4.external_status is None
317
    assert datasource4.data_source == {
318
        'type': 'json',
319
        'value': 'http://chrono.example.net/api/agenda/events-A/datetimes/',
320
    }
321

  
322
    # a datasource was marked as unknown
323
    datasource4.external_status = 'not-found'
324
    datasource4.store()
325
    build_agenda_datasources(pub)
326
    assert NamedDataSource.count() == 2 + 3
327
    datasource4 = NamedDataSource.get(2 + 4)
328
    assert datasource4.external_status is None
wcs/admin/data_sources.py
43 43
        if self.datasource is None:
44 44
            self.datasource = NamedDataSource()
45 45

  
46
    def is_used(self):
47
        for formdef in get_formdefs_of_all_kinds():
48
            if self.datasource.is_used_in_formdef(formdef):
49
                return True
50
        return False
51

  
52 46
    def get_form(self):
53 47
        form = Form(enctype='multipart/form-data', advanced_label=_('Additional options'))
54 48
        form.add(StringWidget, 'name', title=_('Name'), required=True, size=30, value=self.datasource.name)
......
139 133
                'data-dynamic-display-value': 'geojson',
140 134
            },
141 135
        )
142
        if self.datasource.slug and not self.is_used():
136
        if self.datasource.slug and not self.datasource.is_used():
143 137
            form.add(
144 138
                StringWidget,
145 139
                'slug',
......
332 326

  
333 327
    def delete(self):
334 328
        form = Form(enctype='multipart/form-data')
335
        if not self.datasource_ui.is_used():
329
        if not self.datasource.is_used():
336 330
            form.widgets.append(
337 331
                HtmlWidget('<p>%s</p>' % _('You are about to irrevocably delete this data source.'))
338 332
            )
wcs/ctl/check_hobos.py
475 475
                continue
476 476
            if service.get('service-id') == 'fargo':
477 477
                config.set('options', 'fargo_url', service.get('base_url'))
478
            elif service.get('service-id') == 'chrono':
479
                config.set('options', 'chrono_url', service.get('base_url'))
478 480

  
479 481
        try:
480 482
            portal_agent_url = config.get('variables', 'portal_agent_url')
wcs/data_sources.py
22 22
from django.utils import six
23 23
from django.utils.encoding import force_text, force_bytes
24 24
from django.utils.six.moves.urllib import parse as urllib
25
from django.utils.six.moves.urllib import parse as urlparse
25 26

  
26 27
from quixote import get_publisher, get_request, get_session
27 28
from quixote.html import TemplateIO
28 29

  
29 30
from .qommon import _, force_str
31
from .qommon import misc
32
from .qommon import get_logger
33
from .qommon.cron import CronJob
30 34
from .qommon.form import *
31 35
from .qommon.humantime import seconds2humanduration
32 36
from .qommon.misc import get_variadic_url
33
from .qommon import misc
34
from .qommon import get_logger
35

  
37
from .qommon.publisher import get_publisher_class
36 38
from .qommon.storage import StorableObject
37 39
from .qommon.template import Template
38 40
from .qommon.xml_storage import XmlStorableObject
......
145 147
    return tupled_items
146 148

  
147 149

  
148
def get_json_from_url(url, data_source):
150
def get_json_from_url(url, data_source=None, log_message_part='JSON data source'):
149 151
    url = sign_url_auto_orig(url)
152
    data_source = data_source or {}
150 153
    data_key = data_source.get('data_attribute') or 'data'
151 154
    geojson = data_source.get('type') == 'geojson'
152 155
    try:
......
162 165
            if not isinstance(entries.get(data_key), list):
163 166
                raise ValueError('not a json dict with a %s list attribute' % data_key)
164 167
    except misc.ConnectionError as e:
165
        get_logger().warning('Error loading JSON data source (%s)' % str(e))
168
        get_logger().warning('Error loading %s (%s)' % (log_message_part, str(e)))
166 169
        return None
167 170
    except (ValueError, TypeError) as e:
168
        get_logger().warning('Error reading JSON data source output (%s)' % str(e))
171
        get_logger().warning('Error reading %s output (%s)' % (log_message_part, str(e)))
169 172
        return None
170 173
    return entries
171 174

  
......
364 367
    text_attribute = None
365 368
    id_property = None
366 369
    label_template_property = None
370
    external = None
371
    external_status = None
367 372

  
368 373
    # declarations for serialization
369 374
    XML_NODES = [
......
378 383
        ('text_attribute', 'str'),
379 384
        ('id_property', 'str'),
380 385
        ('label_template_property', 'str'),
386
        ('external', 'str'),
387
        ('external_status', 'str'),
381 388
        ('data_source', 'data_source'),
382 389
    ]
383 390

  
......
700 707
            url = get_variadic_url(url, vars)
701 708
        return url
702 709

  
703
    def is_used_in_formdef(self, formdef):
704
        from .fields import WidgetField
710
    def is_used(self):
711
        from wcs.formdef import get_formdefs_of_all_kinds
705 712

  
713
        for formdef in get_formdefs_of_all_kinds():
714
            if self.is_used_in_formdef(formdef):
715
                return True
716
        return False
717

  
718
    def is_used_in_formdef(self, formdef):
706 719
        for field in formdef.fields or []:
707 720
            data_source = getattr(field, 'data_source', None)
708 721
            if not data_source:
......
733 746

  
734 747
    def inspect_keys(self):
735 748
        return []
749

  
750

  
751
def has_chrono(publisher):
752
    return publisher.get_site_option('chrono_url') is not None
753

  
754

  
755
def chrono_url(publisher, url):
756
    chrono_url = publisher.get_site_option('chrono_url')
757
    return urlparse.urljoin(chrono_url, url)
758

  
759

  
760
def collect_agenda_data(publisher):
761
    agenda_url = chrono_url(publisher, 'api/agenda/')
762
    result = get_json_from_url(agenda_url, log_message_part='agenda')
763
    if result is None:
764
        return
765

  
766
    # build datasources from chrono
767
    agenda_data = []
768
    for agenda in result.get('data') or []:
769
        if agenda['kind'] == 'events':
770
            agenda_data.append({'text': agenda['text'], 'url': agenda['api']['datetimes_url']})
771
        elif agenda['kind'] in ['meetings', 'virtual']:
772
            agenda_data.append(
773
                {'text': _('%s - Slot types') % agenda['text'], 'url': agenda['api']['meetings_url']}
774
            )
775
            # get also meeting types
776
            mt_url = chrono_url(publisher, 'api/agenda/%s/meetings/' % agenda['id'])
777
            mt_results = get_json_from_url(mt_url, log_message_part='agenda')
778
            if mt_results is None:
779
                return
780
            for meetingtype in mt_results.get('data') or []:
781
                agenda_data.append(
782
                    {
783
                        'text': _('%s - Slots of type %s') % (agenda['text'], meetingtype['text']),
784
                        'url': meetingtype['api']['datetimes_url'],
785
                    }
786
                )
787
    return agenda_data
788

  
789

  
790
def build_agenda_datasources(publisher):
791
    if not has_chrono(publisher):
792
        return
793

  
794
    agenda_data = collect_agenda_data(publisher)
795
    if agenda_data is None:
796
        return
797

  
798
    # fetch existing datasources
799
    existing_datasources = {}
800
    for datasource in NamedDataSource.select():
801
        if datasource.external != 'agenda':
802
            continue
803
        existing_datasources[datasource.data_source['value']] = datasource
804
    seen_datasources = []
805

  
806
    # build datasources from chrono
807
    for agenda in agenda_data:
808
        url = agenda['url']
809
        datasource = existing_datasources.get(url)
810
        if datasource is None:
811
            datasource = NamedDataSource()
812
            datasource.external = 'agenda'
813
            datasource.data_source = {'type': 'json', 'value': url}
814
        datasource.external_status = None  # reset
815
        datasource.name = agenda['text']
816
        datasource.store()
817
        # maintain caches
818
        existing_datasources[url] = datasource
819
        seen_datasources.append(url)
820

  
821
    # now check outdated agenda datasources
822
    for url, datasource in existing_datasources.items():
823
        if url in seen_datasources:
824
            continue
825
        if datasource.is_used():
826
            datasource.external_status = 'not-found'
827
            datasource.store()
828
            continue
829
        datasource.remove_self()
830

  
831

  
832
if get_publisher_class():
833
    # every hour: check for agenda datasources
834
    get_publisher_class().register_cronjob(
835
        CronJob(build_agenda_datasources, name='build_agenda_datasources', minutes=[0])
836
    )
736
-