Projet

Général

Profil

0001-migrate-to-python3-39430.patch

Benjamin Dauvergne, 30 janvier 2020 22:36

Télécharger (44,8 ko)

Voir les différences:

Subject: [PATCH] migrate to python3 (#39430)

 debian/control          |  10 +-
 debian/pydist-overrides |   6 +-
 debian/rules            |   4 +-
 setup.py                |  36 ++-
 tests/test_wcs.py       |  40 +--
 tox.ini                 |  11 +-
 wcs_olap/cmd.py         |  40 ++-
 wcs_olap/feeder.py      |  25 +-
 wcs_olap/signature.py   |  30 ++-
 wcs_olap/tb.py          |  55 ----
 wcs_olap/wcs_api.py     | 585 ++++++++++++++++++++++++++++++----------
 11 files changed, 563 insertions(+), 279 deletions(-)
 delete mode 100644 wcs_olap/tb.py
debian/control
2 2
Section: python
3 3
Priority: optional
4 4
Maintainer: Benjamin Dauvergne <bdauvergne@entrouvert.com>
5
Build-Depends: python-setuptools (>= 0.6b3), python-all (>= 2.6), debhelper (>= 9), dh-python
6
Standards-Version: 3.9.1
7
X-Python-Version: >= 2.7
8
Homepage: http://dev.entrouvert.org/projects/publik-bi/
5
Build-Depends: python3-setuptools, python3-all, debhelper (>= 9), dh-python
6
Standards-Version: 3.9.6
7
Homepage: http://dev.entrouvert.org/projects/wcs-olap/
9 8

  
10 9
Package: wcs-olap
11 10
Architecture: all
12
Depends: ${python:Depends}
13
XB-Python-Version: ${python:Versions}
11
Depends: ${python3:Depends}
14 12
Description: Export w.c.s. datas into a snowflake schema built on PostgreSQL
debian/pydist-overrides
1
isodate python-isodate
2
psycopg2 python-psycopg2
3
cached_property python-cached-property
1
isodate python3-isodate
2
psycopg2 python3-psycopg2
3
cached_property python3-cached-property
debian/rules
1 1
#!/usr/bin/make -f
2 2

  
3
export PYBUILD_NAME=wcs-olap
4

  
3 5
%:
4
	dh $@ --with python2
6
	dh $@ --with python3 --buildsystem=pybuild
5 7

  
6 8

  
setup.py
9 9

  
10 10
class eo_sdist(sdist):
11 11
    def run(self):
12
        print "creating VERSION file"
13 12
        if os.path.exists('VERSION'):
14 13
            os.remove('VERSION')
15 14
        version = get_version()
16
        version_file = open('VERSION', 'w')
17
        version_file.write(version)
18
        version_file.close()
15
        with open('VERSION', 'w') as fd:
16
            fd.write(version)
19 17
        sdist.run(self)
20
        print "removing VERSION file"
21 18
        if os.path.exists('VERSION'):
22 19
            os.remove('VERSION')
23 20

  
24 21

  
25 22
def get_version():
26 23
    '''Use the VERSION, if absent generates a version with git describe, if not
27
       tag exists, take 0.0.0- and add the length of the commit log.
24
       tag exists, take 0.0- and add the length of the commit log.
28 25
    '''
29 26
    if os.path.exists('VERSION'):
30 27
        with open('VERSION', 'r') as v:
31 28
            return v.read()
32 29
    if os.path.exists('.git'):
33
        p = subprocess.Popen(['git', 'describe', '--dirty', '--match=v*'], stdout=subprocess.PIPE,
34
                             stderr=subprocess.PIPE)
30
        p = subprocess.Popen(
31
            ['git', 'describe', '--dirty=.dirty', '--match=v*'],
32
            stdout=subprocess.PIPE, stderr=subprocess.PIPE)
35 33
        result = p.communicate()[0]
36 34
        if p.returncode == 0:
37
            result = result.split()[0][1:]
35
            result = result.decode('ascii').strip()[1:]  # strip spaces/newlines and initial v
36
            if '-' in result:  # not a tagged version
37
                real_number, commit_count, commit_hash = result.split('-', 2)
38
                version = '%s.post%s+%s' % (real_number, commit_count, commit_hash)
39
            else:
40
                version = result
41
            return version
38 42
        else:
39
            result = '0.0.0-%s' % len(subprocess.check_output(
40
                ['git', 'rev-list', 'HEAD']).splitlines())
41
        return result.replace('-', '.').replace('.g', '+g')
42
    return '0.0.0'
43
            return '0.0.post%s' % len(subprocess.check_output(['git', 'rev-list', 'HEAD']).splitlines())
44
    return '0.0'
43 45

  
44 46

  
45 47
setup(name="wcs-olap",
......
54 56
      maintainer_email="bdauvergne@entrouvert.com",
55 57
      packages=find_packages(),
56 58
      include_package_data=True,
57
      install_requires=['requests', 'psycopg2', 'isodate', 'six', 'cached-property'],
59
      install_requires=[
60
          'requests',
61
          'psycopg2',
62
          'isodate',
63
          'six',
64
          'cached-property'
65
      ],
58 66
      entry_points={
59 67
          'console_scripts': ['wcs-olap=wcs_olap.cmd:main'],
60 68
      },
tests/test_wcs.py
1
from __future__ import unicode_literals
2

  
3 1
import json
4

  
5 2
import pytest
3
import pathlib
6 4

  
7 5
import requests
8
import pathlib2
9
import mock
6
import httmock
10 7

  
11 8
import utils
12 9

  
......
90 87

  
91 88
    # verify JSON schema
92 89
    with (olap_cmd.model_dir / 'olap.model').open() as fd, \
93
            (pathlib2.Path(__file__).parent / 'olap.model').open() as fd2:
94
            json_schema = json.load(fd)
95
            expected_json_schema = json.load(fd2)
96
            expected_json_schema['pg_dsn'] = postgres_db.dsn
97
            assert json_schema == expected_json_schema
90
            (pathlib.Path(__file__).parent / 'olap.model').open() as fd2:
91
        json_schema = json.load(fd)
92
        expected_json_schema = json.load(fd2)
93
        expected_json_schema['pg_dsn'] = postgres_db.dsn
94
        assert json_schema == expected_json_schema
98 95

  
99 96

  
100 97
def test_requests_exception(wcs, postgres_db, tmpdir, olap_cmd, caplog):
101
    with mock.patch('requests.get', side_effect=requests.RequestException('wat!')):
98
    @httmock.urlmatch()
99
    def requests_raise(url, request):
100
        raise requests.RequestException('wat!')
101

  
102
    with httmock.HTTMock(requests_raise):
102 103
        with pytest.raises(SystemExit):
103 104
            olap_cmd(no_log_errors=False)
104 105
    assert 'wat!' in caplog.text
105 106

  
106 107

  
107 108
def test_requests_not_ok(wcs, postgres_db, tmpdir, olap_cmd, caplog):
108
    with mock.patch('requests.get') as mocked_get:
109
        mocked_get.return_value.ok = False
110
        mocked_get.return_value.status_code = 401
111
        mocked_get.return_value.text = '{"err": 1, "err_desc": "invalid signature"}'
109
    @httmock.urlmatch()
110
    def return_401(url, request):
111
        return {'status_code': 401, 'content': {"err": 1, "err_desc": "invalid signature"}}
112

  
113
    with httmock.HTTMock(return_401):
112 114
        with pytest.raises(SystemExit):
113 115
            olap_cmd(no_log_errors=False)
114 116
    assert 'invalid signature' in caplog.text
115 117

  
116 118

  
117 119
def test_requests_not_json(wcs, postgres_db, tmpdir, olap_cmd, caplog):
118
    with mock.patch('requests.get') as mocked_get:
119
        mocked_get.return_value.ok = True
120
        mocked_get.return_value.json.side_effect = ValueError('invalid JSON')
120
    @httmock.urlmatch()
121
    def return_invalid_json(url, request):
122
        return 'x'
123

  
124
    with httmock.HTTMock(return_invalid_json):
121 125
        with pytest.raises(SystemExit):
122 126
            olap_cmd(no_log_errors=False)
123 127
    assert 'Invalid JSON content' in caplog.text
tox.ini
5 5

  
6 6
[tox]
7 7
toxworkdir = {env:TMPDIR:/tmp}/tox-{env:USER}/wcs-olap/{env:BRANCH_NAME:}
8
envlist = py2-coverage
8
envlist = py3-coverage
9 9

  
10 10
[testenv]
11 11
usedevelop = true
......
17 17
	pytest
18 18
	pytest-cov
19 19
	pytest-random
20
	quixote<3.0
20
	quixote>=3
21 21
	psycopg2-binary
22 22
	vobject
23 23
	pyproj
24 24
	django-ratelimit<3
25 25
	gadjo
26
	mock
26
	httmock
27 27
	django>=1.11,<1.12
28 28
commands =
29
	./get_wcs.sh
29
	#./get_wcs.sh
30 30
	py.test {env:COVERAGE:} {posargs:--random-group tests}
31

  
32
[pytest]
33
junit_family=xunit2
wcs_olap/cmd.py
1
import sys
2 1
import argparse
3
import ConfigParser
4
import os
2
import configparser
3
import locale
5 4
import logging
6 5
import logging.config
7
from . import wcs_api
8
from .feeder import WcsOlapFeeder
9
import locale
6
import os
7
import sys
10 8

  
11
from . import tb
9
from . import wcs_api, feeder
12 10

  
13 11

  
14 12
def main():
......
16 14
        main2()
17 15
    except SystemExit:
18 16
        raise
19
    except:
20
        tb.print_tb()
21
        raise SystemExit(1)
22 17

  
23 18

  
24 19
def get_config(path=None):
25
    config = ConfigParser.ConfigParser()
20
    config = configparser.ConfigParser()
26 21
    global_config_path = '/etc/wcs-olap/config.ini'
27 22
    user_config_path = os.path.expanduser('~/.wcs-olap.ini')
28 23
    if not path:
......
60 55
    fake = args.fake
61 56
    config = get_config(path=args.config_path)
62 57
    # list all known urls
63
    urls = [url for url in config.sections() if url.startswith('http://') or
64
            url.startswith('https://')]
58
    urls = [url for url in config.sections() if url.startswith('http://')
59
            or url.startswith('https://')]
65 60
    defaults = {}
66 61
    if not args.all:
67 62
        try:
68 63
            url = args.url or urls[0]
69 64
        except IndexError:
70
            print 'no url found'
65
            print('no url found')
71 66
            raise SystemExit(1)
72 67
        urls = [url]
73 68
        if config.has_section(args.url):
......
97 92
            pg_dsn = defaults['pg_dsn']
98 93
            slugs = defaults.get('slugs', '').strip().split() or getattr(args, 'slug', [])
99 94
            batch_size = int(defaults.get('batch_size', 500))
100
        except KeyError, e:
95
        except KeyError as e:
101 96
            failure = True
102 97
            logger.error('configuration incomplete for %s: %s', url, e)
103 98
        else:
104 99
            try:
105
                api = wcs_api.WcsApi(url=url, orig=orig, key=key, slugs=slugs,
106
                                     verify=defaults.get('verify', 'True') == 'True',
107
                                     batch_size=batch_size)
100
                api = wcs_api.WcsApi(url=url, orig=orig, key=key,
101
                                     batch_size=batch_size,
102
                                     verify=(defaults.get('verify', 'True') == 'True'))
108 103
                logger.info('starting synchronizing w.c.s. at %r with PostgreSQL at %s', url,
109 104
                            pg_dsn)
110
                feeder = WcsOlapFeeder(api=api, schema=schema, pg_dsn=pg_dsn, logger=logger,
111
                                       config=defaults, do_feed=feed, fake=fake)
112
                feeder.feed()
105
                olap_feeder = feeder.WcsOlapFeeder(
106
                    api=api, schema=schema, pg_dsn=pg_dsn, logger=logger,
107
                    config=defaults, do_feed=feed, fake=fake, slugs=slugs)
108
                olap_feeder.feed()
113 109
                logger.info('finished')
114 110
                feed_result = False
115
            except:
111
            except Exception:
116 112
                if args.no_log_errors:
117 113
                    raise
118 114
                feed_result = True
wcs_olap/feeder.py
2 2

  
3 3
from __future__ import unicode_literals
4 4

  
5
from collections import OrderedDict, Counter
5
from collections import OrderedDict
6 6
import datetime
7 7
import six
8 8
import copy
......
10 10
import os
11 11
import json
12 12
import hashlib
13
from utils import Whatever
13
from .utils import Whatever
14 14
import psycopg2
15 15

  
16 16
from cached_property import cached_property
......
78 78
    status_to_id = dict((c[1], c[0]) for c in channels)
79 79
    id_to_status = dict((c[0], c[1]) for c in channels)
80 80

  
81
    def __init__(self, api, pg_dsn, schema, logger=None, config=None, do_feed=True, fake=False):
81
    def __init__(self, api, pg_dsn, schema, logger=None, config=None, do_feed=True, fake=False, slugs=None):
82 82
        self.api = api
83
        self.slugs = slugs
83 84
        self.fake = fake
84 85
        self.logger = logger or Whatever()
85 86
        self.schema = schema
......
291 292

  
292 293
    @cached_property
293 294
    def formdefs(self):
294
        return self.api.formdefs
295
        return [formdef for formdef in self.api.formdefs if not self.slugs or formdef.slug in self.slugs]
295 296

  
296 297
    @cached_property
297 298
    def roles(self):
......
441 442
            if isinstance(o, six.string_types):
442 443
                return o.format(**ctx)
443 444
            elif isinstance(o, dict):
444
                return dict((k, helper(v)) for k, v in o.iteritems())
445
                return dict((k, helper(v)) for k, v in o.items())
445 446
            elif isinstance(o, list):
446 447
                return [helper(v) for v in o]
447 448
            elif isinstance(o, (bool, int, float)):
......
466 467

  
467 468
        # categories
468 469
        tmp_cat_map = self.create_labeled_table(
469
            'category', enumerate(c.name for c in self.categories), comment='catégorie')
470
        self.categories_mapping = dict((c.id, tmp_cat_map[c.name]) for c in self.categories)
470
            'category', enumerate(c.title for c in self.categories), comment='catégorie')
471
        self.categories_mapping = dict((c.slug, tmp_cat_map[c.title]) for c in self.categories)
471 472

  
472 473
        self.create_labeled_table('hour', zip(range(0, 24), map(str, range(0, 24))),
473 474
                                  comment='heures')
......
506 507
            'geolocation_base': 'position géographique',
507 508
        }
