Projet

Général

Profil

0001-sivin-add-initial-app-66453.patch

Serghei Mihai, 28 juin 2022 11:58

Télécharger (16,7 ko)

Voir les différences:

Subject: [PATCH] sivin: add initial app (#66453)

 passerelle/apps/sivin/__init__.py             |   0
 .../apps/sivin/migrations/0001_initial.py     |  49 +++++
 passerelle/apps/sivin/migrations/__init__.py  |   0
 passerelle/apps/sivin/models.py               | 138 ++++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_sivin.py                           | 204 ++++++++++++++++++
 6 files changed, 392 insertions(+)
 create mode 100644 passerelle/apps/sivin/__init__.py
 create mode 100644 passerelle/apps/sivin/migrations/0001_initial.py
 create mode 100644 passerelle/apps/sivin/migrations/__init__.py
 create mode 100644 passerelle/apps/sivin/models.py
 create mode 100644 tests/test_sivin.py
passerelle/apps/sivin/migrations/0001_initial.py
1
# Generated by Django 2.2.24 on 2022-06-21 13:21
2

  
3
from django.db import migrations, models
4

  
5

  
6
class Migration(migrations.Migration):
7

  
8
    initial = True
9

  
10
    dependencies = [
11
        ('base', '0029_auto_20210202_1627'),
12
    ]
13

  
14
    operations = [
15
        migrations.CreateModel(
16
            name='Resource',
17
            fields=[
18
                (
19
                    'id',
20
                    models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
21
                ),
22
                ('title', models.CharField(max_length=50, verbose_name='Title')),
23
                ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
24
                ('description', models.TextField(verbose_name='Description')),
25
                ('consumer_key', models.CharField(max_length=128, verbose_name='Consumer key')),
26
                ('consumer_secret', models.CharField(max_length=128, verbose_name='Consumer secret')),
27
                (
28
                    'environment',
29
                    models.CharField(
30
                        choices=[('test', 'Test'), ('prod', 'Production')],
31
                        max_length=4,
32
                        verbose_name='Environment',
33
                    ),
34
                ),
35
                (
36
                    'users',
37
                    models.ManyToManyField(
38
                        blank=True,
39
                        related_name='_resource_users_+',
40
                        related_query_name='+',
41
                        to='base.ApiUser',
42
                    ),
43
                ),
44
            ],
45
            options={
46
                'verbose_name': 'SIvin',
47
            },
48
        ),
49
    ]
passerelle/apps/sivin/models.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2022 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/>.xs
16

  
17
from urllib.parse import urljoin
18

  
19
from django.core.cache import cache
20
from django.db import models
21
from django.utils.translation import ugettext_lazy as _
22
from requests import RequestException
23

  
24
from passerelle.base.models import BaseResource
25
from passerelle.utils.api import APIError, endpoint
26

  
27
PLATFORMS = {
28
    'test': {
29
        'token_url': 'https://api.rec.sivin.fr/token',
30
        'api_base_url': 'https://api.rec.sivin.fr/sivin/v2/',
31
    },
32
    'prod': {'token_url': 'https://api.sivin.fr/token', 'api_base_url': 'https://api.rec.sivin.fr/sivin/v1/'},
33
}
34

  
35

  
36
ENVS = (('test', _('Test')), ('prod', _('Production')))
37

  
38

  
39
class Resource(BaseResource):
40
    consumer_key = models.CharField(_('Consumer key'), max_length=128)
41
    consumer_secret = models.CharField(_('Consumer secret'), max_length=128)
42
    environment = models.CharField(_('Environment'), choices=ENVS, max_length=4)
43

  
44
    category = _('Data Sources')
45

  
46
    class Meta:
47
        verbose_name = _('SIvin')
48

  
49
    def check_status(self):
50
        self.get_token(renew=True)
51

  
52
    def get_token(self, renew=False):
53
        cache_key = 'sivin-%s-token' % self.pk
54
        token = cache.get(cache_key)
55
        if not renew and token:
56
            return token
57

  
58
        resp = self.requests.post(
59
            PLATFORMS[self.environment]['token_url'],
60
            auth=(self.consumer_key, self.consumer_secret),
61
            data={'grant_type': 'client_credentials'},
62
        )
63
        if resp.status_code >= 400:
64
            raise APIError('Failed to get token. Error: %s' % resp.status_code)
65
        resp = resp.json()
66
        token = resp['access_token']
67
        timeout = int(resp['expires_in'])
68
        cache.set(cache_key, token, timeout)
69
        self.logger.debug('new token: %s (timeout %ss)', token, timeout)
70
        return token
71

  
72
    def call(self, endpoint, payload):
73
        url = urljoin(PLATFORMS[self.environment]['api_base_url'], endpoint)
74
        try:
75
            resp = self.requests.post(
76
                url, headers={'Authorization': 'Bearer %s' % self.get_token()}, json=payload
77
            )
78
            if resp.status_code >= 400:
79
                raise APIError(resp.content)
80
        except RequestException as e:
81
            raise APIError('failed to call %s: %s' % (url, e))
82
        return resp.json()
83

  
84
    def get_infos_by_immat(self, endpoint, immat, codesra=None):
85
        payload = {'immat': immat}
86
        if codesra is not None:
87
            payload['codesra'] = codesra
88
        return {'data': self.call(endpoint, payload)}
89

  
90
    @endpoint(
91
        perm='can_access',
92
        description=_('Get vehicle informations by VIN number'),
93
        parameters={'vin': {'description': _('VIN number'), 'example_value': 'VF1BA0E0514143067'}},
94
    )
95
    def consultervehiculeparvin(self, request, vin):
96
        return {'data': self.call('consultervehiculeparvin', {'vin': vin})}
97

  
98
    @endpoint(
99
        perm='can_access',
100
        description=_('Get vehicles list of a SIREN'),
101
        parameters={'siren': {'description': _('SIREN number'), 'example_value': '000399634'}},
102
    )
103
    def consulterflotteparsiren(self, request, siren):
104
        result = self.call('consulterflotteparsiren', {'siren': siren})
105
        return {'data': [{'id': vin, 'text': vin} for vin in result.get('vins', [])]}
106

  
107
    @endpoint(
108
        perm='can_access',
109
        description=_('Get vehicle details by registration plate'),
110
        parameters={'immat': {'description': _('Registration plate number'), 'example_value': '01XT0747'}},
111
    )
112
    def consultervehiculeparimmat(self, request, immat, codesra=None):
113
        return self.get_infos_by_immat('consultervehiculeparimmat', immat, codesra)
114

  
115
    @endpoint(
116
        perm='can_access',
117
        description=_('Get vehicle "finition" by registration plate'),
118
        parameters={'immat': {'description': _('Registration plate number'), 'example_value': '01XT0747'}},
119
    )
120
    def consulterfinitionparimmat(self, request, immat, codesra=None):
121
        result = self.get_infos_by_immat('consulterfinitionparimmat', immat, codesra)
122
        return {'data': result['data']['finitions']}
123

  
124
    @endpoint(
125
        perm='can_access',
126
        description=_('Get vehicle "finition" by registration plate, ordered by rangs'),
127
        parameters={'immat': {'description': _('Registration plate number'), 'example_value': '01XT0747'}},
128
    )
129
    def consulterfinitionscoresparimmat(self, request, immat, codesra=None):
130
        return self.get_infos_by_immat('consulterfinitionscoresparimmat', immat, codesra)
131

  
132
    @endpoint(
133
        perm='can_access',
134
        description=_('Get vehicle theorical "finition" by registration plate'),
135
        parameters={'immat': {'description': _('Registration plate number'), 'example_value': '01XT0747'}},
136
    )
137
    def consulterfinitiontheoriqueparimmat(self, request, immat, codesra=None):
138
        return self.get_infos_by_immat('consulterfinitiontheoriqueparimmat', immat, codesra)
passerelle/settings.py
164 164
    'passerelle.apps.plone_restapi',
165 165
    'passerelle.apps.sector',
166 166
    'passerelle.apps.sfr_dmc',
167
    'passerelle.apps.sivin',
167 168
    'passerelle.apps.soap',
168 169
    'passerelle.apps.solis',
169 170
    'passerelle.apps.sp_fr',
tests/test_sivin.py
1
# Copyright (C) 2022  Entr'ouvert
2
#
3
# This program is free software: you can redistribute it and/or modify it
4
# under the terms of the GNU Affero General Public License as published
5
# by the Free Software Foundation, either version 3 of the License, or
6
# (at your option) any later version.
7
#
8
# This program is distributed in the hope that it will be useful,
9
# but WITHOUT ANY WARRANTY; without even the implied warranty of
10
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
11
# GNU Affero General Public License for more details.
12
#
13
# You should have received a copy of the GNU Affero General Public License
14
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
15

  
16
import json
17

  
18
import mock
19
import pytest
20
from django.contrib.contenttypes.models import ContentType
21
from django.urls import reverse
22
from requests.exceptions import ReadTimeout
23

  
24
import tests.utils
25
from passerelle.apps.sivin.models import Resource
26
from passerelle.base.models import AccessRight, ApiUser
27

  
28
pytestmark = pytest.mark.django_db
29

  
30
VEHICLE_DETAILS = {
31
    "carrosserie": "BERLINE",
32
    "clEnvironPrf": "70/220 2001/100EURO3",
33
    "codifVin": "VF7FCKFVB26857835",
34
    "genreVCG": "VP",
35
    "immatSiv": "FS032GM",
36
    "genreVPrf": "VP",
37
    "date1erCir": "2003-11-21",
38
    "nSiren": "000000000",
39
}
40

  
41
VEHICLE_THEORICAL_FINITION = {
42
    'carrosserie': 'COMBISPACE',
43
    'clEnvironPrf': '2001/100A',
44
    'codifVin': 'VF7GJRHYK93204774',
45
    'genreVCG': 'VP',
46
    'immatSiv': '01XT0747',
47
    'genreVPrf': 'VP',
48
    'date1erCir': '2004-11-19',
49
    'nSiren': '000000000',
50
}
51

  
52
EXPIRED_TOKEN_MESSAGE = (
53
    '<ams:fault xmlns:ams="http://wso2.org/apimanager/security">'
54
    '<ams:code>900901</ams:code><ams:message>Invalid Credentials</ams:message>'
55
    '<ams:description>Invalid Credentials. Make sure you have provided the correct security credentials</ams:description>'
56
    '</ams:fault>'
57
)
58

  
59

  
60
TOKEN_401 = tests.utils.FakedResponse(
61
    content='{"error_description": "Client Authentication failed.", "error": "invalid_client"}',
62
    status_code=401,
63
    headers={'Content-Type': 'application/json'},
64
)
65

  
66
TOKEN = tests.utils.FakedResponse(
67
    content='{"access_token": "token_value", "scope": "default", "token_type": "Bearer", "expires_in": 3600}',
68
    status_code=200,
69
    headers={'Content-Type': 'application/json'},
70
)
71

  
72
EXPIRED_TOKEN = tests.utils.FakedResponse(
73
    content=EXPIRED_TOKEN_MESSAGE,
74
    status_code=500,
75
)
76

  
77
VIN = tests.utils.FakedResponse(
78
    content=json.dumps(VEHICLE_DETAILS),
79
    status_code=200,
80
    headers={'Content-Type': 'application/json'},
81
)
82

  
83
VEHICLES = tests.utils.FakedResponse(
84
    content='{"vins":["VF1FB30A511331122"]}', status_code=200, headers={'Content-Type': 'application/json'}
85
)
86

  
87
FINITION = tests.utils.FakedResponse(
88
    content=json.dumps(VEHICLE_THEORICAL_FINITION),
89
    status_code=200,
90
    headers={'Content-Type': 'application/json'},
91
)
92

  
93

  
94
@pytest.fixture
95
def conn():
96
    api_user = ApiUser.objects.create(username='sivin', keytype='API', key='sivinkey')
97
    connector = Resource.objects.create(
98
        title='Test', slug='test', consumer_key='key', consumer_secret='secret', environment='test'
99
    )
100
    obj_type = ContentType.objects.get_for_model(Resource)
101
    AccessRight.objects.create(
102
        codename='can_access', apiuser=api_user, resource_type=obj_type, resource_pk=connector.pk
103
    )
104
    return connector
105

  
106

  
107
@mock.patch('passerelle.utils.Request.post')
108
def test_no_api_key(mocked_post, app, conn):
109
    url = reverse(
110
        'generic-endpoint',
111
        kwargs={'connector': 'sivin', 'endpoint': 'consultervehiculeparvin', 'slug': conn.slug},
112
    )
113
    app.get(url, params={'vin': 'vin'}, status=403)
114
    assert mocked_post.call_count == 0
115

  
116

  
117
@mock.patch('passerelle.utils.Request.post')
118
def test_wrong_credentials(mocked_post, app, conn):
119
    mocked_post.return_value = TOKEN_401
120
    url = reverse(
121
        'generic-endpoint',
122
        kwargs={'connector': 'sivin', 'endpoint': 'consultervehiculeparvin', 'slug': conn.slug},
123
    )
124
    resp = app.get(url, params={'apikey': 'sivinkey', 'vin': 'VF1BA0E0514143067'}).json
125
    assert mocked_post.call_count == 1
126
    assert mocked_post.call_args[0][0] == 'https://api.rec.sivin.fr/token'
127
    assert resp['err']
128
    assert resp['err_desc'] == 'Failed to get token. Error: 401'
129

  
130

  
131
@mock.patch('passerelle.utils.Request.post')
132
def test_get_token(mocked_post, app, conn):
133
    mocked_post.return_value = TOKEN
134
    conn.get_token()
135
    assert mocked_post.call_count == 1
136
    assert mocked_post.call_args[0][0] == 'https://api.rec.sivin.fr/token'
137
    # token is in cache so no more http hits
138
    conn.get_token()
139
    assert mocked_post.call_count == 1
140

  
141

  
142
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, VIN))
143
def test_get_details_by_vin(mocked_post, app, conn):
144
    url = reverse(
145
        'generic-endpoint',
146
        kwargs={'connector': 'sivin', 'endpoint': 'consultervehiculeparvin', 'slug': conn.slug},
147
    )
