From dec104f07bf962e75de686b5e6f25b2ff096059f Mon Sep 17 00:00:00 2001 From: Benjamin Dauvergne Date: Fri, 24 Mar 2017 16:28:39 +0100 Subject: [PATCH] add connector for API-Particulier (#14838) API Particulier is an API published by the french government for accessing fiscal and social informations about citizens. It can be used to improve efficiency of procedures in local administrations. --- passerelle/apps/api_particulier/__init__.py | 0 .../api_particulier/migrations/0001_initial.py | 31 +++ .../apps/api_particulier/migrations/__init__.py | 0 passerelle/apps/api_particulier/models.py | 156 +++++++++++++++ .../api_particulier/api_particulier_detail.html | 44 +++++ passerelle/settings.py | 1 + tests/test_api_particulier.py | 214 +++++++++++++++++++++ tests/utils.py | 24 +++ 8 files changed, 470 insertions(+) create mode 100644 passerelle/apps/api_particulier/__init__.py create mode 100644 passerelle/apps/api_particulier/migrations/0001_initial.py create mode 100644 passerelle/apps/api_particulier/migrations/__init__.py create mode 100644 passerelle/apps/api_particulier/models.py create mode 100644 passerelle/apps/api_particulier/templates/api_particulier/api_particulier_detail.html create mode 100644 tests/test_api_particulier.py diff --git a/passerelle/apps/api_particulier/__init__.py b/passerelle/apps/api_particulier/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/passerelle/apps/api_particulier/migrations/0001_initial.py b/passerelle/apps/api_particulier/migrations/0001_initial.py new file mode 100644 index 0000000..e1714c6 --- /dev/null +++ b/passerelle/apps/api_particulier/migrations/0001_initial.py @@ -0,0 +1,31 @@ +# -*- coding: utf-8 -*- +from __future__ import unicode_literals + +from django.db import models, migrations + + +class Migration(migrations.Migration): + + dependencies = [ + ('base', '0002_auto_20151009_0326'), + ] + + operations = [ + migrations.CreateModel( + name='APIParticulier', + fields=[ + ('id', models.AutoField(verbose_name='ID', serialize=False, auto_created=True, primary_key=True)), + ('title', models.CharField(max_length=50)), + ('slug', models.SlugField()), + ('description', models.TextField()), + ('log_level', models.CharField(default=b'NOTSET', max_length=10, verbose_name='Log Level', choices=[(b'NOTSET', b'NOTSET'), (b'DEBUG', b'DEBUG'), (b'INFO', b'INFO'), (b'WARNING', b'WARNING'), (b'ERROR', b'ERROR'), (b'CRITICAL', b'CRITICAL'), (b'FATAL', b'FATAL')])), + ('_platform', models.CharField(max_length=8, verbose_name='platform', choices=[(b'test', 'Test'), (b'prod', 'Production'), (b'dev', 'Development'), (b'mock', 'Mock')])), + ('_api_key', models.CharField(default=b'', max_length=64, verbose_name='API key', blank=True)), + ('users', models.ManyToManyField(to='base.ApiUser', blank=True)), + ], + options={ + 'abstract': False, + }, + bases=(models.Model,), + ), + ] diff --git a/passerelle/apps/api_particulier/migrations/__init__.py b/passerelle/apps/api_particulier/migrations/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/passerelle/apps/api_particulier/models.py b/passerelle/apps/api_particulier/models.py new file mode 100644 index 0000000..07c0a87 --- /dev/null +++ b/passerelle/apps/api_particulier/models.py @@ -0,0 +1,156 @@ +# passerelle.apps.api_particulier +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +'''Gateway to API-Particulier web-service from SGMAP: + https://particulier.api.gouv.fr/ +''' + +from urlparse import urljoin +from collections import OrderedDict + +from django.db import models +from django.utils.translation import ugettext_lazy as _ + +from passerelle.base.models import BaseResource +from passerelle.utils.api import endpoint +from passerelle.utils.jsonresponse import APIError + + +class APIParticulier(BaseResource): + PLATFORMS = [ + { + 'name': 'test', + 'label': _('Test'), + 'url': 'https://particulier-test.api.gouv.fr/api/', + 'api_key': 'test-token', + }, + { + 'name': 'prod', + 'label': _('Production'), + 'url': 'https://particulier.api.gouv.fr/api/' + }, + { + 'name': 'dev', + 'label': _('Development'), + 'url': 'https://particulier-dev.api.gouv.fr/api/' + }, + { + 'name': 'mock', + 'label': _('Mock'), + 'url': 'https://particulier-mock.api.gouv.fr/api/' + }, + ] + PLATFORMS = OrderedDict([(platform['name'], platform) for platform in PLATFORMS]) + + _platform = models.CharField( + verbose_name=_('platform'), + max_length=8, + choices=[(key, platform['label']) for key, platform in PLATFORMS.iteritems()]) + + _api_key = models.CharField( + max_length=64, + default='', + blank=True, + verbose_name=_('API key')) + + @property + def platform(self): + return self.PLATFORMS[self._platform] + + @property + def url(self): + return self.platform['url'] + + @property + def api_key(self): + return self.platform.get('api_key', self._api_key) + + def get(self, path, **kwargs): + user = kwargs.pop('user', None) + url = urljoin(self.url, path) + headers = {'X-API-KEY': self.api_key} + if user: + headers['X-User'] = user + response = self.requests.get( + url, + headers=headers, + **kwargs) + + if response.status_code != 200: + raise APIError( + u'API-particulier platform "%s" returned non-200 code: %s' % + (self._platform, response.status_code), + data={ + 'platform': self._platform, + 'code': response.status_code, + 'content': repr(response.content[:1000]), + }) + try: + return response.json() + except ValueError as e: + content = repr(response.content[:1000]) + raise APIError( + u'API-particulier platform "%s" returned non-JSON content: %s' % + (self._platform, content), + data={ + 'exception': unicode(e), + 'platform': self._platform, + 'content': content, + }) + + @endpoint(serializer_type='json-api', perm='can_access') + def impots_svair(self, request, numero_fiscal, reference_avis, user=None): + return self.get('impots/svair', params={ + 'numeroFiscal': numero_fiscal, + 'referenceAvis': reference_avis, + }, user=user) + + @endpoint(serializer_type='json-api', perm='can_access') + def impots_adresse(self, request, numero_fiscal, reference_avis, user=None): + return self.get('impots/adresse', params={ + 'numeroFiscal': numero_fiscal, + 'referenceAvis': reference_avis, + }, user=user) + + @endpoint(serializer_type='json-api', perm='can_access') + def caf_qf(self, request, code_postal, numero_allocataire, user=None): + return self.get('caf/qf', params={ + 'codePostal': code_postal, + 'numeroAllocataire': numero_allocataire, + }, user=user) + + @endpoint(serializer_type='json-api', perm='can_access') + def caf_adresse(self, request, code_postal, numero_allocataire, user=None): + return self.get('caf/adresse', params={ + 'codePostal': code_postal, + 'numeroAllocataire': numero_allocataire, + }, user=user) + + @endpoint(serializer_type='json-api', perm='can_access') + def caf_famille(self, request, code_postal, numero_allocataire, user=None): + return self.get('caf/famille', params={ + 'codePostal': code_postal, + 'numeroAllocataire': numero_allocataire, + }, user=user) + + @classmethod + def get_icon_class(cls): + return 'ressources' + + category = _('Business Process Connectors') + + class Meta: + verbose_name = _('API Particulier') diff --git a/passerelle/apps/api_particulier/templates/api_particulier/api_particulier_detail.html b/passerelle/apps/api_particulier/templates/api_particulier/api_particulier_detail.html new file mode 100644 index 0000000..1a41598 --- /dev/null +++ b/passerelle/apps/api_particulier/templates/api_particulier/api_particulier_detail.html @@ -0,0 +1,44 @@ +{% extends "passerelle/manage/service_view.html" %} +{% load i18n passerelle %} + +{% block endpoints %} + +{% endblock %} + +{% block security %} +

+ {% trans 'Access is limited to the following API users:' %} +

+{% access_rights_table resource=object permission='can_access' %} +{% endblock %} diff --git a/passerelle/settings.py b/passerelle/settings.py index bdc7209..04f86b4 100644 --- a/passerelle/settings.py +++ b/passerelle/settings.py @@ -111,6 +111,7 @@ INSTALLED_APPS = ( 'orange', 'family', 'passerelle.apps.opengis', + 'api_particulier', # backoffice templates and static 'gadjo', ) diff --git a/tests/test_api_particulier.py b/tests/test_api_particulier.py new file mode 100644 index 0000000..d9f5696 --- /dev/null +++ b/tests/test_api_particulier.py @@ -0,0 +1,214 @@ +# -*- coding: utf-8 -*- + +# tests/test_api_particulier.py +# Copyright (C) 2017 Entr'ouvert +# +# This program is free software: you can redistribute it and/or modify it +# under the terms of the GNU Affero General Public License as published +# by the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU Affero General Public License for more details. +# +# You should have received a copy of the GNU Affero General Public License +# along with this program. If not, see . + +import pytest +from httmock import urlmatch, HTTMock, response + +from api_particulier.models import APIParticulier + +from utils import make_ressource, endpoint_get + +SVAIR_RESPONSE = { + "declarant1": { + "nom": "Martin", + "nomNaissance": "Martin", + "prenoms": "Pierre", + "dateNaissance": "22/03/1985" + }, + "declarant2": { + "nom": "Martin", + "nomNaissance": "Honore", + "prenoms": "Marie", + "dateNaissance": "03/04/1986" + }, + "foyerFiscal": { + "annee": 2015, + "adresse": "12 rue Balzac 75008 Paris" + }, + "dateRecouvrement": "10/10/2015", + "dateEtablissement": "08/07/2015", + "nombreParts": 2, + "situationFamille": "Marié(e)s", + "nombrePersonnesCharge": 2, + "revenuBrutGlobal": 29880, + "revenuImposable": 29880, + "impotRevenuNetAvantCorrections": 2165, + "montantImpot": 2165, + "revenuFiscalReference": 29880, + "anneeImpots": "2015", + "anneeRevenus": "2014" +} + +IMPOTS_ADRESSE = { + "adresses": [ + { + "adresse": { + "citycode": "75108", + "street": "Rue Balzac", + "name": "12 Rue Balzac", + "housenumber": "12", + "city": "Paris", + "type": "housenumber", + "context": "75, Île-de-France", + "score": 0.9401454545454544, + "label": "12 Rue Balzac 75008 Paris", + "postcode": "75008" + }, + "geometry": { + "type": "Point", + "coordinates": [ + 2.300816, + 48.873951 + ] + } + } + ], + "declarant1": { + "nom": "Martin", + "nomNaissance": "Martin", + "prenoms": "Pierre", + "dateNaissance": "22/03/1985" + }, + "declarant2": { + "nom": "Martin", + "nomNaissance": "Honore", + "prenoms": "Marie", + "dateNaissance": "03/04/1986" + }, + "foyerFiscal": { + "annee": 2015, + "adresse": "12 rue Balzac 75008 Paris" + } +} + + +@urlmatch(netloc='^particulier.*\.api\.gouv\.fr$', + path='^/api/impots/svair$') +def api_particulier_impots_svair(url, request): + return response(200, SVAIR_RESPONSE, request=request) + + +@urlmatch(netloc='^particulier.*\.api\.gouv\.fr$', + path='^/api/impots/adresse$') +def api_particulier_impots_adresse(url, request): + return response(200, IMPOTS_ADRESSE, request=request) + + +@urlmatch(netloc='^particulier.*\.api\.gouv\.fr$') +def api_particulier_error_500(url, request): + return response(500, 'something bad happened', request=request) + +@urlmatch(netloc='^particulier.*\.api\.gouv\.fr$') +def api_particulier_error_not_json(url, request): + return response(200, 'something bad happened', request=request) + +@pytest.yield_fixture +def mock_api_particulier(): + with HTTMock(api_particulier_impots_svair, api_particulier_impots_adresse): + yield None + + +@pytest.fixture +def ressource(db): + return make_ressource( + APIParticulier, + slug='test', + title='API Particulier Prod', + description='API Particulier Prod', + _platform='test') + + +def test_error(app, ressource, mock_api_particulier): + with HTTMock(api_particulier_error_500): + def do(endpoint, params): + resp = endpoint_get( + '/api-particulier/test/%s' % endpoint, + app, + ressource, + endpoint, + params=params) + assert resp.status_code == 200 + assert resp.json['err'] == 1 + assert resp.json['data']['code'] == 500 + vector = [ + (['impots_svair', 'impots_adresse'], { + 'numero_fiscal': 12, + 'reference_avis': 15, + 'user': 'john.doe', + }), + (['caf_qf', 'caf_adresse', 'caf_famille'], { + 'code_postal': 12, + 'numero_allocataire': 15 + }), + ] + for endpoints, params in vector: + for endpoint in endpoints: + do(endpoint, params) + with HTTMock(api_particulier_error_not_json): + def do(endpoint, params): + resp = endpoint_get( + '/api-particulier/test/%s' % endpoint, + app, + ressource, + endpoint, + params=params) + assert resp.status_code == 200 + assert resp.json['err'] == 1 + assert resp.json['data']['exception'] == 'No JSON object could be decoded' + vector = [ + (['impots_svair', 'impots_adresse'], { + 'numero_fiscal': 12, + 'reference_avis': 15, + 'user': 'john.doe', + }), + (['caf_qf', 'caf_adresse', 'caf_famille'], { + 'code_postal': 12, + 'numero_allocataire': 15 + }), + ] + for endpoints, params in vector: + for endpoint in endpoints: + do(endpoint, params) + + +def test_impots_svair(app, ressource, mock_api_particulier): + resp = endpoint_get( + '/api-particulier/test/impots_svair', + app, + ressource, + 'impots_svair', + params={ + 'numero_fiscal': 12, + 'reference_avis': 15 + }) + assert resp.json['data']['montantImpot'] == 2165 + + +def test_impots_adresse(app, ressource, mock_api_particulier): + resp = endpoint_get( + '/api-particulier/test/impots_adresse', + app, + ressource, + 'impots_adresse', + params={ + 'numero_fiscal': 12, + 'reference_avis': 15 + }) + assert resp.json['data']['adresses'][0]['adresse']['citycode'] == '75108' + +# FIXME: CAF web services are currently broken, add test eventually when we can test them diff --git a/tests/utils.py b/tests/utils.py index 7af106e..3c8f583 100644 --- a/tests/utils.py +++ b/tests/utils.py @@ -39,3 +39,27 @@ def mock_url(url, response): def mocked(url, request): return response return httmock.HTTMock(mocked) + + +def make_ressource(model_class, **kwargs): + api, created = ApiUser.objects.get_or_create( + username='all', + keytype='', + key='') + ressource = model_class.objects.create(**kwargs) + obj_type = ContentType.objects.get_for_model(model_class) + AccessRight.objects.get_or_create( + codename='can_access', + apiuser=api, + resource_type=obj_type, + resource_pk=ressource.pk) + return ressource + + +def endpoint_get(expected_url, app, ressource, endpoint, **kwargs): + url = generic_endpoint_url( + connector=ressource.__class__.get_connector_slug(), + endpoint=endpoint, + slug=ressource.slug) + assert url == expected_url, 'endpoint URL has changed' + return app.get(url, **kwargs) -- 2.1.4