Projet

Général

Profil

0006-toulouse_smart-add-create-intervention-endpoint-5523.patch

Nicolas Roche, 09 juillet 2021 22:00

Télécharger (18,5 ko)

Voir les différences:

Subject: [PATCH 6/7] toulouse_smart: add create-intervention endpoint (#55230)

 passerelle/contrib/toulouse_smart/models.py   |  56 ++++++
 passerelle/contrib/toulouse_smart/schemas.py  | 124 +++++++++++++
 .../toulouse_smart/create_intervention.json   |  54 ++++++
 tests/test_toulouse_smart.py                  | 172 ++++++++++++++++++
 4 files changed, 406 insertions(+)
 create mode 100644 passerelle/contrib/toulouse_smart/schemas.py
 create mode 100644 tests/data/toulouse_smart/create_intervention.json
passerelle/contrib/toulouse_smart/models.py
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import datetime
18 18

  
19 19
import lxml.etree as ET
20 20
from django.contrib.postgres.fields import JSONField
21 21
from django.db import models
22
from django.urls import reverse
22 23
from django.utils.text import slugify
23 24
from django.utils.timezone import now
24 25
from django.utils.translation import ugettext_lazy as _
25 26
from requests import RequestException
26 27

  
27 28
from passerelle.base.models import BaseResource, HTTPResource
28 29
from passerelle.utils import xml
29 30
from passerelle.utils.api import endpoint
30 31
from passerelle.utils.jsonresponse import APIError
31 32

  
33
from . import schemas, utils
34

  
32 35

  
33 36
class ToulouseSmartResource(BaseResource, HTTPResource):
34 37
    category = _('Business Process Connectors')
35 38

  
36 39
    webservice_base_url = models.URLField(_('Webservice Base URL'))
37 40

  
38 41
    log_requests_errors = False
39 42

  
......
131 134
        },
132 135
    )
133 136
    def get_intervention(self, request, id):
134 137
        url = self.webservice_base_url + 'v1/intervention/%s' % id
135 138
        response = self.request(url)
136 139
        doc = ET.fromstring(response.content)
137 140
        return {'data': xml.to_json(doc)}
138 141

  
142
    @endpoint(
143
        name='create-intervention',
144
        methods=['post'],
145
        description=_('Create an intervention'),
146
        perm='can_access',
147
        post={'request_body': {'schema': {'application/json': schemas.CREATE_SCHEMA}}},
148
    )
149
    def create_intervention(self, request, post_data):
150
        form_url = post_data.pop('form_url')
151
        slug = post_data.pop('slug')
152
        try:
153
            types = [x for x in self.get_intervention_types() if slugify(x['name']) == slug]
154
        except KeyError:
155
            raise APIError('Service is unavailable')
156
        if len(types) == 0:
157
            raise APIError("unknown '%s' block slug" % slug, http_status=400)
158
        intervention_type = types[0]
159

  
160
        try:
161
            block = post_data['data']['data'][0]
162
        except:
163
            raise APIError("cannot find '%s' block field content" % slug, http_status=400)
164
        del post_data['data']
165
        data = {}
166
        cast = {'string': str, 'int': int, 'boolean': bool, 'item': str}
167
        for prop in intervention_type['properties']:
168
            id = utils.make_id(slug + slugify(prop['name']))
169
            if block.get(id):
170
                data[prop['name']] = cast[prop['type']](block[id])
171
            elif prop['required']:
172
                raise APIError("'%s' field is required on '%s' block" % (id, slug), http_status=400)
173

  
174
        post_data['interventionData'] = data
175
        lon = post_data.pop('lon')
176
        lat = post_data.pop('lat')
177
        post_data['geom'] = {"type": "Point", "coordinates": [lon, lat], "crs": "EPSG:4326"}
178
        post_data['interventionCreated'] += 'Z'
179
        post_data['interventionDesired'] += 'Z'
180
        post_data['interventionTypeId'] = intervention_type['id']