148
    resp = app.get(url, params={'apikey': 'sivinkey', 'vin': 'VF1BA0E0514143067'}).json
149
    assert mocked_post.call_count == 2
150
    assert not resp['err']
151
    assert resp['data'] == VEHICLE_DETAILS
152

  
153

  
154
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, VEHICLES))
155
def test_get_vehicles_by_siren(mocked_post, app, conn):
156
    url = reverse(
157
        'generic-endpoint',
158
        kwargs={'connector': 'sivin', 'endpoint': 'consulterflotteparsiren', 'slug': conn.slug},
159
    )
160
    resp = app.get(url, params={'apikey': 'sivinkey', 'siren': '000399634'}).json
161
    assert mocked_post.call_count == 2
162
    assert not resp['err']
163
    for item in resp['data']:
164
        assert 'id' in item
165
        assert 'text' in item
166

  
167

  
168
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, EXPIRED_TOKEN))
169
def test_get_with_expired_token(mocked_post, app, conn):
170
    url = reverse(
171
        'generic-endpoint',
172
        kwargs={'connector': 'sivin', 'endpoint': 'consulterflotteparsiren', 'slug': conn.slug},
173
    )
174
    resp = app.get(url, params={'apikey': 'sivinkey', 'siren': '000399634'}).json
175
    assert mocked_post.call_count == 2
