Projet

Général

Profil

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

Serghei Mihai, 24 juin 2022 09:40

Télécharger (15,9 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               | 139 ++++++++++++++
 passerelle/settings.py                        |   1 +
 tests/test_sivin.py                           | 176 ++++++++++++++++++
 6 files changed, 365 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()
51

  
52
    def get_token(self):
53
        cache_key = 'sivin-%s-token' % self.pk
54
        token = cache.get(cache_key)
55
        if 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.call('consulterfinitionparimmat', {'immat': immat, 'codesra': codesra})
122
        result = self.get_infos_by_immat('consulterfinitionparimmat', immat, codesra)
123
        return {'data': result['data']['finitions']}
124

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

  
133
    @endpoint(
134
        perm='can_access',
135
        description=_('Get vehicle theorical "finition" by registration plate'),
136
        parameters={'immat': {'description': _('Registration plate number'), 'example_value': '01XT0747'}},
137
    )
138
    def consulterfinitiontheoriqueparimmat(self, request, immat, codesra=None):
139
        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
EXPIRED_TOKEN_MESSAGE = (
42
    '<ams:fault xmlns:ams="http://wso2.org/apimanager/security">'
43
    '<ams:code>900901</ams:code><ams:message>Invalid Credentials</ams:message>'
44
    '<ams:description>Invalid Credentials. Make sure you have provided the correct security credentials</ams:description>'
45
    '</ams:fault>'
46
)
47

  
48

  
49
TOKEN_401 = tests.utils.FakedResponse(
50
    content='{"error_description": "Client Authentication failed.", "error": "invalid_client"}',
51
    status_code=401,
52
    headers={'Content-Type': 'application/json'},
53
)
54

  
55
TOKEN = tests.utils.FakedResponse(
56
    content='{"access_token": "token_value", "scope": "default", "token_type": "Bearer", "expires_in": 3600}',
57
    status_code=200,
58
    headers={'Content-Type': 'application/json'},
59
)
60

  
61
EXPIRED_TOKEN = tests.utils.FakedResponse(
62
    content=EXPIRED_TOKEN_MESSAGE,
63
    status_code=500,
64
)
65

  
66
VIN = tests.utils.FakedResponse(
67
    content=json.dumps(VEHICLE_DETAILS),
68
    status_code=200,
69
    headers={'Content-Type': 'application/json'},
70
)
71

  
72
VEHICLES = tests.utils.FakedResponse(
73
    content='{"vins":["VF1FB30A511331122"]}', status_code=200, headers={'Content-Type': 'application/json'}
74
)
75

  
76

  
77
@pytest.fixture
78
def conn():
79
    api_user = ApiUser.objects.create(username='sivin', keytype='API', key='sivinkey')
80
    connector = Resource.objects.create(
81
        title='Test', slug='test', consumer_key='key', consumer_secret='secret', environment='test'
82
    )
83
    obj_type = ContentType.objects.get_for_model(Resource)
84
    AccessRight.objects.create(
85
        codename='can_access', apiuser=api_user, resource_type=obj_type, resource_pk=connector.pk
86
    )
87
    return connector
88

  
89

  
90
@mock.patch('passerelle.utils.Request.post', side_effect=TOKEN_401)
91
def test_no_api_key(mocked_post, app, conn):
92
    mocked_post.return_value = TOKEN
93
    url = reverse(
94
        'generic-endpoint',
95
        kwargs={'connector': 'sivin', 'endpoint': 'consultervehiculeparvin', 'slug': conn.slug},
96
    )
97
    app.get(url, params={'vin': 'vin'}, status=403)
98
    assert mocked_post.call_count == 0
99

  
100

  
101
@mock.patch('passerelle.utils.Request.post')
102
def test_wrong_credentials(mocked_post, app, conn):
103
    mocked_post.return_value = TOKEN_401
104
    url = reverse(
105
        'generic-endpoint',
106
        kwargs={'connector': 'sivin', 'endpoint': 'consultervehiculeparvin', 'slug': conn.slug},
107
    )
108
    resp = app.get(url, params={'apikey': 'sivinkey', 'vin': 'VF1BA0E0514143067'}).json
109
    assert mocked_post.call_count == 1
110
    assert mocked_post.call_args[0][0] == 'https://api.rec.sivin.fr/token'
111
    assert resp['err']
112
    assert resp['err_desc'] == 'Failed to get token. Error: 401'
113

  
114

  
115
@mock.patch('passerelle.utils.Request.post')
116
def test_get_token(mocked_post, app, conn):
117
    mocked_post.return_value = TOKEN
118
    conn.get_token()
119
    assert mocked_post.call_count == 1
120
    assert mocked_post.call_args[0][0] == 'https://api.rec.sivin.fr/token'
121
    # token is in cache so no more http hits
122
    conn.get_token()
123
    assert mocked_post.call_count == 1
124

  
125

  
126
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, VIN))
127
def test_get_details_by_vin(mocked_post, app, conn):
128
    url = reverse(
129
        'generic-endpoint',
130
        kwargs={'connector': 'sivin', 'endpoint': 'consultervehiculeparvin', 'slug': conn.slug},
131
    )
132
    resp = app.get(url, params={'apikey': 'sivinkey', 'vin': 'VF1BA0E0514143067'}).json
133
    assert mocked_post.call_count == 2
134
    assert not resp['err']
135
    assert resp['data'] == VEHICLE_DETAILS
136

  
137

  
138
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, VEHICLES))
139
def test_get_vehicles_by_siren(mocked_post, app, conn):
140
    url = reverse(
141
        'generic-endpoint',
142
        kwargs={'connector': 'sivin', 'endpoint': 'consulterflotteparsiren', 'slug': conn.slug},
143
    )
144
    resp = app.get(url, params={'apikey': 'sivinkey', 'siren': '000399634'}).json
145
    assert mocked_post.call_count == 2
146
    assert not resp['err']
147
    for item in resp['data']:
148
        assert 'id' in item
149
        assert 'text' in item
150

  
151

  
152
@mock.patch('passerelle.utils.Request.post', side_effect=(TOKEN, EXPIRED_TOKEN))
153
def test_get_with_expired_token(mocked_post, app, conn):
154
    url = reverse(
155
        'generic-endpoint',
156
        kwargs={'connector': 'sivin', 'endpoint': 'consulterflotteparsiren', 'slug': conn.slug},
157
    )
158
    resp = app.get(url, params={'apikey': 'sivinkey', 'siren': '000399634'}).json
159
    assert mocked_post.call_count == 2
160
    assert resp['err']
161
    assert resp['err_desc'] == EXPIRED_TOKEN_MESSAGE
162

  
163

  
164
@mock.patch('passerelle.utils.Request.post', side_effect=ReadTimeout('timeout'))
165
def test_connection_timeout(mocked_post, app, conn):
166
    url = reverse(
167
        'generic-endpoint',
168
        kwargs={'connector': 'sivin', 'endpoint': 'consulterflotteparsiren', 'slug': conn.slug},
169
    )
170
    resp = app.get(url, params={'apikey': 'sivinkey', 'siren': '000399634'}).json
171
    assert mocked_post.call_count == 1
172
    assert resp['err']
173
    assert (
174
        resp['err_desc']
175
        == 'failed to call https://api.rec.sivin.fr/sivin/v2/consulterflotteparsiren: timeout'
176
    )
0
-