Use HTTPResource, JSON schema for inputs, JSON lazy logging, added ForwardFile.attempt, and more

Features:
    * class AtrealOpenads now extends HTTPResource for HTTP Basic credentials
    * check_status is now also an endpoint (endpoint 'test_openads_connexion' deleted)
    * added JSON schema to validate POST request input (ouput schemas are defined too but unused for now)
    * added class 'LogJsonPayloadWithFileContent' to lazy log a json payload with content file filtered
    * added 'attempt' field to class 'ForwardFile' that count the attempt of uploading to openADS.API
    * added more controls over response data received

Refactoring:
    * removed the file content in function 'get_fwd_files_status()' and added content size instead
    * splitted the function 'get_fwd_files_status()' in two: one that retrieve detailed files, one only the summary
    * added function 'log_json_payload()' to help to log json payloads
    * added 2 functions to factorize the process of getting files from json payload
    * added function 'check_file_dict()' to factorize the process of checking a file dictionary

Fixes:
    * Default filename are now under 50 chars
    * Make the function 'get_fwd_files_status()' consistent in its return

Tests:
    * updated the test to reflect the use of HTTPResource and the split of 'get_fwd_files_status()'

More:
    * Added a lot of comments
    * Added endpoint anotation to validate response body, but its commented as the 'get' helper is not defined (not like the 'post' one)
This commit is contained in:
Michael Bideau 2019-07-18 21:47:38 +02:00
parent 85e815f72b
commit 02f4a071fc
4 changed files with 848 additions and 161 deletions

View File