176
    assert resp['err']
177
    assert resp['err_desc'] == EXPIRED_TOKEN_MESSAGE
178

  
179

  
180
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, FINITION))
181
def test_get_vehicle_theorical_finition(mocked_post, app, conn):
182
    url = reverse(
183
        'generic-endpoint',
184
        kwargs={'connector': 'sivin', 'endpoint': 'consulterfinitiontheoriqueparimmat', 'slug': conn.slug},
185
    )
186
    resp = app.get(url, params={'apikey': 'sivinkey', 'immat': '01XT0747'}).json
187
    assert mocked_post.call_count == 2
188
    assert not resp['err']
189
    assert resp['data'] == VEHICLE_THEORICAL_FINITION
190

  
191

  
192
@mock.patch('passerelle.utils.Request.post', side_effect=ReadTimeout('timeout'))
193
def test_connection_timeout(mocked_post, app, conn):
194
    url = reverse(
195
        'generic-endpoint',
196
        kwargs={'connector': 'sivin', 'endpoint': 'consulterflotteparsiren', 'slug': conn.slug},
197
    )
198
    resp = app.get(url, params={'apikey': 'sivinkey', 'siren': '000399634'}).json
199
    assert mocked_post.call_count == 1
200
    assert resp['err']
201
    assert (
202
        resp['err_desc']
203
        == 'failed to call https://api.rec.sivin.fr/sivin/v2/consulterflotteparsiren: timeout'
204
    )
0
-