Projet

Général

Profil

0009-add-utilities-to-call-w.c.s.-APIs-31595.patch

Benjamin Dauvergne, 19 avril 2019 14:59

Télécharger (43,9 ko)

Voir les différences:

Subject: [PATCH 09/11] add utilities to call w.c.s. APIs (#31595)

 .gitignore                 |   1 +
 get_wcs.sh                 |   4 +
 passerelle/utils/wcs.py    | 736 +++++++++++++++++++++++++++++++++++++
 tests/wcs/conftest.py      | 450 +++++++++++++++++++++++
 tests/wcs/test_conftest.py |  79 ++++
 tox.ini                    |   6 +-
 6 files changed, 1275 insertions(+), 1 deletion(-)
 create mode 100755 get_wcs.sh
 create mode 100644 passerelle/utils/wcs.py
 create mode 100644 tests/wcs/conftest.py
 create mode 100644 tests/wcs/test_conftest.py
.gitignore
3 3
passerelle.sqlite3
4 4
media
5 5
/static
6
/wcs
get_wcs.sh
1
#!/bin/sh -xue
2

  
3
test -d wcs || git clone http://git.entrouvert.org/wcs.git
4
(cd wcs && git pull)
passerelle/utils/wcs.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2019 Entr'ouvert
3
#
4
# This program is free software: you can redistribute it and/or modify it
5
# under the terms of the GNU Affero General Public License as published
6
# by the Free Software Foundation, either version 3 of the License, or
7
# (at your option) any later version.
8
#
9
# This program is distributed in the hope that it will be useful,
10
# but WITHOUT ANY WARRANTY; without even the implied warranty of
11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12
# GNU Affero General Public License for more details.
13
#
14
# You should have received a copy of the GNU Affero General Public License
15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16

  
17

  
18
import collections
19
import base64
20
import copy
21
import logging
22
import datetime
23
import contextlib
24
import json
25

  
26
import requests
27
import isodate
28

  
29
from django.conf import settings
30
from django.db import models
31
from django.core.cache import cache
32
from django import forms
33
from django.utils.six.moves.urllib import parse as urlparse
34
from django.utils import six
35

  
36
from passerelle.base import signature
37

  
38

  
39
class WcsApiError(Exception):
40
    pass
41

  
42

  
43
class JSONFile(object):
44
    def __init__(self, d):
45
        self.d = d
46

  
47
    @property
48
    def filename(self):
49
        return self.d.get('filename', '')
50

  
51
    @property
52
    def content_type(self):
53
        return self.d.get('content_type', 'application/octet-stream')
54

  
55
    @property
56
    def content(self):
57
        return base64.b64decode(self.d['content'])
58

  
59

  
60
def to_dict(o):
61
    if hasattr(o, 'to_dict'):
62
        return o.to_dict()
63
    elif isinstance(o, dict):
64
        return {k: to_dict(v) for k, v in o.items()}
65
    elif isinstance(o, (list, tuple)):
66
        return [to_dict(v) for v in o]
67
    else:
68
        return o
69

  
70

  
71
class BaseObject(object):
72
    def __init__(self, wcs_api, **kwargs):
73
        self._wcs_api = wcs_api
74
        self.__dict__.update(**kwargs)
75

  
76
    def to_dict(self):
77
        d = collections.OrderedDict()
78
        for key, value in self.__dict__.items():
79
            if key[0] == '_':
80
                continue
81
            d[key] = to_dict(value)
82
        return d
83

  
84

  
85
class FormDataWorkflow(BaseObject):
86
    status = None
87
    fields = None
88

  
89
    def __init__(self, wcs_api, **kwargs):
90
        super(FormDataWorkflow, self).__init__(wcs_api, **kwargs)
91
        if self.status is not None:
92
            self.status = BaseObject(wcs_api, **self.status)
93
        self.fields = self.fields or {}
94

  
95

  
96
class EvolutionUser(BaseObject):
97
    id = None
98
    name = None
99
    NameID = None
100
    email = None
101

  
102

  
103
class Evolution(BaseObject):
104
    who = None
105
    status = None
106
    parts = None
107

  
108
    def __init__(self, wcs_api, **kwargs):
109
        super(Evolution, self).__init__(wcs_api, **kwargs)
110
        self.time = isodate.parse_datetime(self.time)
111
        if self.parts:
112
            self.parts = [BaseObject(wcs_api, **part) for part in self.parts]
113
        if self.who:
114
            self.who = EvolutionUser(wcs_api, **self.who)
115

  
116

  
117
@six.python_2_unicode_compatible
118
class FormData(BaseObject):
119
    geolocations = None
120
    evolution = None
121
    submissions = None
122
    workflow = None
123
    roles = None
124
    with_files = False
125

  
126
    def __init__(self, wcs_api, forms, **kwargs):
127
        self.forms = forms
128
        super(FormData, self).__init__(wcs_api, **kwargs)
129
        self.receipt_time = isodate.parse_datetime(self.receipt_time)
130
        if self.submissions:
131
            self.submission = BaseObject(wcs_api, **self.submission)
132
        if self.workflow:
133
            self.workflow = FormDataWorkflow(wcs_api, **self.workflow)
134
        self.evolution = [Evolution(wcs_api, **evo) for evo in self.evolution or []]
135
        self.functions = {}
136
        self.concerned_roles = []
137
        self.action_roles = []
138
        for function in self.roles or []:
139
            roles = [Role(wcs_api, **r) for r in self.roles[function]]
140
            if function == 'concerned':
141
                self.concerned_roles.extend(roles)
142
            elif function == 'actions':
143
                self.concerned_roles.extend(roles)
144
            else:
145
                try:
146
                    self.functions[function] = roles[0]
147
                except IndexError:
148
                    self.functions[function] = None
149
        if 'roles' in self.__dict__:
150
            del self.roles
151

  
152
    def __str__(self):
153
        return '{self.formdef} - {self.id}'.format(self=self)
154

  
155
    @property
156
    def full(self):
157
        if self.with_files:
158
            return self
159
        if not hasattr(self, '_full'):
160
            self._full = self.forms[self.id]
161
        return self._full
162

  
163
    @property
164
    def anonymized(self):
165
        return self.forms.anonymized[self.id]
166

  
167
    @property
168
    def endpoint_delay(self):
169
        '''Compute delay as the time when the last not endpoint status precedes an endpoint
170
           status.'''
171
        statuses_map = self.formdef.schema.workflow.statuses_map
172
        s = 0
173
        for evo in self.evolution[::-1]:
174
            if evo.status:
175
                try:
176
                    status = statuses_map[evo.status]
177
                except KeyError:  # happen when workflow has changed
178
                    return
179
                if status.endpoint:
180
                    s = 1
181
                    last = evo.time - self.receipt_time
182
                else:
183
                    if s == 1:
184
                        return last
185
                    else:
186
                        return
187

  
188
    def __getitem__(self, key):
189
        value = self.full.fields.get(key)
190
        # unserialize files
191
        if isinstance(value, dict) and 'content' in value:
192
            return JSONFile(value)
193
        return value
194

  
195

  
196
class Workflow(BaseObject):
197
    statuses = None
198
    fields = None
199

  
200
    def __init__(self, wcs_api, **kwargs):
201
        super(Workflow, self).__init__(wcs_api, **kwargs)
202
        self.statuses = [BaseObject(wcs_api, **v) for v in (self.statuses or [])]
203
        assert not hasattr(self.statuses[0], 'startpoint'), 'startpoint is exported by w.c.s. FIXME'
204
        for status in self.statuses:
205
            status.startpoint = False
206
        self.statuses[0].startpoint = True
207
        self.statuses_map = dict((s.id, s) for s in self.statuses)
208
        self.fields = [Field(wcs_api, **field) for field in (self.fields or [])]
209

  
210

  
211
class Field(BaseObject):
212
    items = None
213
    options = None
214
    varname = None
215
    in_filters = False
216
    anonymise = None
217

  
218

  
219
class Schema(BaseObject):
220
    category_id = None
221
    category = None
222
    geolocations = None
223

  
224
    def __init__(self, wcs_api, **kwargs):
225
        super(Schema, self).__init__(wcs_api, **kwargs)
226
        self.workflow = Workflow(wcs_api, **self.workflow)
227
        self.fields = [Field(wcs_api, **f) for f in self.fields]
228
        self.geolocations = sorted((k, v) for k, v in (self.geolocations or {}).items())
229

  
230

  
231
class FormDatas(object):
232
    def __init__(self, wcs_api, formdef, full=False, anonymize=False, batch=1000):
233
        self.wcs_api = wcs_api
234
        self.formdef = formdef
235
        self._full = full
236
        self.anonymize = anonymize
237
        self.batch = batch
238

  
239
    def __getitem__(self, slice_or_id):
240
        # get batch of forms
241
        if isinstance(slice_or_id, slice):
242
            def helper():
243
                if slice_or_id.stop <= slice_or_id.start or slice_or_id.step:
244
                    raise ValueError('invalid slice %s' % slice_or_id)
245
                offset = slice_or_id.start
246
                limit = slice_or_id.stop - slice_or_id.start
247

  
248
                url_parts = ['api/forms/{self.formdef.slug}/list'.format(self=self)]
249
                query = {}
250
                query['full'] = 'on' if self._full else 'off'
251
                if offset:
252
                    query['offset'] = str(offset)
253
                if limit:
254
                    query['limit'] = str(limit)
255
                if self.anonymize:
256
                    query['anonymise'] = 'on'
257
                url_parts.append('?%s' % urlparse.urlencode(query))
258
                for d in self.wcs_api.get_json(*url_parts):
259
                    # w.c.s. had a bug where some formdata lost their draft status, skip them
260
                    if not d.get('receipt_time'):
261
                        continue
262
                    yield FormData(wcs_api=self.wcs_api, forms=self, formdef=self.formdef, **d)
263
            return helper()
264
        # or get one form
265
        else:
266
            url_parts = ['api/forms/{formdef.slug}/{id}/'.format(formdef=self.formdef, id=slice_or_id)]
267
            if self.anonymize:
268
                url_parts.append('?anonymise=true')
269
            d = self.wcs_api.get_json(*url_parts)
270
            return FormData(wcs_api=self.wcs_api, forms=self, formdef=self.formdef, with_files=True, **d)
271

  
272
    @property
273
    def full(self):
274
        forms = copy.copy(self)
275
        forms._full = True
276
        return forms
277

  
278
    @property
279
    def anonymized(self):
280
        forms = copy.copy(self)
281
        forms.anonymize = True
282
        return forms
283

  
284
    def batched(self, batch):
285
        forms = copy.copy(self)
286
        forms.batch = batch
287
        return forms
288

  
289
    def __iter__(self):
290
        start = 0
291
        while True:
292
            empty = True
293
            for formdef in self[start:start + self.batch]:
294
                empty = False
295
                yield formdef
296
            if empty:
297
                break
298
            start += self.batch
299

  
300
    def __len__(self):
301
        return len(list((o for o in self)))
302

  
303

  
304
class CancelSubmitError(Exception):
305
    pass
306

  
307

  
308
class FormDefSubmit(object):
309
    formdef = None
310
    data = None
311
    user_email = None
312
    user_name_id = None
313
    backoffice_submission = False
314
    submission_channel = None
315
    submission_context = None
316
    draft = False
317

  
318
    def __init__(self, wcs_api, formdef, **kwargs):
319
        self.wcs_api = wcs_api
320
        self.formdef = formdef
321
        self.data = {}
322
        self.__dict__.update(kwargs)
323

  
324
    def payload(self):
325
        d = {
326
            'data': self.data.copy(),
327
        }
328
        if self.draft:
329
            d.setdefault('meta', {})['draft'] = True
330
        if self.backoffice_submission:
331
            d.setdefault('meta', {})['backoffice-submission'] = True
332
        if self.submission_context:
333
            d['context'] = self.submission_context
334
        if self.submission_channel:
335
            d.setdefault('context', {})['channel'] = self.submission_channel
336
        if self.user_email:
337
            d.setdefault('user', {})['email'] = self.user_email
338
        if self.user_name_id:
339
            d.setdefault('user', {})['NameID'] = self.user_name_id
340
        return d
341

  
342
    def set(self, field, value, **kwargs):
343
        if isinstance(field, Field):
344
            varname = field.varname
345
            if not varname:
346
                raise ValueError('field has no varname, submit is impossible')
347
        else:
348
            varname = field
349
            try:
350
                field = [f for f in self.formdef.schema.fields if f.varname == varname][0]
351
            except IndexError:
352
                raise ValueError('no field for varname %s' % varname)
353

  
354
        if value is None or value == {} or value == []:
355
            self.data.pop(varname, None)
356
        elif hasattr(self, '_set_type_%s' % field.type):
357
            getattr(self, '_set_type_%s' % field.type)(
358
                varname=varname,
359
                field=field,
360
                value=value, **kwargs)
361
        else:
362
            self.data[varname] = value
363

  
364
    def _set_type_item(self, varname, field, value, **kwargs):
365
        if isinstance(value, dict):
366
            if not set(value).issuperset(set(['id', 'text'])):
367
                raise ValueError('item field value must have id and text value')
368
        # clean previous values
369
        self.data.pop(varname, None)
370
        self.data.pop(varname + '_raw', None)
371
        self.data.pop(varname + '_structured', None)
372
        if isinstance(value, dict):
373
            # structured & display values
374
            self.data[varname + '_raw'] = value['id']
375
            self.data[varname] = value['text']
376
            if len(value) > 2:
377
                self.data[varname + '_structured'] = value
378
        else:
379
            # raw id in varname
380
            self.data[varname] = value
381

  
382
    def _set_type_items(self, varname, field, value, **kwargs):
383
        if not isinstance(value, list):
384
            raise TypeError('%s is an ItemsField it needs a list as value' % varname)
385

  
386
        has_dict = False
387
        for choice in value:
388
            if isinstance(value, dict):
389
                if not set(value).issuperset(set(['id', 'text'])):
390
                    raise ValueError('items field values must have id and text value')
391
                has_dict = True
392
        if has_dict:
393
            if not all(isinstance(choice, dict) for choice in value):
394
                raise ValueError('ItemsField value must be all structured or none')
395
        # clean previous values
396
        self.data.pop(varname, None)
397
        self.data.pop(varname + '_raw', None)
398
        self.data.pop(varname + '_structured', None)
399
        if has_dict:
400
            raw = self.data[varname + '_raw'] = []
401
            display = self.data[varname] = []
402
            structured = self.data[varname + '_structured'] = []
403
            for choice in value:
404
                raw.append(choice['id'])
405
                display.append(choice['text'])
406
                structured.append(choice)
407
        else:
408
            self.data[varname] = value[:]
409

  
410
    def _set_type_file(self, varname, field, value, **kwargs):
411
        filename = kwargs.get('filename')
412
        content_type = kwargs.get('content_type', 'application/octet-stream')
413
        if hasattr(value, 'read'):
414
            content = base64.b64encode(value.read())
415
        elif isinstance(value, six.binary_type):
416
            content = base64.b64encode(value)
417
        elif isinstance(value, dict):
418
            if not set(value).issuperset(set(['filename', 'content'])):
419
                raise ValueError('file field needs a dict value with filename and content')
420
            content = value['content']
421
            filename = value['filename']
422
            content_type = value.get('content_type', content_type)
423
        if not filename:
424
            raise ValueError('missing filename')
425
        self.data[varname] = {
426
            'filename': filename,
427
            'content': content,
428
            'content_type': content_type,
429
        }
430

  
431
    def _set_type_date(self, varname, field, value):
432
        if isinstance(value, six.string_types):
433
            value = datetime.datetime.strptime(value, '%Y-%m-%d').date()
434
        if isinstance(value, datetime.datetime):
435
            value = value.date()
436
        if isinstance(value, datetime.date):
437
            value = value.strftime('%Y-%m-%d')
438
        self.data[varname] = value
439

  
440
    def _set_type_map(self, varname, field, value):
441
        if not isinstance(value, dict):
442
            raise TypeError('value must be a dict for a map field')
443
        if set(value) != set(['lat', 'lon']):
444
            raise ValueError('map field expect keys lat and lon')
445
        self.data[varname] = value
446

  
447
    def _set_type_bool(self, varname, field, value):
448
        if isinstance(value, six.string_types):
449
            value = value.lower().strip() in ['yes', 'true', 'on']
450
        if not isinstance(value, bool):
451
            raise TypeError('value must be a boolean or a string true, yes, on, false, no, off')
452
        self.data[varname] = value
453

  
454
    def cancel(self):
455
        raise CancelSubmitError
456

  
457

  
458
@six.python_2_unicode_compatible
459
class FormDef(BaseObject):
460
    geolocations = None
461

  
462
    def __init__(self, wcs_api, **kwargs):
463
        self._wcs_api = wcs_api
464
        self.__dict__.update(**kwargs)
465

  
466
    def __str__(self):
467
        return self.title
468

  
469
    @property
470
    def formdatas(self):
471
        return FormDatas(wcs_api=self._wcs_api, formdef=self)
472

  
473
    @property
474
    def schema(self):
475
        if not hasattr(self, '_schema'):
476
            d = self._wcs_api.get_json('api/formdefs/{self.slug}/schema'.format(self=self))
477
            self._schema = Schema(self._wcs_api, **d)
478
        return self._schema
479

  
480
    @contextlib.contextmanager
481
    def submit(self, **kwargs):
482
        submitter = FormDefSubmit(
483
            wcs_api=self._wcs_api,
484
            formdef=self,
485
            **kwargs)
486
        try:
487
            yield submitter
488
        except CancelSubmitError:
489
            return
490
        payload = submitter.payload()
491
        d = self._wcs_api.post_json(payload, 'api/formdefs/{self.slug}/submit'.format(self=self))
492
        if d['err'] != 0:
493
            raise WcsApiError('submited returned an error: %s' % d)
494
        submitter.result = BaseObject(self._wcs_api, **d['data'])
495

  
496

  
497
class Role(BaseObject):
498
    pass
499

  
500

  
501
class Category(BaseObject):
502
    pass
503

  
504

  
505
class WcsObjects(object):
506
    url = None
507
    object_class = None
508

  
509
    def __init__(self, wcs_api):
510
        self.wcs_api = wcs_api
511

  
512
    def __getitem__(self, slug):
513
        if isinstance(slug, self.object_class):
514
            slug = slug.slug
515
        for instance in self:
516
            if instance.slug == slug:
517
                return instance
518
        raise KeyError('no instance with slug %r' % slug)
519

  
520
    def __iter__(self):
521
        for d in self.wcs_api.get_json(self.url)['data']:
522
            yield self.object_class(wcs_api=self.wcs_api, **d)
523

  
524
    def __len__(self):
525
        return len(list((o for o in self)))
526

  
527

  
528
class Roles(WcsObjects):
529
    # Paths are not coherent :/
530
    url = 'api/roles'
531
    object_class = Role
532

  
533

  
534
class FormDefs(WcsObjects):
535
    url = 'api/formdefs/'
536
    object_class = FormDef
537

  
538

  
539
class Categories(WcsObjects):
540
    url = 'api/categories/'
541
    object_class = Category
542

  
543

  
544
class WcsApi(object):
545
    def __init__(self, url, email=None, name_id=None, batch_size=1000, session=None, logger=None, orig=None, key=None):
546
        self.url = url
547
        self.batch_size = batch_size
548
        self.email = email
549
        self.name_id = name_id
550
        self.requests = session or requests.Session()
551
        self.logger = logger or logging.getLogger(__name__)
552
        self.orig = orig
553
        self.key = key
554

  
555
    def _build_url(self, url_parts):
556
        url = self.url
557
        for url_part in url_parts:
558
            url = urlparse.urljoin(url, url_part)
559
        return url
560

  
561
    def get_json(self, *url_parts):
562
        url = self._build_url(url_parts)
563
        params = {}
564
        if self.email:
565
            params['email'] = self.email
566
        if self.name_id:
567
            params['NameID'] = self.name_id
568
        if self.orig:
569
            params['orig'] = self.orig
570
        query_string = urlparse.urlencode(params)
571
        complete_url = url + ('&' if '?' in url else '?') + query_string
572
        final_url = complete_url
573
        if self.key:
574
            final_url = signature.sign_url(final_url, self.key)
575
        try:
576
            response = self.requests.get(final_url)
577
            response.raise_for_status()
578
        except requests.RequestException as e:
579
            content = getattr(getattr(e, 'response', None), 'content', None)
580
            raise WcsApiError('GET request failed', final_url, e, content)
581
        else:
582
            try:
583
                return response.json()
584
            except ValueError as e:
585
                raise WcsApiError('Invalid JSON content', final_url, e)
586

  
587
    def post_json(self, data, *url_parts):
588
        url = self._build_url(url_parts)
589
        params = {}
590
        if self.email:
591
            params['email'] = self.email
592
        if self.name_id:
593
            params['NameID'] = self.name_id
594
        if self.orig:
595
            params['orig'] = self.orig
596
        query_string = urlparse.urlencode(params)
597
        complete_url = url + ('&' if '?' in url else '?') + query_string
598
        final_url = complete_url
599
        if self.key:
600
            final_url = signature.sign_url(final_url, self.key)
601
        try:
602
            response = self.requests.post(final_url, data=json.dumps(data), headers={'content-type': 'application/json'})
603
            response.raise_for_status()
604
        except requests.RequestException as e:
605
            content = getattr(getattr(e, 'response', None), 'content', None)
606
            raise WcsApiError('POST request failed', final_url, e, content)
607
        else:
608
            try:
609
                return response.json()
610
            except ValueError as e:
611
                raise WcsApiError('Invalid JSON content', final_url, e)
612

  
613
    @property
614
    def roles(self):
615
        return Roles(self)
616

  
617
    @property
618
    def formdefs(self):
619
        return FormDefs(self)
620

  
621
    @property
622
    def categories(self):
623
        return Categories(self)
624

  
625

  
626
def get_wcs_choices(session=None):
627
    cached_choices = cache.get('wcs-formdef-choices')
628
    if cached_choices is None:
629
        known_services = getattr(settings, 'KNOWN_SERVICES', {})
630

  
631
        def helper():
632
            for key, value in known_services.get('wcs', {}).items():
633
                api = WcsApi(
634
                    url=value['url'],
635
                    orig=value['orig'],
636
                    key=value['secret'],
637
                    session=session)
638
                for formdef in list(api.formdefs):
639
                    title = '%s - %s' % (
640
                        value['title'],
641
                        formdef.title)
642
                    yield key, formdef.slug, title
643
        cached_choices = sorted(helper(), key=lambda x: x[2])
644
        cache.set('wcs-formdef-choices', cached_choices, 600)
645

  
646
    choices = [('', '---------')]
647
    for wcs_slug, formdef_slug, title in cached_choices:
648
        choices.append((FormDefRef(wcs_slug, formdef_slug), title))
649
    return choices
650

  
651

  
652
@six.python_2_unicode_compatible
653
class FormDefRef(object):
654
    _formdef = None
655
    _api = None
656
    session = None
657

  
658
    def __init__(self, value1, value2=None):
659
        if value2:
660
            self.wcs_slug, self.formdef_slug = value1, value2
661
        else:
662
            self.wcs_slug, self.formdef_slug = six.text_type(value1).rsplit(':', 1)
663

  
664
    @property
665
    def api(self):
666
        if not self._api:
667
            config = settings.KNOWN_SERVICES['wcs'].get(self.wcs_slug)
668
            self._api = WcsApi(
669
                url=config['url'],
670
                orig=config['orig'],
671
                key=config['secret'],
672
                session=self.session)
673
        return self._api
674

  
675
    @property
676
    def formdef(self):
677
        if not self._formdef:
678
            self._formdef = self.api.formdefs[self.formdef_slug]
679
        return self._formdef
680

  
681
    def __getattr__(self, name):
682
        return getattr(self.formdef, name)
683

  
684
    def __str__(self):
685
        return '%s:%s' % (self.wcs_slug, self.formdef_slug)
686

  
687
    def __eq__(self, other):
688
        if not other:
689
            return False
690
        if not hasattr(other, 'wcs_slug'):
691
            other = FormDefRef(other)
692
        return self.wcs_slug == other.wcs_slug and self.formdef_slug == other.formdef_slug
693

  
694
    def __ne__(self, other):
695
        return not self.__eq__(other)
696

  
697
    def __deepcopy__(self, memo):
698
        return self.__class__(self.wcs_slug, self.formdef_slug)
699

  
700

  
701
class FormDefFormField(forms.TypedChoiceField):
702
    def __init__(self, **kwargs):
703
        super(FormDefFormField, self).__init__(
704
            choices=self.get_formdef_choices,
705
            coerce=FormDefRef, **kwargs)
706

  
707
    def get_formdef_choices(self):
708
        requests = getattr(self, 'requests', None)
709
        return get_wcs_choices(requests)
710

  
711

  
712
class FormDefField(models.Field):
713
    def get_internal_type(self):
714
        return 'TextField'
715

  
716
    def from_db_value(self, value, *args, **kwargs):
717
        return self.to_python(value)
718

  
719
    def to_python(self, value):
720
        if not value:
721
            return None
722
        if isinstance(value, FormDefRef):
723
            return value
724
        return FormDefRef(value)
725

  
726
    def get_prep_value(self, value):
727
        if not value:
728
            return ''
729
        return str(value)
730

  
731
    def formfield(self, **kwargs):
732
        defaults = {
733
            'form_class': FormDefFormField,
734
        }
735
        defaults.update(kwargs)
736
        return super(FormDefField, self).formfield(**defaults)
tests/wcs/conftest.py
1
# -*- coding: utf-8 -*-
2
# passerelle - uniform access to multiple data sources and services
3
# Copyright (C) 2019 Entr'ouvert
4
#
5
# This program is free software: you can redistribute it and/or modify it
6
# under the terms of the GNU Affero General Public License as published
7
# by the Free Software Foundation, either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17

  
18

  
19
import pickle
20
import sys
21
import time
22
import os
23
import shutil
24
import random
25
import socket
26
import tempfile
27
import contextlib
28
import ConfigParser
29

  
30
import psycopg2
31
import pytest
32
import httmock
33

  
34

  
35
def find_free_tcp_port():
36
    with contextlib.closing(socket.socket(socket.AF_INET, socket.SOCK_STREAM)) as s:
37
        s.bind(('', 0))
38
        s.setsockopt(socket.SOL_SOCKET, socket.SO_REUSEADDR, 1)
39
        return s.getsockname()[1]
40

  
41

  
42
@contextlib.contextmanager
43
def postgres_db_factory():
44
    database = 'db%s' % random.getrandbits(20)
45

  
46
    with contextlib.closing(psycopg2.connect('')) as conn:
47
        conn.set_isolation_level(0)
48
        with conn.cursor() as cursor:
49
            cursor.execute('CREATE DATABASE %s' % database)
50
    try:
51
        yield PostgresDB(database)
52
    finally:
53
        with contextlib.closing(psycopg2.connect('')) as conn:
54
            conn.set_isolation_level(0)
55
            with conn.cursor() as cursor:
56
                cursor.execute('DROP DATABASE IF EXISTS %s' % database)
57

  
58

  
59
class PostgresDB(object):
60
    def __init__(self, database):
61
        self.database = database
62

  
63
    @property
64
    def dsn(self):
65
        return 'dbname={self.database}'.format(self=self)
66

  
67
    @contextlib.contextmanager
68
    def conn(self):
69
        with contextlib.closing(psycopg2.connect(self.dsn)) as conn:
70
            yield conn
71

  
72
    def __repr__(self):
73
        return '<Postgres Database %r>' % self.database
74

  
75

  
76
@pytest.fixture
77
def postgres_db():
78
    with postgres_db_factory() as pg_db:
79
        yield pg_db
80

  
81

  
82
class WcsRunInContextError(Exception):
83
    def __init__(self, msg, exception, tb):
84
        self.msg = msg
85
        self.exception = exception
86
        self.tb = tb
87
        super(WcsRunInContextError, self).__init__(msg)
88

  
89
    def __str__(self):
90
        return '%s\n%s' % (self.msg, self.tb)
91

  
92

  
93
class WcsHost(object):
94
    def __init__(self, wcs, hostname, database=None):
95
        self.wcs = wcs
96
        self.hostname = hostname
97
        self.app_dir = os.path.join(wcs.app_dir, hostname)
98
        with self.config_pck as config:
99
            config['misc'] = {'charset': 'utf-8'}
100
            config['language'] = {'language': 'en'}
101
            config['branding'] = {'theme': 'django'}
102
        if database:
103
            self.set_postgresql(database)
104
        self.__wcs_init()
105

  
106
    @property
107
    def url(self):
108
        return 'http://{self.hostname}:{self.wcs.port}'.format(self=self)
109

  
110
    def run_in_context(self, func):
111
        from multiprocessing import Pipe
112

  
113
        WCSCTL = os.environ.get('WCSCTL')
114
        pipe_out, pipe_in = Pipe()
115
        pid = os.fork()
116
        if pid:
117
            pid, exit_code = os.waitpid(pid, 0)
118
            try:
119
                if pid and exit_code != 0:
120
                    try:
121
                        e, formatted_tb = pipe_out.recv()
122
                    except EOFError:
123
                        e, formatted_tb = None, None
124
                    raise WcsRunInContextError('%s failed' % func, e, formatted_tb)
125
            finally:
126
                pipe_out.close()
127
        else:
128
            sys.path.append(os.path.dirname(WCSCTL))
129
            try:
130
                import wcs.publisher
131
                wcs.publisher.WcsPublisher.APP_DIR = self.wcs.app_dir
132
                publisher = wcs.publisher.WcsPublisher.create_publisher(
133
                    register_tld_names=False)
134
                publisher.app_dir = self.app_dir
135
                publisher.set_config()
136
                func()
137
            except Exception as e:
138
                import traceback
139
                pipe_in.send((e, traceback.format_exc()))
140
                pipe_in.close()
141
                # FIXME: send exception to parent
142
                os._exit(1)
143
            finally:
144
                pipe_in.close()
145
                os._exit(0)
146

  
147
    def __wcs_init(self):
148
        for name in sorted(dir(self)):
149
            if not name.startswith('wcs_init_'):
150
                continue
151
            method = getattr(self, name)
152
            if not hasattr(method, '__call__'):
153
                continue
154
            self.run_in_context(method)
155

  
156
    @property
157
    @contextlib.contextmanager
158
    def site_options(self):
159
        config = ConfigParser.ConfigParser()
160

  
161
        site_options_path = os.path.join(self.app_dir, 'site-options.cfg')
162
        if os.path.exists(site_options_path):
163
            with open(site_options_path) as fd:
164
                config.readfp(fd)
165
        yield config
166
        with open(site_options_path, 'w') as fd:
167
            fd.seek(0)
168
            config.write(fd)
169

  
170
    @property
171
    @contextlib.contextmanager
172
    def config_pck(self):
173
        config_pck_path = os.path.join(self.app_dir, 'config.pck')
174
        if os.path.exists(config_pck_path):
175
            with open(config_pck_path) as fd:
176
                config = pickle.load(fd)
177
        else:
178
            config = {}
179
        yield config
180
        with open(config_pck_path, 'w') as fd:
181
            pickle.dump(config, fd)
182

  
183
    def add_api_secret(self, orig, secret):
184
        with self.site_options as config:
185
            if not config.has_section('api-secrets'):
186
                config.add_section('api-secrets')
187
            config.set('api-secrets', orig, secret)
188

  
189
    def set_postgresql(self, database):
190
        with self.site_options as config:
191
            if not config.has_section('options'):
192
                config.add_section('options')
193
            config.set('options', 'postgresql', 'true')
194

  
195
        with self.config_pck as config:
196
            config['postgresql'] = {
197
                'database': database,
198
            }
199
        self.run_in_context(self._wcs_init_sql)
200

  
201
    def _wcs_init_sql(self):
202
        from quixote import get_publisher
203

  
204
        get_publisher().initialize_sql()
205

  
206
    @property
207
    def api(self):
208
        from passerelle.utils import wcs
209
        self.add_api_secret('test', 'test')
210
        return wcs.WcsApi(self.url, name_id='xxx', orig='test', key='test')
211

  
212
    @property
213
    def anonym_api(self):
214
        from passerelle.utils import wcs
215
        self.add_api_secret('test', 'test')
216
        return wcs.WcsApi(self.url, orig='test', key='test')
217

  
218

  
219
class Wcs(object):
220
    def __init__(self, app_dir, port, wcs_host_class=None, **kwargs):
221
        self.app_dir = app_dir
222
        self.port = port
223
        self.wcs_host_class = wcs_host_class or WcsHost
224
        self.wcs_host_class_kwargs = kwargs
225

  
226
    @contextlib.contextmanager
227
    def host(self, hostname='127.0.0.1', wcs_host_class=None, **kwargs):
228
        wcs_host_class = wcs_host_class or self.wcs_host_class
229
        app_dir = os.path.join(self.app_dir, hostname)
230
        os.mkdir(app_dir)
231
        try:
232
            init_kwargs = self.wcs_host_class_kwargs.copy()
233
            init_kwargs.update(kwargs)
234
            yield wcs_host_class(self, hostname, **init_kwargs)
235
        finally:
236
            shutil.rmtree(app_dir)
237

  
238

  
239
@contextlib.contextmanager
240
def wcs_factory(base_dir, wcs_class=Wcs, **kwargs):
241
    WCSCTL = os.environ.get('WCSCTL')
242
    if not WCSCTL:
243
        raise Exception('WCSCTL is not defined')
244
    tmp_app_dir = tempfile.mkdtemp(dir=base_dir)
245

  
246
    wcs_cfg_path = os.path.join(base_dir, 'wcs.cfg')
247

  
248
    with open(wcs_cfg_path, 'w') as fd:
249
        fd.write(u'''[main]
250
app_dir = %s\n''' % tmp_app_dir)
251

  
252
    local_settings_path = os.path.join(base_dir, 'local_settings.py')
253
    with open(local_settings_path, 'w') as fd:
254
        fd.write(u'''
255
WCS_LEGACY_CONFIG_FILE = '{base_dir}/wcs.cfg'
256
THEMES_DIRECTORY = '/'
257
ALLOWED_HOSTS = ['*']
258
SILENCED_SYSTEM_CHECKS = ['*']
259
DEBUG = False
260
LOGGING = {{
261
    'version': 1,
262
    'disable_existing_loggers': False,
263
    'handlers': {{
264
        'console': {{
265
            'class': 'logging.StreamHandler',
266
        }},
267
    }},
268
    'loggers': {{
269
        'django': {{
270
            'handlers': ['console'],
271
            'level': os.getenv('DJANGO_LOG_LEVEL', 'INFO'),
272
        }},
273
    }},
274
}}
275
'''.format(base_dir=base_dir))
276

  
277
    address = '0.0.0.0'
278
    port = find_free_tcp_port()
279

  
280
    wcs_pid = os.fork()
281
    try:
282
        # launch a Django worker for running w.c.s.
283
        if not wcs_pid:
284
            os.chdir(os.path.dirname(WCSCTL))
285
            os.environ['DJANGO_SETTINGS_MODULE'] = 'wcs.settings'
286
            os.environ['WCS_SETTINGS_FILE'] = local_settings_path
287
            os.execvp('python', ['python', 'manage.py', 'runserver', '--insecure', '--noreload', '%s:%s' % (address, port)])
288
            os._exit(0)
289

  
290
        # verify w.c.s. is launched
291
        s = socket.socket()
292
        i = 0
293
        while True:
294
            i += 1
295
            try:
296
                s.connect((address, port))
297
            except Exception:
298
                time.sleep(0.1)
299
            else:
300
                s.close()
301
                break
302
            assert i < 50, 'no connection found after 5 seconds'
303

  
304
        # verify w.c.s. is still running
305
        pid, exit_code = os.waitpid(wcs_pid, os.WNOHANG)
306
        if pid:
307
            raise Exception('w.c.s. stopped with exit-code %s' % exit_code)
308
        yield wcs_class(tmp_app_dir, port=port, **kwargs)
309
    finally:
310
        os.kill(wcs_pid, 9)
311
        shutil.rmtree(tmp_app_dir)
312

  
313

  
314
class DefaultWcsHost(WcsHost):
315
    def wcs_init_01_setup_auth(self):
316
        from quixote import get_publisher
317

  
318
        get_publisher().cfg['identification'] = {'methods': ['password']}
319
        get_publisher().cfg['debug'] = {'display_exceptions': 'text'}
320
        get_publisher().write_cfg()
321

  
322
    def wcs_init_02_create_user(self):
323
        from quixote import get_publisher
324
        from qommon.ident.password_accounts import PasswordAccount
325
        from wcs.roles import Role
326

  
327
        user = get_publisher().user_class()
328
        user.name = 'foo bar'
329
        user.email = 'foo@example.net'
330
        user.store()
331
        account = PasswordAccount(id='user')
332
        account.set_password('user')
333
        account.user_id = user.id
334
        account.store()
335

  
336
        role = Role(name='role')
337
        role.store()
338

  
339
        user = get_publisher().user_class()
340
        user.name = 'admin'
341
        user.email = 'admin@example.net'
342
        user.name_identifiers = ['xxx']
343
        user.is_admin = True
344
        user.roles = [str(role.id)]
345
        user.store()
346
        account = PasswordAccount(id='admin')
347
        account.set_password('admin')
348
        account.user_id = user.id
349
        account.store()
350

  
351
    def wcs_init_03_create_data(self):
352
        import datetime
353
        import random
354
        from quixote import get_publisher
355

  
356
        from wcs.categories import Category
357
        from wcs.formdef import FormDef
358
        from wcs.roles import Role
359
        from wcs import fields
360

  
361
        cat = Category()
362
        cat.name = 'Catégorie'
363
        cat.description = ''
364
        cat.store()
365

  
366
        role = Role.select()[0]
367

  
368
        formdef = FormDef()
369
        formdef.name = 'Demande'
370
        formdef.category_id = cat.id
371
        formdef.workflow_roles = {'_receiver': role.id}
372
        formdef.fields = [
373
            fields.StringField(id='1', label='1st field', type='string', anonymise=False, varname='string'),
374
            fields.ItemField(id='2', label='2nd field', type='item',
375
                             items=['foo', 'bar', 'baz'], varname='item'),
376
            fields.BoolField(id='3', label='3rd field', type='bool', varname='bool'),
377
            fields.ItemField(id='4', label='4rth field', type='item', varname='item_open'),
378
            fields.ItemField(id='5', label='5th field', type='item',
379
                             varname='item_datasource',
380
                             data_source={'type': 'json', 'value': 'http://datasource.com/'}),
381
        ]
382
        formdef.store()
383

  
384
        user = get_publisher().user_class.select()[0]
385

  
386
        for i in range(10):
387
            formdata = formdef.data_class()()
388
            formdata.just_created()
389
            formdata.receipt_time = datetime.datetime(
390
                2018,
391
                random.randrange(1, 13),
392
                random.randrange(1, 29)
393
            ).timetuple()
394
            formdata.data = {'1': 'FOO BAR %d' % i}
395
            if i % 4 == 0:
396
                formdata.data['2'] = 'foo'
397
                formdata.data['2_display'] = 'foo'
398
                formdata.data['4'] = 'open_one'
399
                formdata.data['4_display'] = 'open_one'
400
            elif i % 4 == 1:
401
                formdata.data['2'] = 'bar'
402
                formdata.data['2_display'] = 'bar'
403
                formdata.data['4'] = 'open_two'
404
                formdata.data['4_display'] = 'open_two'
405
            else:
406
                formdata.data['2'] = 'baz'
407
                formdata.data['2_display'] = 'baz'
408
                formdata.data['4'] = "open'three"
409
                formdata.data['4_display'] = "open'three"
410

  
411
            formdata.data['3'] = bool(i % 2)
412
            if i % 3 == 0:
413
                formdata.jump_status('new')
414
            else:
415
                formdata.jump_status('finished')
416
            if i % 7 == 0:
417
                formdata.user_id = user.id
418
            formdata.store()
419

  
420

  
421
@pytest.fixture
422
def datasource():
423
    @httmock.urlmatch(netloc='datasource.com')
424
    def handler(url, request):
425
        return {
426
            'status_code': 200,
427
            'content': {
428
                'err': 0,
429
                'data': [
430
                    {'id': '1', 'text': 'hello'},
431
                    {'id': '2', 'text': 'world'},
432
                ]
433
            },
434
            'content-type': 'application/json',
435
        }
436
    with httmock.HTTMock(handler):
437
        yield
438

  
439

  
440
@pytest.fixture(scope='session')
441
def wcs(tmp_path_factory):
442
    base_dir = tmp_path_factory.mktemp('wcs')
443
    with wcs_factory(str(base_dir), wcs_host_class=DefaultWcsHost) as wcs:
444
        yield wcs
445

  
446

  
447
@pytest.fixture
448
def wcs_host(wcs, postgres_db, datasource):
449
        with wcs.host('127.0.0.1', database=postgres_db.database) as wcs_host:
450
            yield wcs_host
tests/wcs/test_conftest.py
1
# -*- coding: utf-8 -*-
2
# passerelle - uniform access to multiple data sources and services
3
# Copyright (C) 2019 Entr'ouvert
4
#
5
# This program is free software: you can redistribute it and/or modify it
6
# under the terms of the GNU Affero General Public License as published
7
# by the Free Software Foundation, either version 3 of the License, or
8
# (at your option) any later version.
9
#
10
# This program is distributed in the hope that it will be useful,
11
# but WITHOUT ANY WARRANTY; without even the implied warranty of
12
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
13
# GNU Affero General Public License for more details.
14
#
15
# You should have received a copy of the GNU Affero General Public License
16
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
17

  
18
import pytest
19

  
20
from django.utils.six.moves.urllib import parse as urlparse
21
import requests
22

  
23

  
24
def test_wcs_fixture(wcs_host):
25
    assert wcs_host.url.startswith('http://127.0.0.1:')
26
    requests.get(wcs_host.url)
27
    response = requests.get(urlparse.urljoin(wcs_host.url, '/api/categories/'))
28
    assert response.json()['data'][0]['title'] == u'Catégorie'
29

  
30

  
31
def test_wcs_api(wcs_host):
32
    from passerelle.utils.wcs import WcsApiError
33

  
34
    api = wcs_host.api
35
    assert len(api.categories) == 1
36
    assert len(api.formdefs) == 1
37
    assert len(api.roles) == 1
38
    formdef = api.formdefs['demande']
39

  
40
    assert formdef.schema.fields[4].label == '5th field'
41
    assert len(formdef.formdatas) == 10
42
    assert len(formdef.formdatas.full) == 10
43
    formdata = next(iter(formdef.formdatas))
44
    assert formdata is not formdata.full
45
    assert formdata.full is formdata.full
46
    assert formdata.full.full is formdata.full
47
    assert formdata.full.anonymized is not formdata.full
48

  
49
    with formdef.submit() as submitter:
50
        with pytest.raises(ValueError):
51
            submitter.set('zob', '1')
52
        submitter.draft = True
53
        submitter.submission_channel = 'mdel'
54
        submitter.submission_context = {
55
            'mdel_ref': 'ABCD',
56
        }
57
        submitter.set('string', 'hello')
58
        submitter.set('item', 'foo')
59
        submitter.set('item_open', {
60
            'id': '1',
61
            'text': 'world',
62
            'foo': 'bar'
63
        })
64
        submitter.set('item_datasource', {
65
            'id': '2',
66
            'text': 'world',
67
        })
68

  
69
    formdata = formdef.formdatas[submitter.result.id]
70
    api = wcs_host.anonym_api
71

  
72
    assert len(api.categories) == 1
73
    assert len(api.formdefs) == 1
74
    assert len(api.roles) == 1
75
    formdef = api.formdefs['demande']
76
    assert formdef.schema.fields[4].label == '5th field'
77
    with pytest.raises(WcsApiError):
78
        assert len(formdef.formdatas) == 10
79
    assert len(formdef.formdatas.anonymized) == 10
tox.ini
9 9
  DJANGO_SETTINGS_MODULE=passerelle.settings
10 10
  PASSERELLE_SETTINGS_FILE=tests/settings.py
11 11
  BRANCH_NAME={env:BRANCH_NAME:}
12
  WCSCTL=wcs/wcsctl.py
12 13
  fast: FAST=--nomigrations
13 14
  sqlite: DB_ENGINE=django.db.backends.sqlite3
14 15
  pg: DB_ENGINE=django.db.backends.postgresql_psycopg2
15 16
deps =
16 17
  django18: django>=1.8,<1.9
17 18
  django111: django>=1.11,<1.12
18
  pg: psycopg2-binary
19
  psycopg2-binary
19 20
  pytest-cov
20 21
  pytest-django<3.4.6
21 22
  pytest
......
32 33
  pytest-httpbin
33 34
  pytest-localserver
34 35
  pytest-sftpserver
36
  http://quixote.python.ca/releases/Quixote-2.7b2.tar.gz
37
  vobject
35 38
commands =
39
  ./get_wcs.sh
36 40
  django18: py.test {posargs: {env:FAST:} --junitxml=test_{envname}_results.xml --cov-report xml --cov-report html --cov=passerelle/ --cov-config .coveragerc tests/}
37 41
  django18: ./pylint.sh passerelle/
38 42
  django111: py.test {posargs: --junitxml=test_{envname}_results.xml tests/}
39
-