@ -0,0 +1,339 @@
#!/usr/bin/env python
# -*- coding: utf-8 -*-
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2018 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 <http://www.gnu.org/licenses/>.
import json
# TODO add string limits (maxLength)
JSON_SCHEMA_FILE = {
"description": "A file object",
"$id" : "#file",
"type": "object",
"properties": {
"content" : { "type": "string" },
"content_type": { "type": ["string","null"] },
"filename" : { "type": "string" }
},
"required": ["content","filename"]
}
JSON_SCHEMA_FILE_B64 = {
"description": "A file object encoded in base64",
"$id" : "#file",
"type": "object",
"properties": {
"b64_content" : { "type": "string" },
"content_type": { "type": ["string","null"] },
"filename" : { "type": "string" }
},
"required": ["b64_content","filename"]
}
JSON_SCHEMA_DATE_FRENCH = {
"type": "string",
"pattern": "^(0?[1-9]|[12][0-9]|3[01])/(0?[1-9]|1[012])/\d{4}$"
}
JSON_SCHEMA_CHECK_STATUS_OUT = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Response of an openADS 'connexion' test",
"type" : "object",
"properties": {
"response": { "type": "integer" }
},
"required": ["response"]
}
JSON_SCHEMA_CREATE_DOSSIER_IN = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Incoming request to create a 'dossier' in openADS.API",
"definitions": {
"refs-cadastrales": {
"description": "The 3 items of a 'cadastrale' reference",
"$id" : "#refs-cadastrales",
"type" : "array",
"items": {
"type": "string"
},
"minItems": 3,
"maxItems": 3
},
"file": JSON_SCHEMA_FILE,
"file_plan_cadastral": {
"description": "A 'plan cadastral' document file",
"anyOf": [{ "$ref": "#/definitions/file" }, { "type": "null" }]
}
},
"type": "object",
"properties": {
"fields": {
"type": "object",
"properties": {
"cerfa": {
"description": "A 'CERFA' PDF document file",
"type": "object",
"allOf": [{"$ref": "#/definitions/file" }]
},
"plan_cadastral_1": { "$ref": "#/definitions/file_plan_cadastral" },
"plan_cadastral_2": { "$ref": "#/definitions/file_plan_cadastral" },
"plan_cadastral_3": { "$ref": "#/definitions/file_plan_cadastral" },
"plan_cadastral_4": { "$ref": "#/definitions/file_plan_cadastral" },
"plan_cadastral_5": { "$ref": "#/definitions/file_plan_cadastral" },
"terrain_numero_voie" : { "type": "string" },
"terrain_nom_voie" : { "type": "string" },
"terrain_code_postal" : { "type": "string" },
"terrain_localite" : { "type": "string" },
"terrain_lieu_dit" : { "type": ["string","null"] },
"reference_cadastrale": {
"description": "A list of 'cadastrales' references",
"type": "array",
"items": { "$ref": "#/definitions/refs-cadastrales" }
},
"autres_parcelles": {
#"enum": ["Oui","Non"]
"type": "boolean"
},
"references_cadastrales": {
"description": "A list of 'cadastrales' references",
# conditionaly required and typed below
},
"proprietaire" : { "enum": ["Oui","Non"] },
"proprietaire_qualite" : { "type": "string" },
"nom" : { "type": "string" },
"prenom" : { "type": "string" },
"numero_voie" : { "type": "string" },
"nom_voie" : { "type": "string" },
"code_postal" : { "type": "string" },
"localite" : { "type": "string" },
"lieu_dit" : { "type": ["string","null"] },
"mandataire_nom" : { }, # conditionaly required and typed below
"mandataire_prenom" : { }, # conditionaly required and typed below
"mandataire_numero_voie": { }, # conditionaly required and typed below
"mandataire_nom_voie" : { }, # conditionaly required and typed below
"mandataire_code_postal": { }, # conditionaly required and typed below
"mandataire_localite" : { }, # conditionaly required and typed below
"mandataire_lieu_dit" : { } # conditionaly required and typed below
},
# requirements
"required": [
"cerfa",
"terrain_numero_voie",
"terrain_nom_voie",
"terrain_code_postal",
"terrain_localite",
"reference_cadastrale",
"proprietaire",
"proprietaire_qualite"
],
# conditional requirements
"allOf": [
{
"anyOf": [
{
"properties": {
"autres_parcelles": { "const": False },
"references_cadastrales": { "type": "null" }
}
},
{
"properties": {
"autres_parcelles": { "const": True },
"references_cadastrales": {
"type": "array",
"items": { "$ref": "#/definitions/refs-cadastrales" }
}
},
"required": ["autres_parcelles", "references_cadastrales"]
}
]
},
{
"anyOf": [
{
"properties": {
"proprietaire": { "const": "Oui" },
"mandataire_nom" : { "type": "null" },
"mandataire_prenom" : { "type": "null" },
"mandataire_numero_voie": { "type": "null" },
"mandataire_nom_voie" : { "type": "null" },
"mandataire_code_postal": { "type": "null" },
"mandataire_localite" : { "type": "null" },
"mandataire_lieu_dit" : { "type": "null" }
},
"required": [
"nom",
"prenom",
"numero_voie",
"nom_voie",
"code_postal",
"localite"
]
},
{
"properties": {
"proprietaire": { "const": "Non" },
"mandataire_nom" : { "type": "string" },
"mandataire_prenom" : { "type": "string" },
"mandataire_numero_voie": { "type": "string" },
"mandataire_nom_voie" : { "type": "string" },
"mandataire_code_postal": { "type": "string" },
"mandataire_localite" : { "type": "string" },
"mandataire_lieu_dit" : { "type": ["string","null"] }
},
"required": [
"mandataire_nom",
"mandataire_prenom",
"mandataire_numero_voie",
"mandataire_nom_voie",
"mandataire_code_postal",
"mandataire_localite"
]
}
]
}
]
}
},
"required": ["fields"]
}
JSON_SCHEMA_CREATE_DOSSIER_OUT = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Response of a 'dossier' creation in openADS.API",
"type" : "object",
"properties": {
"numero_dossier": { "type": "string" },
"recepisse": JSON_SCHEMA_FILE_B64
},
"required": ["numero_dossier", "recepisse"]
}
JSON_SCHEMA_GET_DOSSIER_OUT = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Response of a 'dossier' creation in openADS.API",
"type" : "object",
"properties": {
"etat" : { "type": "string" },
"date_depot" : JSON_SCHEMA_DATE_FRENCH,
"date_decision": JSON_SCHEMA_DATE_FRENCH,
"date_limite_instruction": JSON_SCHEMA_DATE_FRENCH,
"decision" : { "type": "string" }
},
"required": [
"etat",
"date_depot",
"date_decision",
"date_limite_instruction",
"decision"
]
}
JSON_SCHEMA_FORWARDFILE = {
"description": "A ForwardFile object (PDF document that must be forwarded to openADS)",
"$id" : "#forwardfile",
"type": "object",
"properties": {
"id" : { "type": "integer" },
"numero_demande" : { "type": "string" },
"numero_dossier" : { "type": "string" },
"type_fichier" : { "type": "string" },
"file_hash" : { "type": "string" },
"orig_filename" : { "type": "string" },
"content_type" : { "type": "string" },
"upload_status" : { "type": "string" },
"upload_attempt" : { "type": "integer" },
"upload_msg" : { "type": "string" },
"content_size" : { "type": "integer" },
"last_update_datetime": { "type": "string", "format": "date-time" }
},
"required": [
"id",
"numero_demande",
"numero_dossier",
"type_fichier",
"file_hash",
"orig_filename",
"content_type",
"upload_status",
"upload_attempt",
"upload_msg",
"content_size",
"last_update_datetime"
]
}
JSON_SCHEMA_GET_FWD_FILES_OUT = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Response of a request about the forwarding (detailled) of user files to openADS",
"type" : "array",
"items": { "$ref": "#/definitions/forwardfile" },
"definitions": {
"forwardfile" : JSON_SCHEMA_FORWARDFILE
}
}
JSON_SCHEMA_GET_FWD_FILE_STATUS = {
"description": "The status of a ForwardFile",
"$id" : "#forwardfile-status",
"type": "string",
"pattern": "^\[\w+\] .+ => .+$"
}
JSON_SCHEMA_GET_FWD_FILES_STATUS_OUT = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Response of a request about the forwarding (summarized) of user files to openADS",
"type" : "object",
"properties": {
"all_forwarded": { "type": "boolean" },
"pending" : { "type": "array", "items": { "$ref": "#/definitions/forwardfile-status" } },
"uploading": { "type": "array", "items": { "$ref": "#/definitions/forwardfile-status" } },
"success" : { "type": "array", "items": { "$ref": "#/definitions/forwardfile-status" } },
"failed" : { "type": "array", "items": { "$ref": "#/definitions/forwardfile-status" } }
},
"required": ["all_forwarded","pending","uploading","success","failed"],
"definitions": {
"forwardfile-status" : JSON_SCHEMA_GET_FWD_FILE_STATUS
}
}
JSON_SCHEMA_GET_COURRIER_OUT = {
"$schema": "http://json-schema.org/draft-07/schema#",
"title" : "Response of a 'courrier' from an openADS 'dossier'",
"type" : "object",
"properties": {
"courrier": JSON_SCHEMA_FILE_B64
},
"required": ["courrier"]
}
if __name__ == '__main__':
fmt = '\n----- %s -----\n%s\n'
print(fmt % ('JSON_SCHEMA_CHECK_STATUS_OUT' , json.dumps(JSON_SCHEMA_CHECK_STATUS_OUT)))
print(fmt % ('JSON_SCHEMA_CREATE_DOSSIER_IN' , json.dumps(JSON_SCHEMA_CREATE_DOSSIER_IN)))
print(fmt % ('JSON_SCHEMA_CREATE_DOSSIER_OUT' , json.dumps(JSON_SCHEMA_CREATE_DOSSIER_OUT)))
print(fmt % ('JSON_SCHEMA_GET_DOSSIER_OUT' , json.dumps(JSON_SCHEMA_GET_DOSSIER_OUT)))
print(fmt % ('JSON_SCHEMA_GET_FWD_FILES_OUT' , json.dumps(JSON_SCHEMA_GET_FWD_FILES_OUT)))
print(fmt % ('JSON_SCHEMA_GET_FWD_FILES_STATUS_OUT',
json.dumps(JSON_SCHEMA_GET_FWD_FILES_STATUS_OUT)))
print(fmt % ('JSON_SCHEMA_GET_COURRIER_OUT' , json.dumps(JSON_SCHEMA_GET_COURRIER_OUT)))

View File