508 509
        self.create_table('{generic_formdata_table}', self.columns)
509
        for at, comment in self.comments.iteritems():
510
        for at, comment in self.comments.items():
510 511
            self.ex('COMMENT ON COLUMN {generic_formdata_table}.%s IS %%s' % at, vars=(comment,))
511 512
        self.ex('COMMENT ON TABLE {generic_formdata_table} IS %s', vars=('tous les formulaires',))
512 513
        # evolutions
......
663 664
            }
664 665

  
665 666
        # add function fields
666
        for function, name in self.formdef.schema.workflow.functions.iteritems():
667
        for function, name in self.formdef.schema.workflow.functions.items():
667 668
            at = 'function_%s' % slugify(function)
668 669
            columns[at] = {
669 670
                'sql_col_name': at,
......
746 747
        values = []
747 748
        generic_evolution_values = []
748 749
        evolution_values = []
749
        for data in self.formdef.datas:
750
        for data in self.formdef.formdatas.anonymized.full:
750 751
            json_data = {}
751 752

  
752 753
            # ignore formdata without status
......
818 819
                    v = '(%.6f, %.6f)' % (v.get('lon'), v.get('lat'))
819 820
                row['geolocation_%s' % geolocation] = v
820 821
            # add function fields value
821
            for function, name in self.formdef.schema.workflow.functions.iteritems():
822
            for function, name in self.formdef.schema.workflow.functions.items():
822 823
                try:
823 824
                    v = data.functions[function]
824 825
                except KeyError:
......
949 950
        })