181
        post_data['notificationUrl'] = reverse(
182
            'generic-endpoint',
183
            kwargs={'connector': self, 'slug': self.slug, 'endpoint': 'update-intervention'},
184
        )
185

  
186
        url = self.webservice_base_url + 'v1/intervention'
187
        response = self.request(url, json=post_data)
188
        try:
189
            result = response.json()
190
        except ValueError:
191
            raise APIError('invalid json, got: %s' % response.text)
192
        self.set('intervention-%s' % result['id'], form_url)
193
        return {'data': result}
194

  
139 195

  
140 196
class Cache(models.Model):
141 197
    resource = models.ForeignKey(
142 198
        verbose_name=_('Resource'),
143 199
        to=ToulouseSmartResource,
144 200
        on_delete=models.CASCADE,
145 201
        related_name='cache_entries',
146 202
    )
passerelle/contrib/toulouse_smart/schemas.py
1
# passerelle - uniform access to multiple data sources and services
2
# Copyright (C) 2021 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
CREATE_SCHEMA = {
19
    '$schema': 'http://json-schema.org/draft-04/schema#',
20
    "type": "object",
21
    'properties': {
22
        'slug': {
23
            'description': "slug du block de champs intervention",
24
            'type': 'string',
25
        },
26
        'description': {
27
            'description': "Description de la demande",
28
            'type': 'string',
29
        },
30
        'lat': {
31
            'description': 'Latitude',
32
            'type': 'number',
33
        },
34
        'lon': {
35
            'description': 'Longitude',
36
            'type': 'number',
37
        },
38
        'cityId': {
39
            'description': 'Code INSEE de la commune',
40
            'type': 'string',
41
        },
42
        'safeguardRequired': {
43
            'description': 'Présence d’un danger ?',
44
            'type': 'boolean',
45
        },
46
        'interventionCreated': {
47
            'description': 'Date de création de la demande',
48
            'type': 'string',
49
            'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
50
        },
51
        'interventionDesired': {
52
            'description': 'Date d’intervention souhaitée',
53
            'type': 'string',
54
            'pattern': '[0-9]{4}-[0-9]{2}-[0-9]{2}T[0-9]{2}:[0-9]{2}:[0-9]{2}',
55
        },
56
        'submitterFirstName': {
57
            'description': 'Prénom du demandeur',
58
            'type': 'string',
59
        },
60
        'submitterLastName': {
61
            'description': 'Nom du demandeur',
62
            'type': 'string',
63
        },
64
        'submitterMail': {
65
            'description': 'Adresse mail du demandeur',
66
            'type': 'string',
67
        },
68
        'submitterPhone': {
69
            'description': 'Numéro de téléphone du demandeur',
70
            'type': 'string',
71
        },
72
        'submitterAddress': {
73
            'description': 'Adresse du demandeur',
74
            'type': 'string',
75
        },
76
        'submitterType': {
77
            'description': 'Type de demandeur (Commune, élu, usager…)',
78
            'type': 'string',
79
        },
80
        'onPrivateLand': {
81
            'description': 'Intervention sur le domaine privé ?',
82
            'type': 'boolean',
83
        },
84
        'checkDuplicated': {
85
            'description': 'Activation de la détection de doublon (laisser à false pour le moment)',
86
            'type': 'boolean',
87
        },
88
        'external_number': {
89
            'description': 'Numéro externe de la demande (numéro Publik : {{ form_number }})',
90
            'type': 'string',
91
            'pattern': '[0-9]+[0-9]+',
92
        },
93
        'external_status': {
94
            'description': 'Statut externe de la demande (statut Publik : {{ form_status }})',
95
            'type': 'string',
96
        },
97
        'address': {
98
            'description': 'Adresse de la demande (démarche dans Publik : {{ form_backoffice_url }})',
99
            'type': 'string',
100
        },
101
        'form_url': {
102
            'description': "L'adresse vers la vue (front-office) du formulaire : {{ form_url }}",
103
            'type': 'string',
104
        },
105
    },
106
    'required': [
107
        'slug',
108
        'description',
109
        'lat',
110
        'lon',
111
        'interventionCreated',
112
        'interventionDesired',
113
        'submitterFirstName',
114
        'submitterLastName',
115
        'submitterMail',
116
        'submitterPhone',
117
        'submitterAddress',
118
        'submitterType',
119
        'external_number',
120
        'external_status',
121
        'cityId',
122
        'address',
123
    ],
124
}
tests/data/toulouse_smart/create_intervention.json
1
{
2
  "id": "96bc8712-21a6-4fba-a511-740d6f1bd0bc",
3
  "name": "DI-20210707-0031",
4
  "description": "truc",
5
  "geom": {
6
    "type": "Point",
7
    "coordinates": [
8
      4.8546695709228525,
9
      45.75129501117154
10
    ],
11
    "crs": "EPSG:3943"
12
  },
13
  "interventionData": {},
14
  "safeguardRequired": false,
15
  "media": null,
16
  "safeguardDone": null,
17
  "interventionCreated": "2021-07-07T12:19:31.302Z",
18
  "interventionDesired": "2021-06-30T16:08:05Z",
19
  "interventionDone": null,
20
  "submitter": {
21
    "id": null,
22
    "lastName": "admin",
23
    "firstName": "admin12",
24
    "mail": "admin@localhost",
25
    "furtherInformation": null,
26
    "phoneNumber": "0699999999",
27
    "address": ""
28
  },
29
  "submitterType": "\u00e9lu",
30
  "organizations": [
31
    {
32
      "id": "f1378d8a-12bf-4c14-913f-22624b0ecab8",
33
      "name": "Direction des P\u00f4les"
34
    },
35
    {
36
      "id": "8ad4af63-70b5-416f-a75d-c510d83ce1bd",
37
      "name": "Transport Logistique"
38
    }
39
  ],
40
  "domain": null,
41
  "state": {
42
    "id": "e844e67f-5382-4c0f-94d8-56f618263485",
43
    "table": null,
44
    "stateLabel": "Nouveau",
45
    "closes": false
46
  },
47
  "interventionTypeId": "f72d370c-4d25-489f-956e-2a0d48433789",
48
  "onPrivateLand": false,
49
  "duplicates": null,
50
  "external_number": "174-4",
51
  "external_status": "statut1",
52
  "cityId": "12345",
53
  "address": "https://wcs.example.com/backoffice/management/foo/2/"
54
}
tests/test_toulouse_smart.py
11 11
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
12 12
# GNU Affero General Public License for more details.
13 13
#
14 14
# You should have received a copy of the GNU Affero General Public License
15 15
# along with this program.  If not, see <http://www.gnu.org/licenses/>.
16 16

  
17 17
import functools
18 18
import io
19
import json
19 20
import os
20 21
import zipfile
22
from copy import deepcopy
21 23

  
22 24
import httmock
23 25
import lxml.etree as ET
24 26
import pytest
25 27
import utils
26 28
from test_manager import login
27 29

  
28 30
from passerelle.contrib.toulouse_smart.models import ToulouseSmartResource
......
73 75
    return decorator