@ -1,9 +1,9 @@
# -*- coding: utf-8 -*-
# Generated by Django 1.11.15 on 2019-07-11 16:20
# Generated by Django 1.11.15 on 2019-07-18 15:52
from __future__ import unicode_literals
from django.db import migrations, models
import atreal_openads.models
from django.db import migrations, models
class Migration(migrations.Migration):
@ -22,9 +22,13 @@ class Migration(migrations.Migration):
('title', models.CharField(max_length=50, verbose_name='Title')),
('description', models.TextField(verbose_name='Description')),
('slug', models.SlugField(unique=True, verbose_name='Identifier')),
('basic_auth_username', models.CharField(blank=True, max_length=128, verbose_name='Basic authentication username')),
('basic_auth_password', models.CharField(blank=True, max_length=128, verbose_name='Basic authentication password')),
('client_certificate', models.FileField(blank=True, null=True, upload_to=b'', verbose_name='TLS client certificate')),
('trusted_certificate_authorities', models.FileField(blank=True, null=True, upload_to=b'', verbose_name='TLS trusted CAs')),
('verify_cert', models.BooleanField(default=True, verbose_name='TLS verify certificates')),
('http_proxy', models.CharField(blank=True, max_length=128, verbose_name='HTTP and HTTPS proxy')),
('collectivite', models.CharField(blank=True, default=b'', help_text='ex: Marseille, or ex: 3', max_length=255, verbose_name='Collectivity (identifier)')),
('openADS_API_login', models.CharField(default=b'', help_text='ex: user1234', max_length=255, verbose_name='openADS API login')),
('openADS_API_password', models.CharField(default=b'', help_text='ex: ah9pGbKKHv5ToF3cPQuV', max_length=255, verbose_name='openADS API password')),
('openADS_API_url', models.URLField(default=b'', help_text='ex: https://openads.your_domain.net/api/', max_length=255, verbose_name='openADS API URL')),
('extra_debug', models.BooleanField(default=0, help_text='ex: True', verbose_name='Extra debug')),
('users', models.ManyToManyField(blank=True, related_name='_atrealopenads_users_+', related_query_name='+', to='base.ApiUser')),
@ -44,6 +48,7 @@ class Migration(migrations.Migration):
('orig_filename', models.CharField(blank=True, default=b'', max_length=100)),
('content_type', models.CharField(blank=True, default=b'', max_length=100)),
('upload_file', models.FileField(null=True, upload_to=atreal_openads.models.get_upload_path)),
('upload_attempt', models.PositiveIntegerField(blank=True, default=0)),
('upload_status', models.CharField(blank=True, default=b'', max_length=10)),
('upload_msg', models.CharField(blank=True, default=b'', max_length=255)),
('last_update_datetime', models.DateTimeField(auto_now=True)),

View File