950 951

  
951 952
        # add dimension for function
952
        for function, name in self.formdef.schema.workflow.functions.iteritems():
953
        for function, name in self.formdef.schema.workflow.functions.items():
953 954
            at = 'function_%s' % slugify(function)
954 955
            cube['joins'].append({
955 956
                'name': at,
wcs_olap/signature.py
1
import urllib.parse as urlparse
1 2
import datetime
2 3
import base64
3 4
import hmac
4 5
import hashlib
5
import urllib
6 6
import random
7
import urlparse
8 7

  
9 8
'''Simple signature scheme for query strings'''
9
# from http://repos.entrouvert.org/portail-citoyen.git/tree/portail_citoyen/apps/data_source_plugin/signature.py
10 10

  
11 11

  
12 12
def sign_url(url, key, algo='sha256', timestamp=None, nonce=None):
......
20 20
        timestamp = datetime.datetime.utcnow()
21 21
    timestamp = timestamp.strftime('%Y-%m-%dT%H:%M:%SZ')
22 22
    if nonce is None:
23
        nonce = hex(random.SystemRandom().getrandbits(128))[2:-1]
23
        nonce = hex(random.getrandbits(128))[2:]
24 24
    new_query = query
25 25
    if new_query:
26 26
        new_query += '&'
27
    new_query += urllib.urlencode((
27
    new_query += urlparse.urlencode((
28 28
        ('algo', algo),
29 29
        ('timestamp', timestamp),
30 30
        ('nonce', nonce)))
31 31
    signature = base64.b64encode(sign_string(new_query, key, algo=algo))
32
    new_query += '&signature=' + urllib.quote(signature)
32
    new_query += '&signature=' + urlparse.quote(signature)
33 33
    return new_query
34 34

  
35 35

  
36 36
def sign_string(s, key, algo='sha256', timedelta=30):
37 37
    digestmod = getattr(hashlib, algo)
38
    if isinstance(key, unicode):
38
    if isinstance(key, str):
39 39
        key = key.encode('utf-8')
40
    if isinstance(s, str):
41
        s = s.encode('utf-8')
40 42
    hash = hmac.HMAC(key, digestmod=digestmod, msg=s)
41 43
    return hash.digest()
42 44

  
......
48 50

  
49 51
def check_query(query, key, known_nonce=None, timedelta=30):
50 52
    parsed = urlparse.parse_qs(query)
53
    if not ('signature' in parsed and 'algo' in parsed
54
            and 'timestamp' in parsed and 'nonce' in parsed):
55
        return False
56
    unsigned_query, signature_content = query.split('&signature=', 1)
57
    if '&' in signature_content:
58
        return False  # signature must be the last parameter
51 59
    signature = base64.b64decode(parsed['signature'][0])
52 60
    algo = parsed['algo'][0]
53 61
    timestamp = parsed['timestamp'][0]
54 62
    timestamp = datetime.datetime.strptime(timestamp, '%Y-%m-%dT%H:%M:%SZ')
55 63
    nonce = parsed['nonce']
56
    unsigned_query = query.split('&signature=')[0]
57 64
    if known_nonce is not None and known_nonce(nonce):
58 65
        return False
59 66
    if abs(datetime.datetime.utcnow() - timestamp) > datetime.timedelta(seconds=timedelta):
......
68 75
        return False
69 76
    res = 0
70 77
    for a, b in zip(signature, signature2):
71
        res |= ord(a) ^ ord(b)
78
        res |= a ^ b
72 79
    return res == 0
80

  
81

  
82
if __name__ == '__main__':
83
    key = '12345'
84
    signed_query = sign_query('NameId=_12345&orig=montpellier', key)
85
    assert check_query(signed_query, key, timedelta=0) is False
86
    assert check_query(signed_query, key) is True
wcs_olap/tb.py
1
from StringIO import StringIO
2
import sys
3
import linecache
4

  
5

  
6
def print_tb():
7
    exc_type, exc_value, tb = sys.exc_info()
8
    if exc_value:
9
        exc_value = unicode(str(exc_value), errors='ignore')
10
    error_file = StringIO()
11

  
12
    limit = None
13
    if hasattr(sys, 'tracebacklimit'):
14
        limit = sys.tracebacklimit
15
    print >>error_file, "Exception:"
16
    print >>error_file, "  type = '%s', value = '%s'" % (exc_type, exc_value)
17
    print >>error_file
18

  
19
    # format the traceback
20
    print >>error_file, 'Stack trace (most recent call first):'
21
    n = 0
22
    while tb is not None and (limit is None or n < limit):
23
        frame = tb.tb_frame
24
        function = frame.f_code.co_name
25
        filename = frame.f_code.co_filename
26
        exclineno = frame.f_lineno
27
        locals = frame.f_locals.items()
28

  
29
        print >>error_file, '  File "%s", line %s, in %s' % (filename, exclineno, function)
30
        linecache.checkcache(filename)
31
        for lineno in range(exclineno - 2, exclineno + 3):
32
            line = linecache.getline(filename, lineno, frame.f_globals)
33
            if line:
34
                if lineno == exclineno:
35
                    print >>error_file, '>%5s %s' % (lineno, line.rstrip())
36
                else:
37
                    print >>error_file, ' %5s %s' % (lineno, line.rstrip())
38
        print >>error_file
39
        if locals:
40
            print >>error_file, "  locals: "
41
            for key, value in locals:
42
                print >>error_file, "     %s =" % key,
43
                try:
44
                    repr_value = repr(value)
45
                    if len(repr_value) > 10000:
46
                        repr_value = repr_value[:10000] + ' [...]'
47
                    print >>error_file, repr_value,
48
                except:
49
                    print >>error_file, "<ERROR WHILE PRINTING VALUE>",
50
                print >>error_file
51
        print >>error_file
52
        tb = tb.tb_next
53
        n = n + 1
54

  
55
    print >>sys.stderr, error_file.getvalue()
wcs_olap/wcs_api.py
1
import six
1
# wcs_olap
2
# Copyright (C) 2020 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
import collections
18
import base64
19
import copy
20
import logging
21
import datetime
22
import contextlib
23
import json
24

  
2 25
import requests
3
import urlparse
4
import urllib
5 26
import isodate
6
import logging
7 27

  
28
import urllib.parse as urlparse
8 29

  
9 30
from . import signature
10 31

  
11 32

  
12
logger = logging.getLogger(__name__)
33
class WcsApiError(Exception):
34
    pass
13 35

  
14 36

  
15
def exception_to_text(e):
16
    try:
17
        return six.text_type(e)
18
    except Exception:
19
        pass
37
class JSONFile(object):
38
    def __init__(self, d):
39
        self.d = d
20 40

  
21
    try:
22
        return six.text_type(e.decode('utf8'))
23
    except Exception:
24
        pass
41
    @property
42
    def filename(self):
43
        return self.d.get('filename', '')
25 44

  
26
    try:
27
        return six.text_type(repr(e))
28
    except Exception:
29
        pass
45
    @property
46
    def content_type(self):
47
        return self.d.get('content_type', 'application/octet-stream')
30 48

  
31
    try:
32
        args = e.args
33
        try:
34
            content = six.text_type(repr(args)) if args != [] else ''
35
        except Exception:
36
            content = '<exception-while-rendering-args>'
37
    except AttributeError:
38
        content = ''
39
    return u'%s(%s)' % (e.__class__.__name__, content)
49
    @property
50
    def content(self):
51
        return base64.b64decode(self.d['content'])
40 52

  
41 53

  
42
class WcsApiError(Exception):
43
    def __init__(self, message, **kwargs):
44
        super(WcsApiError, self).__init__(message)
45
        self.kwargs = kwargs
46

  
47
    def __str__(self):
48
        kwargs = self.kwargs.copy()
49
        if 'exception' in kwargs:
50
            kwargs['exception'] = exception_to_text(kwargs['exception'])
51
        return '%s: %s' % (self.args[0], ' '.join('%s=%s' % (key, value) for key, value in kwargs.items()))
54
def to_dict(o):
55
    if hasattr(o, 'to_dict'):
56
        return o.to_dict()
57
    elif isinstance(o, dict):
58
        return {k: to_dict(v) for k, v in o.items()}
59
    elif isinstance(o, (list, tuple)):
60
        return [to_dict(v) for v in o]
61
    else:
62
        return o
52 63

  
53 64

  
54 65
class BaseObject(object):
55 66
    def __init__(self, wcs_api, **kwargs):
56
        self.__wcs_api = wcs_api
67
        self._wcs_api = wcs_api
57 68
        self.__dict__.update(**kwargs)
58 69

  
70
    def to_dict(self):
71
        d = collections.OrderedDict()
72
        for key, value in self.__dict__.items():
73
            if key[0] == '_':
74
                continue
75
            d[key] = to_dict(value)
76
        return d
77

  
59 78

  
60 79
class FormDataWorkflow(BaseObject):
61 80
    status = None
......
92 111
class FormData(BaseObject):
93 112
    geolocations = None
94 113
    evolution = None
114
    submission = None
115
    workflow = None
116
    roles = None
117
    with_files = False
95 118

  
96
    def __init__(self, wcs_api, **kwargs):
119
    def __init__(self, wcs_api, forms, **kwargs):
120
        self.forms = forms
97 121
        super(FormData, self).__init__(wcs_api, **kwargs)
98 122
        self.receipt_time = isodate.parse_datetime(self.receipt_time)
99
        self.submission = BaseObject(wcs_api, **self.submission)
100
        self.workflow = FormDataWorkflow(wcs_api, **self.workflow)
123
        if self.submission:
124
            self.submission = BaseObject(wcs_api, **self.submission)
125
        if self.workflow:
126
            self.workflow = FormDataWorkflow(wcs_api, **self.workflow)
101 127
        self.evolution = [Evolution(wcs_api, **evo) for evo in self.evolution or []]
102 128
        self.functions = {}
103 129
        self.concerned_roles = []
104 130
        self.action_roles = []
105
        for function in self.roles:
131
        for function in self.roles or []:
106 132
            roles = [Role(wcs_api, **r) for r in self.roles[function]]
107 133
            if function == 'concerned':
108 134
                self.concerned_roles.extend(roles)
......
113 139
                    self.functions[function] = roles[0]
114 140
                except IndexError:
115 141
                    self.functions[function] = None
116
        del self.roles
142
        if 'roles' in self.__dict__:
143
            del self.roles
117 144

  
118
    def __repr__(self):
119
        return '<{klass} {display_id!r}>'.format(klass=self.__class__.__name__,
120
                                                 display_id=self.id)
145
    def __str__(self):
146
        return '{self.formdef} - {self.id}'.format(self=self)
147

  
148
    @property
149
    def full(self):
150
        if self.with_files:
151
            return self
152
        if not hasattr(self, '_full'):
153
            self._full = self.forms[self.id]
154
        return self._full
155

  
156
    @property
157
    def anonymized(self):
158
        return self.forms.anonymized[self.id]
121 159

  
122 160
    @property
123 161
    def endpoint_delay(self):
......
140 178
                    else:
141 179
                        return
142 180

  
181
    def __getitem__(self, key):
182
        value = self.full.fields.get(key)
183
        # unserialize files
184
        if isinstance(value, dict) and 'content' in value:
185
            return JSONFile(value)
186
        return value
187

  
143 188

  
144 189
class Workflow(BaseObject):
145 190
    statuses = None
......
148 193
    def __init__(self, wcs_api, **kwargs):
149 194
        super(Workflow, self).__init__(wcs_api, **kwargs)
150 195
        self.statuses = [BaseObject(wcs_api, **v) for v in (self.statuses or [])]
151
        if self.statuses:
152
            assert not hasattr(self.statuses[0], 'startpoint'), 'startpoint is exported by w.c.s. FIXME'
153
            for status in self.statuses:
154
                status.startpoint = False
155
            self.statuses[0].startpoint = True
196
        assert not hasattr(self.statuses[0], 'startpoint'), 'startpoint is exported by w.c.s. FIXME'
197
        for status in self.statuses:
198
            status.startpoint = False
199
        self.statuses[0].startpoint = True
156 200
        self.statuses_map = dict((s.id, s) for s in self.statuses)
157 201
        self.fields = [Field(wcs_api, **field) for field in (self.fields or [])]
158 202

  
......
177 221
        self.geolocations = sorted((k, v) for k, v in (self.geolocations or {}).items())
178 222

  
179 223

  
224
class FormDatas(object):
225
    def __init__(self, wcs_api, formdef, full=False, anonymize=False, batch=1000):
226
        self.wcs_api = wcs_api
227
        self.formdef = formdef
228
        self._full = full
229
        self.anonymize = anonymize
230
        self.batch = batch
231

  
232
    def __getitem__(self, slice_or_id):
233
        # get batch of forms
234
        if isinstance(slice_or_id, slice):
235
            def helper():
236
                if slice_or_id.stop <= slice_or_id.start or slice_or_id.step:
237
                    raise ValueError('invalid slice %s' % slice_or_id)
238
                offset = slice_or_id.start
239
                limit = slice_or_id.stop - slice_or_id.start
240

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

  
265
    @property
266
    def full(self):
267
        forms = copy.copy(self)
268
        forms._full = True
269
        return forms
270

  
271
    @property
272
    def anonymized(self):
273
        forms = copy.copy(self)
274
        forms.anonymize = True
275
        return forms
276

  
277
    def batched(self, batch):
278
        forms = copy.copy(self)
279
        forms.batch = batch
280
        return forms
281

  
282
    def __iter__(self):
283
        start = 0
284
        while True:
285
            empty = True
286
            for formdef in self[start:start + self.batch]:
287
                empty = False
288
                yield formdef
289
            if empty:
290
                break
291
            start += self.batch
292

  
293
    def __len__(self):
294
        return len(list((o for o in self)))
295

  
296

  
297
class CancelSubmitError(Exception):
298
    pass
299

  
300

  
301
class FormDefSubmit(object):
302
    formdef = None
303
    data = None
304
    user_email = None
305
    user_name_id = None
306
    backoffice_submission = False
307
    submission_channel = None
308
    submission_context = None
309
    draft = False
310

  
311
    def __init__(self, wcs_api, formdef, **kwargs):
312
        self.wcs_api = wcs_api
313
        self.formdef = formdef
314
        self.data = {}
315
        self.__dict__.update(kwargs)
316

  
317
    def payload(self):
318
        d = {
319
            'data': self.data.copy(),
320
        }
321
        if self.draft:
322
            d.setdefault('meta', {})['draft'] = True
323
        if self.backoffice_submission:
324
            d.setdefault('meta', {})['backoffice-submission'] = True
325
        if self.submission_context:
326
            d['context'] = self.submission_context
327
        if self.submission_channel:
328
            d.setdefault('context', {})['channel'] = self.submission_channel
329
        if self.user_email:
330
            d.setdefault('user', {})['email'] = self.user_email
331
        if self.user_name_id:
332
            d.setdefault('user', {})['NameID'] = self.user_name_id
333
        return d
334

  
335
    def set(self, field, value, **kwargs):
336
        if isinstance(field, Field):
337
            varname = field.varname
338
            if not varname:
339
                raise ValueError('field has no varname, submit is impossible')
340
        else:
341
            varname = field
342
            try:
343
                field = [f for f in self.formdef.schema.fields if f.varname == varname][0]
344
            except IndexError:
345
                raise ValueError('no field for varname %s' % varname)
346

  
347
        if value is None or value == {} or value == []:
348
            self.data.pop(varname, None)
349
        elif hasattr(self, '_set_type_%s' % field.type):
350
            getattr(self, '_set_type_%s' % field.type)(
351
                varname=varname,
352
                field=field,
353
                value=value, **kwargs)
354
        else:
355
            self.data[varname] = value
356

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

  
375
    def _set_type_items(self, varname, field, value, **kwargs):
376
        if not isinstance(value, list):
377
            raise TypeError('%s is an ItemsField it needs a list as value' % varname)
378

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

  
403
    def _set_type_file(self, varname, field, value, **kwargs):
404
        filename = kwargs.get('filename')
405
        content_type = kwargs.get('content_type', 'application/octet-stream')
406
        if hasattr(value, 'read'):
407
            content = base64.b64encode(value.read()).decode('ascii')
408
        elif isinstance(value, bytes):
409
            content = base64.b64encode(value).decode('ascii')
410
        elif isinstance(value, dict):
411
            if not set(value).issuperset(set(['filename', 'content'])):
412
                raise ValueError('file field needs a dict value with filename and content')
413
            content = value['content']
414
            filename = value['filename']
415
            content_type = value.get('content_type', content_type)
416
        if not filename:
417
            raise ValueError('missing filename')
418
        self.data[varname] = {
419
            'filename': filename,
420
            'content': content,
421
            'content_type': content_type,
422
        }
423

  
424
    def _set_type_date(self, varname, field, value):
425
        if isinstance(value, str):
426
            value = datetime.datetime.strptime(value, '%Y-%m-%d').date()
427
        if isinstance(value, datetime.datetime):
428
            value = value.date()
429
        if isinstance(value, datetime.date):
430
            value = value.strftime('%Y-%m-%d')
431
        self.data[varname] = value
432

  
433
    def _set_type_map(self, varname, field, value):
434
        if not isinstance(value, dict):
435
            raise TypeError('value must be a dict for a map field')
436
        if set(value) != set(['lat', 'lon']):
437
            raise ValueError('map field expect keys lat and lon')
438
        self.data[varname] = value
439

  
440
    def _set_type_bool(self, varname, field, value):
441
        if isinstance(value, str):
442
            value = value.lower().strip() in ['yes', 'true', 'on']
443
        if not isinstance(value, bool):
444
            raise TypeError('value must be a boolean or a string true, yes, on, false, no, off')
445
        self.data[varname] = value
446

  
447
    def cancel(self):
448
        raise CancelSubmitError
449

  
450

  
180 451
class FormDef(BaseObject):
181 452
    geolocations = None
182 453

  
183 454
    def __init__(self, wcs_api, **kwargs):
184
        self.__wcs_api = wcs_api
455
        self._wcs_api = wcs_api
185 456
        self.__dict__.update(**kwargs)
186 457

  
187
    def __unicode__(self):
458
    def __str__(self):
188 459
        return self.title
189 460

  
190 461
    @property
191
    def datas(self):
192
        datas = self.__wcs_api.get_formdata(self.slug)
193
        for data in datas:
194
            data.formdef = self
195
            yield data
462
    def formdatas(self):
463
        return FormDatas(wcs_api=self._wcs_api, formdef=self)
196 464

  
197 465
    @property
198 466
    def schema(self):
199
        return self.__wcs_api.get_schema(self.slug)
200

  
201
    def __repr__(self):
202
        return '<{klass} {slug!r}>'.format(klass=self.__class__.__name__, slug=self.slug)
467
        if not hasattr(self, '_schema'):
468
            d = self._wcs_api.get_json('api/formdefs/{self.slug}/schema'.format(self=self))
469
            self._schema = Schema(self._wcs_api, **d)
470
        return self._schema
471

  
472
    @contextlib.contextmanager
473
    def submit(self, **kwargs):
474
        submitter = FormDefSubmit(
475
            wcs_api=self._wcs_api,
476
            formdef=self,
477
            **kwargs)
478
        try:
479
            yield submitter
480
        except CancelSubmitError:
481
            return
482
        payload = submitter.payload()
483
        d = self._wcs_api.post_json(payload, 'api/formdefs/{self.slug}/submit'.format(self=self))
484
        if d['err'] != 0:
485
            raise WcsApiError('submited returned an error: %s' % d)
486
        submitter.result = BaseObject(self._wcs_api, **d['data'])
203 487

  
204 488

  
205 489
class Role(BaseObject):
......
210 494
    pass
211 495

  
212 496

  
497
class WcsObjects(object):
498
    url = None
499
    object_class = None
500

  
501
    def __init__(self, wcs_api):
502
        self.wcs_api = wcs_api
503

  
504
    def __getitem__(self, slug):
505
        if isinstance(slug, self.object_class):
506
            slug = slug.slug
507
        for instance in self:
508
            if instance.slug == slug:
509
                return instance
510
        raise KeyError('no instance with slug %r' % slug)
511

  
512
    def __iter__(self):
513
        for d in self.wcs_api.get_json(self.url)['data']:
514
            yield self.object_class(wcs_api=self.wcs_api, **d)
515

  
516
    def __len__(self):
517
        return len(list((o for o in self)))
518

  
519

  
520
class Roles(WcsObjects):
521
    # Paths are not coherent :/
522
    url = 'api/roles'
523
    object_class = Role
524

  
525

  
526
class FormDefs(WcsObjects):
527
    url = 'api/formdefs/?include-count=on'
528
    object_class = FormDef
529

  
530

  
531
class Categories(WcsObjects):
532
    url = 'api/categories/'
533
    object_class = Category
534

  
535

  
213 536
class WcsApi(object):
214
    def __init__(self, url, orig, key, verify=True, slugs=None, batch_size=500):
537
    def __init__(self, url, email=None, name_id=None, batch_size=1000,
538
                 session=None, logger=None, orig=None, key=None, verify=True):
215 539
        self.url = url
540
        self.batch_size = batch_size
541
        self.email = email
542
        self.name_id = name_id
543
        self.requests = session or requests.Session()
544
        self.logger = logger or logging.getLogger(__name__)
216 545
        self.orig = orig
217 546
        self.key = key
218 547
        self.verify = verify
219
        self.cache = {}
220
        self.slugs = slugs or []
221
        self.batch_size = batch_size
222 548

  
223
    @property
224
    def formdefs_url(self):
225
        return urlparse.urljoin(self.url, 'api/formdefs/')
226

  
227
    @property
228
    def forms_url(self):
229
        return urlparse.urljoin(self.url, 'api/forms/')
230

  
231
    @property
232
    def roles_url(self):
233
        return urlparse.urljoin(self.url, 'api/roles')
549
    def _build_url(self, url_parts):
550
        url = self.url
551
        for url_part in url_parts:
552
            url = urlparse.urljoin(url, url_part)
553
        return url
234 554

  
235 555
    def get_json(self, *url_parts):
236
        url = reduce(lambda x, y: urlparse.urljoin(x, y), url_parts)
237
        params = {'orig': self.orig}
238
        query_string = urllib.urlencode(params)
239
        presigned_url = url + ('&' if '?' in url else '?') + query_string
240
        if presigned_url in self.cache:
241
            return self.cache[presigned_url]
242
        signed_url = signature.sign_url(presigned_url, self.key)
556
        url = self._build_url(url_parts)
557
        params = {}
558
        if self.email:
559
            params['email'] = self.email
560
        if self.name_id:
561
            params['NameID'] = self.name_id
562
        if self.orig:
563
            params['orig'] = self.orig
564
        query_string = urlparse.urlencode(params)
565
        complete_url = url + ('&' if '?' in url else '?') + query_string
566
        final_url = complete_url
567
        if self.key:
568
            final_url = signature.sign_url(final_url, self.key)
243 569
        try:
244
            response = requests.get(signed_url, verify=self.verify)
570
            response = self.requests.get(final_url, verify=self.verify)
571
            response.raise_for_status()
245 572
        except requests.RequestException as e:
246
            raise WcsApiError('GET request failed', url=signed_url, exception=e)
573
            content = getattr(getattr(e, 'response', None), 'content', None)
574
            raise WcsApiError('GET request failed', final_url, e, content)
575
        else:
576
            try:
577
                return response.json()
578
            except ValueError as e:
579
                raise WcsApiError('Invalid JSON content', final_url, e)
580

  
581
    def post_json(self, data, *url_parts):
582
        url = self._build_url(url_parts)
583
        params = {}
584
        if self.email:
585
            params['email'] = self.email
586
        if self.name_id:
587
            params['NameID'] = self.name_id
588
        if self.orig:
589
            params['orig'] = self.orig
590
        query_string = urlparse.urlencode(params)
591
        complete_url = url + ('&' if '?' in url else '?') + query_string
592
        final_url = complete_url
593
        if self.key:
594
            final_url = signature.sign_url(final_url, self.key)
595
        try:
596
            response = self.requests.post(
597
                final_url,
598
                data=json.dumps(data),
599
                headers={'content-type': 'application/json'},
600
                verify=self.verify)
601
            response.raise_for_status()
602
        except requests.RequestException as e:
603
            content = getattr(getattr(e, 'response', None), 'content', None)
604
            raise WcsApiError('POST request failed', final_url, e, content)
247 605
        else:
248
            if not response.ok:
249
                try:
250
                    text = response.text
251
                except UnicodeError:
252
                    text = '<undecodable>' + repr(response.content)
253
                raise WcsApiError('GET response is not 200',
254
                                  url=signed_url,
255
                                  status_code=response.status_code,
256
                                  content=text)
257 606
            try:
258
                content = response.json()
259
                self.cache[presigned_url] = content
260
                return content
607
                return response.json()
261 608
            except ValueError as e:
262
                raise WcsApiError('Invalid JSON content', url=signed_url, exception=e)
609
                raise WcsApiError('Invalid JSON content', final_url, e)
263 610

  
264 611
    @property
265 612
    def roles(self):
266
        return [Role(wcs_api=self, **d) for d in self.get_json(self.roles_url)['data']]
613
        return Roles(self)
267 614

  
268 615
    @property
269 616
    def formdefs(self):
270
        result = self.get_json(self.formdefs_url + '?include-count=on')
271
        if isinstance(result, dict):
272
            if result['err'] == 0:
273
                data = result['data']
274
            else:
275
                logger.error(u'could not retrieve formdefs from %s, err_desc: %s',
276
                             self.formdefs_url, result.get('err_desc'))
277
                return []
278
        else:
279
            data = result
280
        return [FormDef(wcs_api=self, **d) for d in data
281
                if not self.slugs or d['slug'] in self.slugs]
617
        return FormDefs(self)
282 618

  
283 619
    @property
284 620
    def categories(self):
285
        d = {}
286
        for f in self.formdefs:
287
            if hasattr(f.schema, 'category'):
288
                d[f.schema.category_id] = f.schema.category
289
        return [Category(wcs_api=self, id=k, name=v) for k, v in d.items()]
290

  
291
    def get_formdata(self, slug):
292
        offset = 0
293
        limit = self.batch_size
294
        while True:
295
            data = self.get_json(self.forms_url,
296
                    slug + '/list?anonymise&full=on&offset=%d&limit=%d' % (offset, limit))
297
            for d in data:
298
                # w.c.s. had a bug where some formdata lost their draft status, skip them
299
                if not d.get('receipt_time'):
300
                    continue
301
                yield FormData(wcs_api=self, **d)
302
            if len(data) < limit:
303
                break
304
            offset += limit
305

  
306
    def get_schema(self, slug):
307
        json_schema = self.get_json(self.formdefs_url, slug + '/', 'schema?anonymise')
308
        return Schema(wcs_api=self, **json_schema)
621
        return Categories(self)
309
-