74 76

  
75 77

  
76 78
def get_xml_file(filename):
77 79
    with open(os.path.join(TEST_BASE_DIR, "%s.xml" % filename), 'rb') as desc:
78 80
        return desc.read()
79 81

  
80 82

  
83
def get_json_file(filename):
84
    with open(os.path.join(TEST_BASE_DIR, "%s.json" % filename)) as desc:
85
        return desc.read()
86

  
87

  
81 88
@mock_response(['/v1/type-intervention', None, b'<List></List>'])
82 89
def test_empty_intervention_types(smart):
83 90
    assert smart.get_intervention_types() == []
84 91

  
85 92

  
86 93
INTERVENTION_TYPES = '''<List>
87 94
   <item>
88 95
       <id>1234</id>
......
224 231
@mock_response(
225 232
    ['/v1/intervention', None, None, 404],
226 233
)
227 234
def test_get_intervention_wrond_id(app, smart):
228 235
    resp = app.get(URL + 'get-intervention?id=3f0558bd-7d85-49a8-97e4-d07bc7f8dc9b')
229 236
    assert resp.json['err']
230 237
    assert 'failed to get' in resp.json['err_desc']
231 238
    assert '404' in resp.json['err_desc']
239

  
240

  
241
BLOCK_FIELD_PAYLOAD = {
242
    'data': [
243
        {
244
            '038a8c2e-14de-4d4f-752f-496eb7fe90d7': 'Candélabre',
245
            '038a8c2e-14de-4d4f-752f-496eb7fe90d7_display': 'Candélabre',
246
            'e72f251a-5eef-5b78-c35a-94b549510029': '42',
247
        }
248
    ],
249
    'schema': {
250
        '038a8c2e-14de-4d4f-752f-496eb7fe90d7': 'item',
251
        'e72f251a-5eef-5b78-c35a-94b549510029': 'string',
252
    },
253
}
254

  
255

  
256
CREATE_INTERVENTION_PAYLOAD = {
257
    'slug': 'coin',
258
    'description': 'coin coin',
259
    'data': BLOCK_FIELD_PAYLOAD,
260
    'lat': 48.833708,
261
    'lon': 2.323349,
262
    'cityId': '12345',
263
    'interventionCreated': '2021-06-30T16:08:05',
264
    'interventionDesired': '2021-06-30T16:08:05',
265
    'submitterFirstName': 'John',
266
    'submitterLastName': 'Doe',
267
    'submitterMail': 'john.doe@example.com',
268
    'submitterPhone': '0123456789',
269
    'submitterAddress': '3 rue des champs de blés',
270
    'submitterType': 'usager',
271
    'external_number': '42-2',
272
    'external_status': 'statut-1-wcs',
273
    'address': 'https://wcs.example.com/backoffice/management/foo/2/',
274
    'form_url': 'https://wcs.example.com/foo/2/',
275
}
276

  
277

  
278
CREATE_INTERVENTION_QUERY = {
279
    'description': 'coin coin',
280
    'cityId': '12345',
281
    'interventionCreated': '2021-06-30T16:08:05Z',
282
    'interventionDesired': '2021-06-30T16:08:05Z',
283
    'submitterFirstName': 'John',
284
    'submitterLastName': 'Doe',
285
    'submitterMail': 'john.doe@example.com',
286
    'submitterPhone': '0123456789',
287
    'submitterAddress': '3 rue des champs de bl\u00e9s',
288
    'submitterType': 'usager',
289
    'external_number': '42-2',
290
    'external_status': 'statut-1-wcs',
291
    'address': 'https://wcs.example.com/backoffice/management/foo/2/',
292
    'interventionData': {'FIELD1': 'Candélabre', 'FIELD2': 42},
293
    'geom': {'type': 'Point', 'coordinates': [2.323349, 48.833708], 'crs': 'EPSG:4326'},
294
    'interventionTypeId': '1234',
295
    'notificationUrl': '/Test/test/update-intervention',
296
}
297

  
298

  
299
@mock_response(
300
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
301
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, get_json_file('create_intervention')],
302
)
303
def test_create_intervention(app, smart):
304
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
305
    assert not resp.json['err']
306
    assert resp.json['data']['id'] == '96bc8712-21a6-4fba-a511-740d6f1bd0bc'
307
    assert smart.get('intervention-%s' % resp.json['data']['id']) == 'https://wcs.example.com/foo/2/'
308

  
309

  
310
def test_create_intervention_wrong_payload(app, smart):
311
    payload = deepcopy(CREATE_INTERVENTION_PAYLOAD)
312
    del payload['slug']
313
    resp = app.post_json(URL + 'create-intervention/', params=payload, status=400)
314
    assert resp.json['err']
315
    assert "'slug' is a required property" in resp.json['err_desc']
316

  
317

  
318
@mock_response()
319
def test_create_intervention_types_unavailable(app, smart):
320
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
321
    assert resp.json['err']
322
    assert 'Service is unavailable' in resp.json['err_desc']
323

  
324

  
325
@mock_response(
326
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
327
)
328
def test_create_intervention_wrong_block_slug(app, smart):
329
    payload = deepcopy(CREATE_INTERVENTION_PAYLOAD)
330
    payload['slug'] = 'coin-coin'
331
    resp = app.post_json(URL + 'create-intervention/', params=payload, status=400)
332
    assert resp.json['err']
333
    assert "unknown 'coin-coin' block slug" in resp.json['err_desc']
334

  
335

  
336
@mock_response(
337
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
338
)
339
def test_create_intervention_no_block(app, smart):
340
    payload = deepcopy(CREATE_INTERVENTION_PAYLOAD)
341
    del payload['data']
342
    resp = app.post_json(URL + 'create-intervention/', params=payload, status=400)
343
    assert resp.json['err']
344
    assert "cannot find 'coin' block field content" in resp.json['err_desc']
345

  
346

  
347
@mock_response(
348
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
349
)
350
def test_create_intervention_missing_value(app, smart):
351
    block_field_payload = {
352
        'data': [
353
            {
354
                '038a8c2e-14de-4d4f-752f-496eb7fe90d7': 'Candélabre',
355
                '038a8c2e-14de-4d4f-752f-496eb7fe90d7_display': 'Candélabre',
356
                'e72f251a-5eef-5b78-c35a-94b549510029': None,
357
            }
358
        ],
359
        'schema': {
360
            '038a8c2e-14de-4d4f-752f-496eb7fe90d7': 'item',
361
            'e72f251a-5eef-5b78-c35a-94b549510029': 'string',
362
        },
363
    }
364
    payload = deepcopy(CREATE_INTERVENTION_PAYLOAD)
365
    block_field_payload = deepcopy(BLOCK_FIELD_PAYLOAD)
366
    block_field_payload['data'][0]['e72f251a-5eef-5b78-c35a-94b549510029'] = None
367
    payload['data'] = block_field_payload
368
    resp = app.post_json(URL + 'create-intervention/', params=payload, status=400)
369
    assert resp.json['err']
370
    assert "field is required on 'coin' block" in resp.json['err_desc']
371

  
372

  
373
@mock_response(
374
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
375
)
376
def test_create_intervention_missing_field(app, smart):
377
    payload = deepcopy(CREATE_INTERVENTION_PAYLOAD)
378
    block_field_payload = deepcopy(BLOCK_FIELD_PAYLOAD)
379
    del block_field_payload['data'][0]['e72f251a-5eef-5b78-c35a-94b549510029']
380
    payload['data'] = block_field_payload
381
    resp = app.post_json(URL + 'create-intervention/', params=payload, status=400)
382
    assert resp.json['err']
383
    assert "field is required on 'coin' block" in resp.json['err_desc']
384

  
385

  
386
@mock_response(
387
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
388
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, None, 500],
389
)
390
def test_create_intervention_error_status(app, smart):
391
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
392
    assert resp.json['err']
393
    assert 'failed to post' in resp.json['err_desc']
394

  
395

  
396
@mock_response(
397
    ['/v1/type-intervention', None, INTERVENTION_TYPES],
398
    ['/v1/intervention', CREATE_INTERVENTION_QUERY, 'not json content'],
399
)
400
def test_create_intervention_error_content(app, smart):
401
    resp = app.post_json(URL + 'create-intervention/', params=CREATE_INTERVENTION_PAYLOAD)
402
    assert resp.json['err']
403
    assert 'invalid json' in resp.json['err_desc']
232
-