@ -35,10 +35,20 @@ from django.utils.translation import ugettext_lazy as _
from django.core.files import File
from django.core.files.base import ContentFile
from passerelle.base.models import BaseResource
from passerelle.base.models import BaseResource, HTTPResource
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
from .json_schemas import (
JSON_SCHEMA_CHECK_STATUS_OUT,
JSON_SCHEMA_CREATE_DOSSIER_IN,
JSON_SCHEMA_CREATE_DOSSIER_OUT,
JSON_SCHEMA_GET_DOSSIER_OUT,
JSON_SCHEMA_GET_FWD_FILES_OUT,
JSON_SCHEMA_GET_FWD_FILES_STATUS_OUT,
JSON_SCHEMA_GET_COURRIER_OUT
)
class MLStripper(HTMLParser):
"""HTML parser that removes html tags."""
@ -95,6 +105,71 @@ def get_upload_path(instance, filename):
)
def dict_deref_multi(data, keys):
"""Access a dict through a list of keys.
Inspired by: https://stackoverflow.com/a/47969823
"""
if keys:
key = int(keys[0]) if isinstance(data, list) else keys[0]
return dict_deref_multi(data[key], keys[1:]) if keys else data
# TODO implement wildcard filtering
def dict_replace_content_by_paths(dic, paths_to_replace=[], path_separator='/', replace_by='<file content>'):
"""Replace a dict content, with a string, from a list of paths."""
newdic = dic
if newdic and (isinstance(newdic, dict) or isinstance(newdic, list)):
for p in paths_to_replace:
if p:
if p.startswith(path_separator):
p = p[1:]
items = p.split(path_separator)
if items:
try:
value = dict_deref_multi(newdic, items)
except KeyError:
continue
except TypeError:
continue
if not isinstance(value, str) and not isinstance(value, unicode):
raise ValueError("path '%s' is not a string ([%s] %s)" % (p, str(type(value)), str(value)))
# newdic is still untouched
if newdic is dic:
# create a copy of it
newdic = copy.deepcopy(dic)
last_item = items.pop(-1)
value = dict_deref_multi(newdic, items)
value[last_item] = replace_by
return newdic
class LogJsonPayloadWithFileContent(object):
""" Return a string representation of a JSON payload with its file content emptyed."""
def __init__(self, payload, paths_to_replace=[], path_separator='/', replace_by='<data>'):
""" arguments:
- payload string the JSON payload to dump
- paths_to_replace array a list of strings either:
* in 'path' format (i.e.: ['/fields/file/content',...])
* in 'namespace' format (i.e.: ['fields.file.content',...])
- path_separator string the path separator used in 'paths_to_replace' format:
* '/' for 'path' format
* '.' for 'namespace' format
- replace_by string the new value of the content at the specified path
"""
self.payload = payload
self.paths_to_replace = paths_to_replace
self.path_separator = path_separator
self.replace_by = replace_by
def __str__(self):
return json.dumps(dict_replace_content_by_paths(
self.payload,
paths_to_replace = self.paths_to_replace,
path_separator = self.path_separator,
replace_by = self.replace_by
))
class ForwardFile(models.Model):
"""Represent a file uploaded by a user, to be forwarded to openADS.API."""
numero_demande = models.CharField(max_length=20)
@ -104,20 +179,17 @@ class ForwardFile(models.Model):
orig_filename = models.CharField(max_length=100, default='', blank=True)
content_type = models.CharField(max_length=100, default='', blank=True)
upload_file = models.FileField(upload_to=get_upload_path, null=True)
upload_attempt = models.PositiveIntegerField(default=0, blank=True)
upload_status = models.CharField(max_length=10, default='', blank=True)
upload_msg = models.CharField(max_length=255, default='', blank=True)
last_update_datetime = models.DateTimeField(auto_now=True)
class AtrealOpenads(BaseResource):
class AtrealOpenads(BaseResource, HTTPResource):
"""API that proxy/relay communications with/to openADS."""
collectivite = models.CharField(_('Collectivity (identifier)'), max_length=255,
help_text=_('ex: Marseille, or ex: 3'), default='', blank=True)
openADS_API_login = models.CharField(_('openADS API login'), max_length=255,
help_text=_('ex: user1234'), default='')
openADS_API_password = models.CharField(_('openADS API password'), max_length=255,
help_text=_('ex: ah9pGbKKHv5ToF3cPQuV'), default='')
openADS_API_url = models.URLField(_('openADS API URL'), max_length=255,
help_text=_('ex: https://openads.your_domain.net/api/'), default='')
extra_debug = models.BooleanField(_('Extra debug'),
@ -127,9 +199,7 @@ class AtrealOpenads(BaseResource):
category = _('Business Process Connectors')
api_description = _('''
This API provides exchanges with openADS.
''')
api_description = _('''This API provides exchanges with openADS.''')
class Meta:
verbose_name = _('openADS')
@ -147,41 +217,165 @@ class AtrealOpenads(BaseResource):
self.logger.debug(*args, **kwargs)
def check_status(self):
def log_json_payload(self, payload, title='payload', paths_to_replace=[]):
"""Log a json paylod surrounded by dashes and with file content filtered."""
self.debug("----- %s (begining) -----", title)
if paths_to_replace:
self.debug("%s", LogJsonPayloadWithFileContent(
payload,
paths_to_replace = paths_to_replace,
path_separator = '.',
replace_by = '<b64 content>'
))
else:
self.debug("%s", LogJsonPayloadWithFileContent(payload))
self.debug("----- %s (end) -----", title)
def get_files_from_json_payload(self, payload, title='payload'):
"""Return files from a JSON payload with all checks and logging."""
# check the 'files' key
if 'files' not in payload:
self.log_json_payload(payload, title)
raise APIError("Expecting '%s' key in JSON %s" %
('files', title))
files = payload.get('files')
if not isinstance(files, list):
self.log_json_payload(payload, title)
raise APIError(
"Expecting '%s' value in JSON %s to be a %s (not a %s)" %
('files', title, 'list', str(type(files))))
if len(files) <= 0:
self.log_json_payload(payload, title)
raise APIError("Expecting non-empty '%s' value in JSON %s" %
('files', title))
# log the response
self.log_json_payload(
payload, title
,['files.%i.b64_content' % i for i in range(0, len(files))])
# return the files
return files
def check_file_dict(self, dict_file, b64=True, title='payload'):
"""Ensure a file dict has all its required items."""
# key to get the content
content_key = 'content'
# if content is in base 64
if b64:
content_key = 'b64_content'
# check content existence
if content_key not in dict_file:
raise APIError("Expecting 'files.0.%s' key in JSON %s" % (content_key, title))
# get its content
file_content = dict_file[content_key]
if not isinstance(file_content, str) and not isinstance(file_content, unicode):
raise APIError(
"Expecting '%s' value in JSON %s to be a %s (not a %s)" %
('file.%s' % content_key, title, 'string', str(type(file_content))))
# check filename
if (
'filename' in dict_file
and not isinstance(dict_file['filename'], str)
and not isinstance(dict_file['filename'], unicode)
):
raise APIError(
"Expecting '%s' value in file dict to be a %s (not a %s)" %
('file.filename', title, 'string', str(type(dict_file['filename']))))
def get_first_file_from_json_payload(self, payload, title='payload', ensure_content=True, b64=True):
"""Return the first file from a JSON payload with all checks and logging."""
# get all files
files = self.get_files_from_json_payload(payload, title)
# get the first file
first = files[0]
# asked to check its content
if ensure_content:
self.check_file_dict(first, title, b64)
# return the first file
return first
@endpoint(
description=_("Test an openADS 'connexion'")
#~ get={
#~ 'description': _("Test an openADS 'connexion'"),
#~ 'response_body': {
#~ 'schema': {
#~ 'application/json': JSON_SCHEMA_CHECK_STATUS_OUT
#~ }
#~ }
#~ }
)
def check_status(self, *args, **kwargs):
"""Check avaibility of the openADS.API service."""
url = urlparse.urljoin(self.openADS_API_url, '__api__')
response = self.requests.get(url, auth=(self.openADS_API_login, self.openADS_API_password))
response = self.requests.get(url)
response.raise_for_status()
return {'response': response.status_code}
@endpoint(description="Test an openADS 'connexion'")
def test_openads_connexion(self, request):
return self.check_status()
@endpoint(
description="Create an openADS 'dossier'",
methods=['post'],
pattern='^(?P<type_dossier>\w+)/?$',
example_pattern='{type_dossier}/',
parameters={
'type_dossier': {'description': _("Type of 'dossier'"), 'example_value': 'DIA'}
})
},
post={'description': _("Create an openADS 'dossier'"),
'request_body': {
'schema': {
'application/json': JSON_SCHEMA_CREATE_DOSSIER_IN
}
},
#~ 'response_body': {
#~ 'schema': {
#~ 'application/json': JSON_SCHEMA_CREATE_DOSSIER_OUT
#~ }
#~ }
}
)
def create_dossier(self, request, type_dossier, *args, **kwargs):
self.debug("----- request json (begining) -----")
json_data = json.loads(request.body)
debug_json = copy.deepcopy(json_data)
file_keys = ['cerfa'] + ['plan_cadastral_%s' % i for i in range(1,5)]
for k in file_keys:
if k in debug_json['fields'] \
and debug_json['fields'][k] \
and isinstance(debug_json['fields'][k], dict) \
and 'content' in debug_json['fields'][k]:
debug_json['fields'][k]['content'] = '<b64 content>'
self.debug(json.dumps(debug_json))
self.debug("----- request json (end) -----")
# loads the request body as JSON content
json_data = json.loads(request.body)
# every field key that might contain a file content
file_keys = ['cerfa'] + ['plan_cadastral_%s' % i for i in range(1,5)]
# detect if there evolutions data to filter them out of logging
# TODO replace when wildcard filtering is implement in 'dict_replace_content_by_paths()'
evolution_to_filter = []
if 'evolution' in json_data and isinstance(json_data['evolution'], list):
for e, evol in enumerate(json_data['evolution']):
if isinstance(evol, dict) and 'parts' in evol and isinstance(evol['parts'], list):
for p, part in enumerate(evol['parts']):
if isinstance(part, dict) and 'data' in part:
evolution_to_filter.append('evolution.%s.parts.%s.data' % (e, p))
# log the request body (filtering the files content)
self.log_json_payload(
json_data, 'request',
['fields.%s.content' % k for k in file_keys] + evolution_to_filter)
# build the payload
payload = { "collectivite": self.collectivite }
payload["terrain"] = {
@ -229,22 +423,23 @@ class AtrealOpenads(BaseResource):
payload["demandeurs"] = [demandeur]
self.debug("----- payload (begining) -----")
self.debug(json.dumps(payload))
self.debug("----- payload (end) -----")
# log the payload
self.log_json_payload(payload)
# prepare files that will be forwarded
files = []
file_keys = ['cerfa'] + ['plan_cadastral_%s' % i for i in range(1,5)]
for k in file_keys:
if k in json_data['fields'] \
and json_data['fields'][k] \
and isinstance(json_data['fields'][k], dict) \
and 'content' in json_data['fields'][k]:
if (
k in json_data['fields']
and json_data['fields'][k]
and isinstance(json_data['fields'][k], dict)
and 'content' in json_data['fields'][k]
):
content = base64.b64decode(json_data['fields'][k]['content'])
content_type = magic.from_buffer(content, mime=True)
upload_file = ContentFile(content)
file_hash = self.file_digest(upload_file)
filename = file_hash + '.pdf'
filename = file_hash[45:] + '.pdf'
if 'content_type' in json_data['fields'][k]:
content_type = json_data['fields'][k]['content_type']
if k == 'cerfa' and content_type != 'application/pdf':
@ -259,49 +454,40 @@ class AtrealOpenads(BaseResource):
'upload_file' : upload_file
})
# log files to be forwarded
self.debug("----- files (begining) -----")
self.debug(files)
self.debug("----- files (end) -----")
# make a request to openADS.API (with the payload)
url = urlparse.urljoin(self.openADS_API_url, '/dossiers/%s' % type_dossier)
response = self.requests.post(
url,
json=payload,
auth=(self.openADS_API_login, self.openADS_API_password),
timeout=self.openADS_API_timeout
)
# response is an error code
if response.status_code // 100 != 2:
error = self.get_response_error(response)
self.logger.warning("Request [POST] '%s' failed with error: '%s'", url, error)
raise APIError(error)
# load the response JSON content
try:
result = response.json()
except ValueError:
raise APIError('No JSON content returned: %r' % response.content[:1000])
self.debug("----- response json (begining) -----")
debug_json = copy.deepcopy(result)
if 'files' in debug_json \
and debug_json['files'] \
and isinstance(debug_json['files'], list) \
and len(debug_json['files']) > 0 \
and isinstance(debug_json['files'][0], dict) \
and 'b64_content' in debug_json['files'][0]:
debug_json['files'][0]['b64_content'] = '<b64 content>'
self.debug(json.dumps(debug_json))
self.debug("----- response json (end) -----")
# get the recepisse
recepisse = self.get_first_file_from_json_payload(result, title='response')
numero_dossier = result.get('numero_dossier')
self.debug("Numéro dossier: %s", str(numero_dossier))
recepisse = result['files'][0]
try:
recepisse_content = base64.b64decode(recepisse['b64_content'])
except TypeError:
raise APIError('Invalid content for recepisse')
self.debug("Successfully decoded recepisse from base64")
if recepisse['content_type'] and recepisse['content_type'] != 'application/pdf':
# ensure recepisse content type is PDF
if (
'content_type' in recepisse
and recepisse['content_type']
and recepisse['content_type'] != 'application/pdf'
):
self.debug(
"Forcing 'recepisse' content type to '%s' instead of '%s'.",
'application/pdf',
@ -309,6 +495,27 @@ class AtrealOpenads(BaseResource):
)
recepisse['content_type'] = 'application/pdf'
# decode the recepisse from base 64
try:
recepisse_content = base64.b64decode(recepisse['b64_content'])
except TypeError:
raise APIError('Failed to decode recepisse content from base 64')
self.debug("Successfully decoded recepisse from base 64")
# check/get the 'numero_dossier'
if 'numero_dossier' not in result:
raise APIError("Expecting 'numero_dossier' key in JSON response")
numero_dossier = result.get('numero_dossier')
if not isinstance(numero_dossier, str) and not isinstance(numero_dossier, unicode):
raise APIError(
"Expecting '%s' value in JSON response to be a %s (not a %s)" %
('numero_dossier', 'string', str(type(numero_dossier))))
self.debug("Numéro dossier: %s", str(numero_dossier))
# save files to be forwarded to openADS.API
if files:
file_ids = []
for f in files:
@ -342,6 +549,7 @@ class AtrealOpenads(BaseResource):
','.join([str(fid) for fid in file_ids])
)
# respond with the 'numero_dossier' and the recepisse file
return {
'numero_dossier': numero_dossier,
'recepisse' : recepisse
@ -349,29 +557,44 @@ class AtrealOpenads(BaseResource):
@endpoint(
description="Get informations about an openADS 'dossier'",
description=_("Get informations about an openADS 'dossier'"),
pattern='^(?P<type_dossier>\w+)/?$',
example_pattern='{type_dossier}/',
parameters={
'type_dossier' : {'description': _("Type of 'dossier'") , 'example_value': 'DIA'},
'numero_dossier': {'description': _("Identifier for 'dossier'"), 'example_value': 'DIA0130551900001'}
})
},
#~ get={
#~ 'description': _("Get informations about an openADS 'dossier'"),
#~ 'response_body': {
#~ 'schema': {
#~ 'application/json': JSON_SCHEMA_GET_DOSSIER_OUT
#~ }
#~ }
#~ }
)
def get_dossier(self, request, type_dossier, numero_dossier, *args, **kwargs):
# make a request to openADS.API
url = urlparse.urljoin(self.openADS_API_url, '/dossier/%s/%s' % (type_dossier, numero_dossier))
response = self.requests.get(url, auth=(self.openADS_API_login, self.openADS_API_password))
response = self.requests.get(url)
# response is an error
if response.status_code // 100 != 2:
error = self.get_response_error(response)
self.logger.warning("Request [GET] '%s' failed with error: '%s'", url, error)
raise APIError(error)
# load the response as JSON
try:
result = response.json()
except ValueError:
raise APIError('No JSON content returned: %r' % response.content[:1000])
etat = result.get('etat')
date_depot = result.get('date_depot')
date_decision = result.get('date_decision')
decision = result.get('decision')
date_limite_instruction = result.get('date_limite_instruction')
# log the response
self.log_json_payload(result, 'response')
# return the response as-is
return response.json()
@ -394,105 +617,166 @@ class AtrealOpenads(BaseResource):
@endpoint(
description="Get informations about the forwarding of a user file to openADS",
methods=['get'],
description=_("Get informations about the forwarding of user files to openADS"),
parameters={
'numero_dossier': {'description': _("Identifier for 'dossier'"), 'example_value': 'DIA0130551900001'},
'fichier_id' : {'description': _("File identifier") , 'example_value': '78'},
'summary' : {'description': _("Summary (only)") , 'example_value': '1'}
})
def get_fwd_files_status(self, request, numero_dossier, fichier_id=None, summary=None, *args, **kwargs):
'fichier_id' : {'description': _("File identifier") , 'example_value': '78'}
},
#~ get={
#~ 'description': _("Get informations about the forwarding of user files to openADS"),
#~ 'response_body': {
#~ 'schema': {
#~ 'application/json': JSON_SCHEMA_GET_FWD_FILES_OUT
#~ }
#~ }
#~ }
)
def get_fwd_files(self, request, numero_dossier, fichier_id=None, *args, **kwargs):
payload = []
fwd_files = []
# search for all files matching the 'numero_dossier' number
if not fichier_id:
try:
fwd_files = ForwardFile.objects.filter(numero_dossier=numero_dossier)
except ForwardFile.DoesNotExist:
raise Http404("No file matches 'numero_dossier=%s'." % numero_dossier)
fwd_files = ForwardFile.objects.filter(numero_dossier=numero_dossier)
# search for a single file
elif fichier_id:
try:
fwd_file = ForwardFile.objects.get(id=fichier_id)
fichier_id = int(fichier_id)
except ValueError:
raise APIError('fichier_id must be an integer')
try:
fwd_files = [ForwardFile.objects.get(id=fichier_id)]
except ForwardFile.DoesNotExist:
raise Http404("No file matches 'numero_dossier=%s' and 'id=%s'." % (numero_dossier, fichier_id))
if fwd_file:
fwd_files.append(fwd_file)
if fwd_files:
summary_enabled = summary and len(summary) and summary == '1'
if summary_enabled:
summary_data = {
'all_forwarded': True,
'pending' : [],
'uploading' : [],
'success' : [],
'failed' : []
}
for fwd_file in fwd_files:
b64content = None
if fwd_file.upload_file:
b64content = base64.b64encode(fwd_file.upload_file.read())
payload.append({
'id' : fwd_file.id,
'numero_demande': fwd_file.numero_demande,
'numero_dossier': fwd_file.numero_dossier,
'type_fichier' : fwd_file.type_fichier,
'file_hash' : fwd_file.file_hash,
'orig_filename' : fwd_file.orig_filename,
'content_type' : fwd_file.content_type,
'upload_status' : fwd_file.upload_status,
'upload_msg' : fwd_file.upload_msg,
'b64_content' : b64content,
'last_update_datetime' : fwd_file.last_update_datetime
})
if summary_enabled:
status_msg = '[%s] %s => %s' % (fwd_file.id, fwd_file.orig_filename, fwd_file.upload_msg)
summary_data[fwd_file.upload_status].append(status_msg)
if fwd_file.upload_status != 'success':
summary_data['all_forwarded'] = False
if summary_enabled:
payload = summary_data
# append each file to the response payload
for fwd_file in fwd_files:
payload.append({
'id' : fwd_file.id,
'numero_demande': fwd_file.numero_demande,
'numero_dossier': fwd_file.numero_dossier,
'type_fichier' : fwd_file.type_fichier,
'file_hash' : fwd_file.file_hash,
'orig_filename' : fwd_file.orig_filename,
'content_type' : fwd_file.content_type,
'upload_status' : fwd_file.upload_status,
'upload_attempt': fwd_file.upload_attempt,
'upload_msg' : fwd_file.upload_msg,
'content_size' : fwd_file.upload_file.size if fwd_file.upload_file else 0,
'last_update_datetime' : fwd_file.last_update_datetime
})
# return the payload containing the list of files
return payload
@endpoint(
description="Get a 'courrier' from an openADS 'dossier'",
description=_("Get informations about the forwarding of a user file to openADS"),
parameters={
'numero_dossier': {'description': _("Identifier for 'dossier'"), 'example_value': 'DIA0130551900001'},
'fichier_id' : {'description': _("File identifier") , 'example_value': '78'}
},
#~ get={
#~ 'description': _("Get informations about the forwarding of a user file to openADS"),
#~ 'response_body': {
#~ 'schema': {
#~ 'application/json': JSON_SCHEMA_GET_FWD_FILES_STATUS_OUT
#~ }
#~ }
#~ }
)
def get_fwd_files_status(self, request, numero_dossier, fichier_id=None, *args, **kwargs):
# get all files matching 'numero_dossier' and 'fichier_id'
fwd_files = self.get_fwd_files(request, numero_dossier, fichier_id, *args, **kwargs)
# prepare the response payload
payload = {
'all_forwarded': True,
'pending' : [],
'uploading' : [],
'success' : [],
'failed' : []
}
# build a summary of all files statuses
for fwd_file in fwd_files:
status_msg = '[%s] %s => %s' % (
fwd_file['id'],
fwd_file['orig_filename'],
fwd_file['upload_msg']
)
payload[fwd_file['upload_status']].append(status_msg)
if fwd_file['upload_status'] != 'success':
payload['all_forwarded'] = False
# respond with the payload
return payload
@endpoint(
description= _("Get a 'courrier' from an openADS 'dossier'"),
pattern='^(?P<type_dossier>\w+)/?$',
example_pattern='{type_dossier}/',
parameters={
'type_dossier' : {'description': _("Type of 'dossier'") , 'example_value': 'DIA'},
'numero_dossier': {'description': _("Identifier for 'dossier'"), 'example_value': 'DIA0130551900001'}
})
},
#~ get={
#~ 'description': _("Get a 'courrier' from an openADS 'dossier'"),
#~ 'response_body': {
#~ 'schema': {
#~ 'application/json': JSON_SCHEMA_GET_COURRIER_OUT
#~ }
#~ }
#~ }
)
def get_courrier(self, request, type_dossier, numero_dossier, *args, **kwargs):
# make a request to openADS.API
url = urlparse.urljoin(
self.openADS_API_url,
'/dossier/%s/%s/courrier/%s' % (type_dossier, numero_dossier, 'dia_renonciation_preempter'))
response = self.requests.get(url, auth=(self.openADS_API_login, self.openADS_API_password))
response = self.requests.get(url)
# response is an error
if response.status_code // 100 != 2:
error = self.get_response_error(response)
self.logger.warning("Request [GET] '%s' failed with error: '%s'", url, error)
raise APIError(error)
# load the response as JSON
try:
result = response.json()
except ValueError:
raise APIError('No JSON content returned: %r' % response.content[:1000])
courrier = result['files'][0]
# log the response (filtering the file content)
self.log_json_payload(
result, 'response',
['files.%i.b64_content' % i for i in range(0, len(result['files']))])
# get the courrier
courrier = self.get_first_file_from_json_payload(result, title='response')
# decode the courrier from base 64
try:
courrier_content = base64.b64decode(courrier['b64_content'])
except TypeError:
raise APIError('Invalid content for courrier')
# return the 'courrier' file
return {'courrier': courrier}
def get_response_error(self, response):
"""Return a error string from an HTTP response."""
try:
# load the response as JSON
result = response.json()
# collect errors and turn them into messages (multispaces are filtered)
errors = result.get('errors')
msg = []
if errors:
@ -501,10 +785,18 @@ class AtrealOpenads(BaseResource):
name = error.get('name')
desc = error.get('description')
msg.append('[%s] (%s) %s' % (location, normalize(name), normalize(desc)))
# if there are messages
if msg:
# return a string representing the HTTP error
return "HTTP error: %s, %s" % (response.status_code, ','.join(msg))
except ValueError:
pass
# TODO ask for openADS.API to *always* send JSON formatted errors, not HTML ones
# return a string representing the HTTP error (filtering the HTML tags and multispaces)
return "HTTP error: %s, %s" % \
(response.status_code,
clean_spaces(strip_tags(response.content[:1000])) if response.content else '')
@ -512,14 +804,22 @@ class AtrealOpenads(BaseResource):
def upload_user_files(self, type_dossier, numero_dossier, file_ids):
"""A Job to forward user uploaded files to openADS."""
payload = []
fwd_files = []
# for every file ids specified (in parameters of this job)
for fid in file_ids:
self.debug("upload_user_files() ForwardFile file_id: %s", fid)
# get the matching forward file
fwd_file = ForwardFile.objects.get(id=fid)
# found one
if fwd_file:
self.debug("upload_user_files() got ForwardFile")
# add the file content and data to the payload
payload.append({
'filename' : fwd_file.orig_filename + ('.pdf' if fwd_file.orig_filename[-4:] != '.pdf' else ''),
'content_type' : fwd_file.content_type,
@ -527,72 +827,105 @@ class AtrealOpenads(BaseResource):
'file_type' : fwd_file.type_fichier
})
self.debug("upload_user_files() payload added")
fwd_file.upload_status = 'uploading'
if fwd_file.upload_msg and re.search(r'^attempt \d+$', fwd_file.upload_msg):
self.debug("upload_user_files() upload_msg: '%s'", fwd_file.upload_msg)
attempt_num = fwd_file.upload_msg.replace('attempt ', '').strip()
self.debug("upload_user_files() attempt_num: '%s'", attempt_num)
fwd_file.upload_msg = 'attempt %s' % attempt_num
else:
fwd_file.upload_msg = 'attempt 1'
self.debug("upload_user_files() ForwardFile ready to be saved")
fwd_file.save()
fwd_files.append(fwd_file)
else:
self.logger.warning("upload_user_files() failed to find ForwardFile file_id: %s", fid);
# update the file upload data (status and attempts)
fwd_file.upload_status = 'uploading'
fwd_file.upload_attempt += 1
fwd_file.upload_msg = 'attempt %s' % fwd_file.upload_attempt
self.debug("upload_user_files() upload_msg: '%s'", fwd_file.upload_msg)
fwd_file.save()
self.debug("upload_user_files() ForwardFile saved")
# append the forwarded file to the list
fwd_files.append(fwd_file)
# not found the forwarded file specified: bug
else:
self.logger.warning("upload_user_files() failed to find ForwardFile file_id: %s", fid)
# if files need to be forwarded
if payload:
self.debug("upload_user_files() payload is not empty")
debug_payload = copy.deepcopy(payload)
for p in debug_payload:
if 'b64_content' in p:
p['b64_content'] = '<b64 content>'
self.debug("upload_user_files() payload is: %s", str(debug_payload))
# log the payload
self.log_json_payload(
payload, 'payload',
['%i.b64_content' % i for i in range(0, len(fwd_files))])
# make the request to openADS.API (with a specific timeout)
url = urlparse.urljoin(self.openADS_API_url, '/dossier/%s/%s/files' % (type_dossier, numero_dossier))
response = self.requests.post(
url,
json=payload,
auth=(self.openADS_API_login, self.openADS_API_password),
timeout=self.openADS_API_timeout
)
# reponse is an error
if response.status_code // 100 != 2:
# update every files status as 'failed' and save the error message
for fwd_file in fwd_files:
fwd_file.upload_status = 'failed'
fwd_file.upload_msg = self.get_response_error(response)
fwd_file.save()
# log (warning) the error message
self.logger.warning(
"upload_user_files() openADS response is not OK (code: %s) for dossier '%s' and files '%s'",
response.status_code,
numero_dossier,
','.join(file_ids)
)
# response is not an error
else:
# load the reponse as JSON
try:
result = response.json()
# in case of failure
except ValueError:
# update every files status as 'failed' and save the error message
for fwd_file in fwd_files:
fwd_file.upload_status = 'failed'
fwd_file.upload_msg = 'No JSON content returned: %r' % response.content[:1000]
fwd_file.save()
# log (warning) the error message
self.logger.warning(
"upload_user_files() openADS response is not JSON valid for dossier '%s' and files '%s'",
numero_dossier,
','.join([f.id for f in fwd_files])
)
# response correctly loaded as JSON
else:
# TODO handle response (now its just an informational sentence in key 'data')
# update every files status as 'success' and save the success message
for fwd_file in fwd_files:
fwd_file.upload_status = 'success'
fwd_file.upload_msg = 'uploaded successfuly'
# delete file content (on success)
fpath = fwd_file.upload_file.path
fwd_file.upload_file.delete()
# save the file
fwd_file.save()
# log the success message
self.debug(
"upload_user_files() flaging file '%s' has transfered (deleted '%s')",
fwd_file.id,
fpath
)
# no file need to be forwarded
else:
self.logger.warning(
"upload_user_files() payload is empty for dossier '%s' and files '%s'",

View File

@ -54,9 +54,9 @@ def atreal_openads(db):
return AtrealOpenads.objects.create(
slug = CONNECTOR_SLUG,
collectivite = COLLECTIVITE,
openADS_API_login = OPENADS_API_LOGIN,
openADS_API_password = OPENADS_API_PASSWORD,
openADS_API_url = OPENADS_API_URL
openADS_API_url = OPENADS_API_URL,
basic_auth_username = OPENADS_API_LOGIN,
basic_auth_password = OPENADS_API_PASSWORD
)
@ -67,7 +67,7 @@ def test_openads_check_status(app, atreal_openads):
fake_resp = JsonResponse(fake_resp_json)
with mock.patch('passerelle.utils.Request.get') as requests_get:
requests_get.return_value = mock.Mock(content=fake_resp, status_code=200)
jresp = atreal_openads.check_status()
jresp = atreal_openads.check_status(None)
assert jresp['response'] == 200
@ -261,12 +261,12 @@ def test_openads_upload2ForwardFile(app, atreal_openads):
assert FF.upload_status == 'pending'
def test_openads_get_fwd_files_status(app, atreal_openads):
def test_openads_get_fwd_files(app, atreal_openads):
with pytest.raises(Http404) as e:
resp404 = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=18, summary=None)
atreal_openads.get_fwd_files(None, FAKE_NUMERO_DOSSIER, fichier_id=18)
assert re.search(r"^No file matches 'numero_dossier=[^']+' and 'id=[^']+'.$", str(e.value))
resp404 = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=None, summary=None)
resp404 = atreal_openads.get_fwd_files(None, FAKE_NUMERO_DOSSIER, fichier_id=None)
assert resp404 is not None
assert len(resp404) == 0
@ -274,25 +274,35 @@ def test_openads_get_fwd_files_status(app, atreal_openads):
FF.save()
assert isinstance(FF, ForwardFile)
jresp = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=None, summary=None)
jresp = atreal_openads.get_fwd_files(None, FAKE_NUMERO_DOSSIER, fichier_id=None)
assert jresp is not None
assert len(jresp) == 1
assert jresp[0]['id'] == FF.id
for k in ['numero_dossier', 'type_fichier', 'file_hash', 'orig_filename', 'content_type', 'upload_status', 'upload_msg']:
assert jresp[0][k] == getattr(FF, k)
assert jresp[0]['b64_content'] == get_file_data(FF.upload_file.path)
assert jresp[0]['content_size'] == len(get_file_data(FF.upload_file.path, b64=False))
assert jresp[0]['last_update_datetime'] == FF.last_update_datetime
jresp = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=FF.id, summary=None)
jresp = atreal_openads.get_fwd_files(None, FAKE_NUMERO_DOSSIER, fichier_id=FF.id)
assert jresp is not None
assert len(jresp) == 1
assert jresp[0]['id'] == FF.id
for k in ['numero_dossier', 'type_fichier', 'file_hash', 'orig_filename', 'content_type', 'upload_status', 'upload_msg']:
assert jresp[0][k] == getattr(FF, k)
assert jresp[0]['b64_content'] == get_file_data(FF.upload_file.path)
assert jresp[0]['content_size'] == len(get_file_data(FF.upload_file.path, b64=False))
assert jresp[0]['last_update_datetime'] == FF.last_update_datetime
jresp = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=None, summary='1')
def test_openads_get_fwd_files_status(app, atreal_openads):
with pytest.raises(Http404) as e:
atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=18)
assert re.search(r"^No file matches 'numero_dossier=[^']+' and 'id=[^']+'.$", str(e.value))
FF = atreal_openads.upload2ForwardFile(TEST_FILE_CERFA_DIA, FAKE_NUMERO_DOSSIER)
FF.save()
assert isinstance(FF, ForwardFile)
jresp = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=None)
assert jresp is not None
assert jresp['all_forwarded'] == False
status_msg = '[%s] %s => %s' % (FF.id, FF.orig_filename, FF.upload_msg)
@ -302,7 +312,7 @@ def test_openads_get_fwd_files_status(app, atreal_openads):
assert len(jresp['success']) == 0
assert len(jresp['failed']) == 0
jresp = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=FF.id, summary='1')
jresp = atreal_openads.get_fwd_files_status(None, FAKE_NUMERO_DOSSIER, fichier_id=FF.id)
assert jresp is not None
assert jresp['all_forwarded'] == False
status_msg = '[%s] %s => %s' % (FF.id, FF.orig_filename, FF.upload_msg)