éditeur de gabarit pour le remplissage de pdf (#74797) #122

Merged
bdauvergne merged 3 commits from wip/74797-connecteur-PDF-gabarit-de-xfdf-p into main 2023-03-02 17:29:58 +01:00
19 changed files with 920 additions and 254 deletions

4
debian/control vendored
View File

@ -13,7 +13,9 @@ Homepage: https://dev.entrouvert.org/projects/passerelle
Package: python3-passerelle
Architecture: all
Depends: pdftk,
Depends: ghostscript,
pdftk,
poppler-utils,
python3-cmislib,
python3-dateutil,
python3-distutils,

View File

@ -36,6 +36,11 @@ LOGGING['loggers']['paramiko.transport'] = {
'propagate': True,
}
# silence pdfrw
LOGGING['loggers']['pdfrw'] = {
'propagate': False,
}
exec(open('/etc/%s/settings.py' % PROJECT_NAME).read())
# run additional settings snippets

View File

@ -0,0 +1,63 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2020 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/>.
from django import forms
from django.utils.translation import gettext_lazy as _
from passerelle.utils.forms import ConditionField, TemplateField
from passerelle.utils.pdf import PDF
from . import models
class FieldsMappingEditForm(forms.ModelForm):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
if not self.instance.fill_form_file:
return
fields_mapping = self.instance.fields_mapping or {}
with self.instance.fill_form_file as fd:
pdf = PDF(fd)
for page in pdf.pages:
for i, field in enumerate(page.fields):
name = f'field_{field.digest_id}'
if field.widget_type == 'checkbox':
help_text = _('boolean expression')
field_class = ConditionField
elif field.widget_type == 'text':
help_text = _('text template')
field_class = TemplateField
else:
continue
Review

Pépin de i18n ici, il faut qu'on ait au moins la traduction de _('field')

Pépin de i18n ici, il faut qu'on ait au moins la traduction de _('field')
Review

il faut que ça corresponde à ce qu'il y a dans la miniature du PDF annotée avec pillow donc non.

il faut que ça corresponde à ce qu'il y a dans la miniature du PDF annotée avec pillow donc non.
label = _('field {number} ({help_text})').format(number=i + 1, help_text=help_text)
initial = fields_mapping.get(name, '')
self.fields[name] = field_class(label=label, required=False, initial=initial)
self.fields[name].page_number = page.page_number
self.fields[name].widget.attrs['tabindex'] = '0'
self.fields[name].widget.attrs['class'] = '0'
def save(self, commit=True):
fields_mapping = {}
for name in self.fields:
value = self.cleaned_data.get(name)
if value:
fields_mapping[name] = value
self.instance.fields_mapping = fields_mapping
return super().save(commit=commit)
class Meta:
model = models.Resource
fields = ()

View File

@ -18,11 +18,11 @@ class Migration(migrations.Migration):
name='fill_form_file',
field=models.FileField(
blank=True,
help_text='PDF file, used if not input-form in fill-form payload',
help_text='PDF file',
null=True,
upload_to=passerelle.utils.models.resource_file_upload_to,
validators=[passerelle.apps.pdf.models.validate_pdf],
verbose_name='Fill Form default input file',
verbose_name='Fill Form input file',
),
),
]

View File

@ -0,0 +1,19 @@
# Generated by Django 3.2.18 on 2023-03-01 16:48
from django.contrib.postgres.fields.jsonb import JSONField
from django.db import migrations
class Migration(migrations.Migration):
dependencies = [
('pdf', '0002_resource_fill_form_file'),
]
operations = [
migrations.AddField(
model_name='resource',
name='fields_mapping',
field=JSONField(null=True, verbose_name='Field mapping', blank=True),
),
]

View File

@ -18,10 +18,10 @@ import base64
import os
import subprocess
import tempfile
import xml.etree.ElementTree as ET
from collections import OrderedDict
from django.conf import settings
from django.contrib.postgres.fields.jsonb import JSONField
from django.core.exceptions import ValidationError
from django.db import models
from django.http.response import HttpResponse
@ -31,6 +31,8 @@ from passerelle.base.models import BaseResource
from passerelle.utils.api import endpoint
from passerelle.utils.jsonresponse import APIError
from passerelle.utils.models import resource_file_upload_to
from passerelle.utils.pdf import PDF
from passerelle.utils.templates import evaluate_condition, evaluate_template
PDF_FILE_OBJECT = {
'type': 'object',
@ -80,50 +82,36 @@ ASSEMBLE_SCHEMA = {
),
}
FILL_FORM_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'title': '',
'description': '',
'type': 'object',
'required': ['filename', 'fields'],
'unflatten': True,
'properties': OrderedDict(
{
'filename': {
'description': _('output PDF filename'),
'type': 'string',
},
'input-form': PDF_FILE_OBJECT,
'fields': {
'description': _('hierarchical dictionary of fields'),
'type': 'object',
},
}
),
}
def validate_pdf(fieldfile):
fieldfile.open()
if fieldfile.read(5) != b'%PDF-':
raise ValidationError(
_('%(value)s is not a PDF file'),
params={'value': fieldfile},
)
to_close = fieldfile.closed
try:
if fieldfile.read(5) != b'%PDF-':
raise ValidationError(
_('%(value)s is not a PDF file'),
params={'value': fieldfile},
)
finally:
if to_close:
fieldfile.close()
class Resource(BaseResource):
category = _('Misc')
fill_form_file = models.FileField(
_('Fill Form default input file'),
_('Fill Form input file'),
upload_to=resource_file_upload_to,
help_text=_('PDF file, used if not input-form in fill-form payload'),
help_text=_('PDF file'),
validators=[validate_pdf],
null=True,
blank=True,
)
fields_mapping = JSONField(verbose_name=_('Field mapping'), null=True, blank=True)
hide_description_fields = ['fields_mapping']
class Meta:
verbose_name = _('PDF')
@ -182,77 +170,86 @@ class Resource(BaseResource):
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
return response
FILL_FORM_SCHEMA = {
'$schema': 'http://json-schema.org/draft-04/schema#',
'title': '',
'description': _('content of the form to map on PDF fields'),
'unflatten': True,
'type': 'object',
'properties': OrderedDict(
{
'extra': {
'type': 'object',
'properties': OrderedDict(
{
'filename': {
'type': 'string',
'description': _('file name'),
},
'flatten': {
'description': _('remove PDF fields, keep only the drawed values'),
'type': 'boolean',
},
}
),
}
}
),
}
@endpoint(
name='fill-form',
description=_('Fills the input PDF form with fields'),
description=_('Fills the input PDF form with fields applying mappings to the received payload'),
perm='can_access',
methods=['post'],
parameters={
'filename': {'description': _('file name')},
'flatten': {'description': _('remove PDF fields, keep only the drawed values')},
},
post={
'request_body': {'schema': {'application/json': FILL_FORM_SCHEMA}},
'input_example': {
'filename': 'filled.pdf',
'fields/Page1[0]/FirstName[0]': 'John',
'fields/Page1[0]/LastName[0]': 'Doe',
'fields/Page2[0]/Checkbox[0]': '0',
'fields/Page2[0]/Checkbox[1]': '1',
'extra': {
'filename': 'filled.pdf',
'flatten': True,
},
'prenom': 'Jean',
'nom': 'Dupont',
},
},
)
def fill_form(self, request, post_data):
filename = post_data.pop('filename')
fields = post_data.pop('fields')
def fill_form(self, request, post_data, flatten=None, filename=None):
extra = post_data.pop('extra', {})
filename = filename or extra.get('filename') or post_data.get('filename') or 'form.pdf'
flatten_pdf = str(flatten or extra.get('flatten') or post_data.get('flatten')).lower() in (
'1',
'on',
'yes',
'true',
)
xfdf_root = ET.Element('xfdf')
xfdf_root.attrib['xmlns'] = 'http://ns.adobe.com/xfdf/'
xfdf_root.attrib['xml:space'] = 'preserve'
xfdf_f = ET.SubElement(xfdf_root, 'f')
xfdf_fields = ET.SubElement(xfdf_root, 'fields')
def add_fields(element, fields):
if isinstance(fields, dict):
for key in fields:
field = ET.SubElement(element, 'field')
field.attrib['name'] = key
add_fields(field, fields[key])
else:
value = ET.SubElement(element, 'value')
value.text = str(fields)
add_fields(xfdf_fields, fields)
with tempfile.TemporaryDirectory(prefix='passerelle-pdftk-%s-fill-form-' % self.id) as tmpdir:
if isinstance(post_data.get('input-form'), dict) and post_data['input-form'].get('content'):
input_filename = os.path.join(tmpdir, 'input-form.pdf')
with open(input_filename, mode='wb') as fd:
fd.write(base64.b64decode(post_data['input-form']['content']))
elif self.fill_form_file:
input_filename = self.fill_form_file.path
else:
raise APIError("missing or bad 'input-form' property", http_status=400)
# create xfdf
xfdf_filename = os.path.join(tmpdir, 'fields.xfdf')
xfdf_f.attrib['href'] = input_filename
with open(xfdf_filename, mode='wb') as fd:
ET.indent(xfdf_root)
ET.ElementTree(xfdf_root).write(fd, encoding='UTF-8', xml_declaration=True)
# call pdftk fill_form
pdf_content = self.run_pdftk(args=[input_filename, 'fill_form', xfdf_filename])
response = HttpResponse(pdf_content, content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
return response
def pdftk_dump_data_fields_utf8(self):
if not self.fill_form_file:
return
try:
dump = self.run_pdftk(args=[self.fill_form_file.path, 'dump_data_fields_utf8']).decode()
except APIError as apierror:
return 'Error: %r' % apierror
unflatten_separated = ''
for line in dump.splitlines():
unflatten_separated += '<br>%s' % line
if line.startswith('FieldName: '):
unflatten_separated += ' → <b>fields/%s</b>' % line[11:].replace('.', '/')
return unflatten_separated
raise APIError('not PDF file configured')
fields_mapping = self.fields_mapping
if not fields_mapping:
raise APIError('no fields mapping configured')
with self.fill_form_file.open() as fd:
pdf = PDF(fd)
for page in pdf.pages:
for field in page.fields:
mapping_template = fields_mapping.get(f'field_{field.digest_id}')
if not mapping_template:
continue
if field.widget_type == 'checkbox':
value = evaluate_condition(mapping_template, post_data)
elif field.widget_type == 'text':
value = evaluate_template(mapping_template, post_data)
else:
raise NotImplementedError
if value is not None:
field.set(value)
response = HttpResponse(content_type='application/pdf')
response['Content-Disposition'] = 'attachment; filename="%s"' % filename
pdf.write(response, flatten=flatten_pdf)
return response

View File

@ -0,0 +1,74 @@
{% extends "passerelle/manage/resource_child_base.html" %}
{% load i18n gadjo %}
{% block breadcrumb %}
<a href="{% url 'manage-home' %}">{% trans 'Web Services' %}</a>
<a href="{{ object.get_absolute_url }}">PDF &mdash; {{ object.title }}</a>
<a href="#">{% trans "Edit fields mapping" %}</a>
{% endblock %}
{% block appbar %}
<h2>
{% trans "Edit fields mapping" %}
</h2>
{% endblock %}
{% block content %}
<form method="post" enctype="multipart/form-data" class="pdf-fields-mapping-edit-form">
{% csrf_token %}
<div class="buttons pdf-fields-mapping-edit-form--buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{{ object.get_absolute_url }}">{% trans 'Cancel' %}</a>
</div>
{% if form.errors %}
<div class="errornotice" tabindex="-1" autofocus>
<p>{% trans "There were errors processing your form." %}</p>
{% for error in form.non_field_errors %}
<p>{{ error }}</p>
{% endfor %}
{% for field in form %}
{% if field.is_hidden and field.errors %}
<p>
{% for error in field.errors %}
{% blocktrans with name=field.name %}(Hidden field {{name}}) {{ error }}{% endblocktrans %}
{% if not forloop.last %}<br>{% endif %}
{% endfor %}
</p>
{% endif %}
{% endfor %}
</div>
{% endif %}
{% for page_number, image_map in pages %}
<h3>{% blocktrans with number=page_number|add:1 %}Page {{number}}{% endblocktrans %}</h3>
<div class="pdf-fields-mapping-edit-form--page">
<div class="pdf-fields-mapping-edit-form--thumbnail">
<div>
<map name="map-page-{{ page_number }}">
{{ image_map|safe }}
</map>
<img src="{% url "pdf-page-thumbnail" connector="pdf" slug=object.slug page_number=page_number %}" usemap="#map-page-{{ page_number }}">
</div>
</div>
<div class="pdf-fields-mapping-edit-form--fields">
{% for field in form %}
{% if field.field.page_number == page_number %}
{% include "gadjo/widget.html" with field=field %}
{% endif %}
{% endfor %}
</div>
</div>
{% endfor %}
<div class="buttons pdf-fields-mapping-edit-form--buttons">
<button class="submit-button">{% trans "Save" %}</button>
<a class="cancel" href="{{ object.get_absolute_url }}">{% trans 'Cancel' %}</a>
</div>
</form>
Review

Autre petit manque sur l'i18n ici, il faudrait plutôt

<h3>{% blocktrans with number=page_number|add:1 %}Page {{number}}{% endblocktrans %}</h3>

Autre petit manque sur l'i18n ici, il faudrait plutôt `<h3>{% blocktrans with number=page_number|add:1 %}Page {{number}}{% endblocktrans %}</h3>`
Review

ok c'est fait.

ok c'est fait.
<script>
$(document).on('click', 'area', function (event) {
var $target = $(event.target);
var href = $target.attr('href');
$('.pdf-fields-mapping-edit-form--fields').scrollTop = $(href).offsetTop;
$(href + ' input').focus();
})
</script>
{% endblock %}

View File

@ -1,20 +1,8 @@
{% extends "passerelle/manage/service_view.html" %}
{% load i18n passerelle %}
{% block extra-tab-buttons %}
{% if user.is_staff and object.fill_form_file %}
<button role="tab" aria-selected="false" aria-controls="panel-dumpfields" id="tab-dumpfields"
tabindex="-1">{% trans "Fill Form default PDF Fields" %}</button>
{% endif %}
{% endblock %}
{% block extra-tab-panels %}
{% if user.is_staff and object.fill_form_file %}
<div id="panel-dumpfields" role="tabpanel" tabindex="-1" aria-labelledby="tab-dumpfields" hidden>
<div>
<p>{% blocktrans with file=object.fill_form_file %}PDFtk {{ file }} dump_data_fields_utf8 output{% endblocktrans %}</p>
<p>{{ object.pdftk_dump_data_fields_utf8|safe }}</p>
</div>
</div>
{% block actions %}
{% if object|can_edit:request.user %}
<a href="{% url 'pdf-fields-mapping-edit' connector='pdf' slug=object.slug %}">{% trans 'Fill form: Edit fields mapping' %}</a>
Review

Peut-être nommer le bouton "Fill Form: Edit field mapping" pour préciser que c'est un bouton lié au système de remplissage, on traduira par "Formulaire : configuration des champs"

Peut-être nommer le bouton "Fill Form: Edit field mapping" pour préciser que c'est un bouton lié au système de remplissage, on traduira par "Formulaire : configuration des champs"
{% endif %}
{% endblock %}

View File

@ -0,0 +1,32 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2019 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/>.
from django.urls import re_path
from . import views
management_urlpatterns = [
re_path(
r'^(?P<slug>[\w,-]+)/fields-mapping/edit/$',
views.FieldsMappingEditView.as_view(),
name='pdf-fields-mapping-edit',
),
re_path(
r'^(?P<slug>[\w,-]+)/page/(?P<page_number>[0-9]+)/$',
views.PageThumbnailView.as_view(),
name='pdf-page-thumbnail',
),
]

View File

@ -0,0 +1,94 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2019 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 hashlib
import io
import PIL.Image
import PIL.ImageDraw
from django.http import Http404, HttpResponse, HttpResponseNotModified
from django.utils.translation import gettext_lazy as _
from django.views.generic import UpdateView
from passerelle.base.views import ResourceView
from passerelle.utils.pdf import PDF
from . import forms, models
class FieldsMappingEditView(ResourceView, UpdateView):
template_name = 'pdf/fields_mapping_edit.html'
model = models.Resource
form_class = forms.FieldsMappingEditForm
def get_context_data(self, **kwargs):
context_data = super().get_context_data(**kwargs)
resource = self.get_object()
with resource.fill_form_file as fd:
pdf = PDF(fd)
pages = []
for page in pdf.pages:
pages.append((page.page_number, page.fields_image_map(id_prefix='id_field_', id_suffix='_p')))
context_data['pages'] = pages
return context_data
def get_success_url(self):
return super().get_success_url() + '#'
Review
Cette partie n'est absolument pas couverte par les tests : https://jenkins.entrouvert.org/job/gitea/job/passerelle/job/wip%252F74797-connecteur-PDF-gabarit-de-xfdf-p/15/Coverage_20Report_20_28native_29/d_1f676f23cbeaa486_views_py.html
Review

J'ai rajouté la couverture de ce code dans le test.

J'ai rajouté la couverture de ce code dans le test.
class PageThumbnailView(ResourceView):
model = models.Resource
def make_thumbnail(self, page):
# produce a thumbnail and add
# * red rectangle over field's rectangles
# * enumerated field names
thumbnail = page.thumbnail_png()
image = PIL.Image.open(io.BytesIO(thumbnail))
draw = PIL.ImageDraw.Draw(image, 'RGBA')
for i, (field, area_rect) in enumerate(page.thumbnail_field_rects()):
draw.rectangle(area_rect, fill=(255, 0, 0, 50))
x = area_rect.x1
y = (area_rect.y1 + area_rect.y2) / 2 - 5
if field.widget_type == 'checkbox':
Review

i18n ici encore, un _('field') qui manque (idéalement un _(''field %d) mais à toi de voir)

i18n ici encore, un `_('field')` qui manque (idéalement un `_(''field %d)` mais à toi de voir)
Review

Avec la police non vectorielle par défaut de pillow je ne sais pas si on peut avoir de l'UTF-8 (je vois bien qu'on traduirait par "champ {i + 1}" mais je ne suis pas convaincu de l'utilité ici de traduire, ça reste un écran technique ou personne n'ira à part un admin fonctionnel.

Avec la police non vectorielle par défaut de pillow je ne sais pas si on peut avoir de l'UTF-8 (je vois bien qu'on traduirait par "champ {i + 1}" mais je ne suis pas convaincu de l'utilité ici de traduire, ça reste un écran technique ou personne n'ira à part un admin fonctionnel.
y -= 10
draw.text((x, y), str(_('field %s') % (i + 1)), anchor='lb', fill=(0, 0, 0, 255))
del draw
output = io.BytesIO()
image.save(output, 'PNG')
return output.getvalue()
def get(self, request, page_number, **kwargs):
with self.get_object().fill_form_file as fd:
pdf_content = fd.read()
etag = hashlib.md5(pdf_content).hexdigest()
if_none_match = request.headers.get('If-None-Match', '').split(',')
if etag in if_none_match:
# use browser cache
response = HttpResponseNotModified()
else:
# produce the thumbnail
pdf = PDF(pdf_content)
try:
page = pdf.page(int(page_number))
except IndexError:
raise Http404
thumbnail_content = self.make_thumbnail(page)
response = HttpResponse(thumbnail_content, content_type='image/png')
response['ETag'] = etag
response['Cache-Control'] = 'max-age=3600'
return response

View File

@ -407,3 +407,31 @@ ul.get-params li {
min-height: 200px;
}
}
/* passerelle/apps/pdf/templates/pdf/field_mapping_edit.html */
.pdf-fields-mapping-edit-form--page {
display: flex;
height: max-content;
}
.pdf-fields-mapping-edit-form--fields {
height: 1132px;
overflow-y: scroll;
> .widget textarea, > .widget input {
width: 100%;
}
.widget:target {
border: 2px dashed #FFAAAA;
}
}
.pdf-fields-mapping-edit-form--thumbnail {
position: sticky;
top: 0;
}
.pdf-fields-mapping-edit-form--thumbnail, .pdf-fields-mapping-edit-form--fields {
margin-right: 1em;
flex: 1;
}

View File

@ -15,8 +15,34 @@
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from django import forms
from django.core import validators
from django.core import exceptions, validators
from django.template import Template, TemplateSyntaxError
from django.utils.translation import gettext_lazy as _
class LDAPURLField(forms.URLField):
default_validators = [validators.URLValidator(schemes=['ldap', 'ldaps'])]
def validate_condition_template(condition_template):
real_template = f'{{% if {condition_template} %}}OK{{% endif %}}'
try:
Template(real_template)
except (TemplateSyntaxError, OverflowError) as e:
raise exceptions.ValidationError(_('syntax error: %s') % e, code='syntax-error')
class ConditionField(forms.CharField):
default_validators = [validate_condition_template]
def validate_template(template):
try:
Template(template)
except (TemplateSyntaxError, OverflowError) as e:
raise exceptions.ValidationError(_('syntax error: %s') % e, code='syntax-error')
class TemplateField(forms.CharField):
default_validators = [validate_template]
widget = forms.Textarea

249
passerelle/utils/pdf.py Normal file
View File

@ -0,0 +1,249 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 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 base64
import dataclasses
import functools
import hashlib
import io
import subprocess
import tempfile
import typing
import pdfrw
class Rect(typing.NamedTuple):
x1: float
y1: float
x2: float
y2: float
@dataclasses.dataclass(frozen=True)
class Widget:
page: 'Page' = dataclasses.field(compare=False, repr=False)
name: str
widget_type: str = dataclasses.field(compare=False)
rect: Rect = dataclasses.field(compare=False)
on_value: str = dataclasses.field(compare=False, default=pdfrw.PdfName.On)
annotation: pdfrw.PdfDict = dataclasses.field(default=None, repr=False)
Review
    annotation: typing.Optional[pdfrw.PdfDict] = dataclasses.field(default=None, repr=False) 

Je ne suis vraiment pas à l'aise avec cette ligne (et un peu celles d'avant); le gain (?) ne vaut pour moi certainement pas l'introduction de cette partie qui pourra plus difficilement être maintenue; je préférerais vraiment que ça devienne du Python plus commun.

``` annotation: typing.Optional[pdfrw.PdfDict] = dataclasses.field(default=None, repr=False) ``` Je ne suis vraiment pas à l'aise avec cette ligne (et un peu celles d'avant); le gain (?) ne vaut pour moi certainement pas l'introduction de cette partie qui pourra plus difficilement être maintenue; je préférerais vraiment que ça devienne du Python plus commun.
Review

Il y a déjà du code équivalent dans chrono et lingo donc non.

Il y a déjà du code équivalent dans chrono et lingo donc non.
Review

Vraiment 1/ je sais que j'ai déjà vu du code ainsi passer, 2/ j'ai vérifié dans chrono avant d'écrire mon commentaire et le code n'y a pas de ligne aussi incompréhensible.

Vraiment 1/ je sais que j'ai déjà vu du code ainsi passer, 2/ j'ai vérifié dans chrono avant d'écrire mon commentaire et le code n'y a pas de ligne aussi incompréhensible.
Review
chrono/agendas/models.py:@dataclasses.dataclass(frozen=True)
chrono/agendas/models.py-class SharedCustodySlot:
chrono/agendas/models.py-    guardian: Person = dataclasses.field(compare=False)
chrono/agendas/models.py-    date: datetime.date
chrono/agendas/models.py-    label: str = dataclasses.field(compare=False, default='')

Si tu peux me pointer les lignes de https://docs.python.org/3/library/dataclasses.html qu´on peut utiliser ou pas, ça m'aidera.

``` chrono/agendas/models.py:@dataclasses.dataclass(frozen=True) chrono/agendas/models.py-class SharedCustodySlot: chrono/agendas/models.py- guardian: Person = dataclasses.field(compare=False) chrono/agendas/models.py- date: datetime.date chrono/agendas/models.py- label: str = dataclasses.field(compare=False, default='') ``` Si tu peux me pointer les lignes de https://docs.python.org/3/library/dataclasses.html qu´on peut utiliser ou pas, ça m'aidera.
Review
    annotation: typing.Optional[pdfrw.PdfDict] = dataclasses.field(default=None, repr=False) 

Tu peux éventuellement réagir en décidant de poser un commentaire explicatif, ou je ne sais quoi d'autre j'ai aucune idée du pourquoi/comment de cette ligne.

Mais perso s'il faut une règle je dirais bien que l'import du module typing serait mon niveau de blocage. (inutile aussi de me pointer le from typing import List dans lingo).

``` annotation: typing.Optional[pdfrw.PdfDict] = dataclasses.field(default=None, repr=False) ``` Tu peux éventuellement réagir en décidant de poser un commentaire explicatif, ou je ne sais quoi d'autre j'ai aucune idée du pourquoi/comment de cette ligne. Mais perso s'il faut une règle je dirais bien que l'import du module typing serait mon niveau de blocage. (inutile aussi de me pointer le `from typing import List` dans lingo).
Review

Si le probl

Si le probl
Review

(le message n'est pas passé)

(le message n'est pas passé)
Review

J'ai viré le typing.Optional.

J'ai viré le typing.Optional.
@property
def digest_id(self):
if not self.name:
return ''
name_bytes = self.name.encode()
digest_algo = hashlib.md5(name_bytes)
digest = digest_algo.digest()
b32_encoded = base64.b32encode(digest).decode()
return b32_encoded.strip('=').upper()
@property
def value(self):
if self.widget_type == 'text':
if self.annotation[pdfrw.PdfName.V]:
return self.annotation[pdfrw.PdfName.V].decode()
return ''
elif self.widget_type == 'checkbox':
return self.annotation[pdfrw.PdfName.V] == self.on_value
def set(self, value):
# allow rendering of values in Acrobat Reader
self.page.pdf._pdf_reader.Root.AcroForm.update(pdfrw.PdfDict(NeedAppearances=pdfrw.PdfObject('true')))
if self.widget_type == 'text':
str_value = str(value)
self.annotation.update(pdfrw.PdfDict(V=str_value, AS=str_value))
elif self.widget_type == 'checkbox':
bool_value = self.on_value if value else pdfrw.PdfName.Off
self.annotation.update(pdfrw.PdfDict(V=bool_value, AS=bool_value))
@dataclasses.dataclass
class Page:
pdf: object
page_number: object
THUMBNAIL_DEFAULT_WIDTH = 800
@property
def page(self):
return self.pdf._pdf_reader.pages[self.page_number]
@property
def fields(self):
fields = []
for annotation in self.page[pdfrw.PdfName.Annots] or ():
if annotation[pdfrw.PdfName.Subtype] != pdfrw.PdfName.Widget:
continue
if not annotation[pdfrw.PdfName.T]:
continue
name = annotation[pdfrw.PdfName.T].decode()
parent = annotation[pdfrw.PdfName.Parent]
while parent and parent[pdfrw.PdfName.T]:
name = f'{parent[pdfrw.PdfName.T].decode()}.{name}'
parent = parent[pdfrw.PdfName.Parent]
if not annotation[pdfrw.PdfName.FT]:
continue
pdf_field_type = annotation[pdfrw.PdfName.FT]
pdf_field_flags = annotation[pdfrw.PdfName.Ff] or 0
RADIO_FLAG = 2**16
PUSH_BUTTON_FLAG = 2**17
if (
pdf_field_type == pdfrw.PdfName.Btn
and not (pdf_field_flags & RADIO_FLAG)
and not (pdf_field_flags & PUSH_BUTTON_FLAG)
):
widget_type = 'checkbox'
elif pdf_field_type == pdfrw.PdfName.Tx:
widget_type = 'text'
else:
continue
on_value = None
if widget_type == 'checkbox':
try:
on_values = list(annotation[pdfrw.PdfName.AP][pdfrw.PdfName.N].keys())
except KeyError:
on_value = pdfrw.PdfName.On
else:
if pdfrw.PdfName.Off in on_values:
on_values.remove(pdfrw.PdfName.Off)
on_value = on_values[0]
fields.append(
Widget(
name=name,
widget_type=widget_type,
rect=Rect(*map(float, annotation[pdfrw.PdfName.Rect])),
on_value=on_value,
page=self,
annotation=annotation,
)
)
fields.sort(key=lambda field: (-field.rect[1], field.rect[0]))
return fields
@property
def media_box(self):
return Rect(*map(float, self.page[pdfrw.PdfName.MediaBox]))
def thumbnail_png(self, width=None):
width = width or self.THUMBNAIL_DEFAULT_WIDTH
fp = io.BytesIO(
subprocess.check_output(
[
'pdftoppm',
'-png',
'-scale-to-x',
str(width or '-1'),
'-scale-to-y',
'-1',
'-f',
str(self.page_number + 1),
'-l',
str(self.page_number + 1),
'-',
],
stderr=subprocess.DEVNULL,
input=self.pdf.content,
)
)
return fp.getvalue()
def thumbnail_field_rects(self, width=None):
'''Transform coordinates of fields to coordindates in thumbnail image.'''
width = width or self.THUMBNAIL_DEFAULT_WIDTH
media_box = self.media_box
media_width = media_box.x2 - media_box.x1
media_height = media_box.y2 - media_box.y1
height = int(width / media_width * media_height)
for field in self.fields:
field_rect = field.rect
yield field, Rect(
# PDF coordinates origin is in the bottom-left corner but img
# tag origin is in the top-left corner
x1=int((field_rect.x1 - media_box.x1) / media_width * width),
y1=int((media_box.y2 - field_rect.y1) / media_height * height),
x2=int((field_rect.x2 - media_box.x1) / media_width * width),
y2=int((media_box.y2 - field_rect.y2) / media_height * height),
)
def fields_image_map(self, width=None, sep='\n', id_prefix='', id_suffix=''):
tags = []
for field, area_rect in self.thumbnail_field_rects(width=width):
coords = ','.join(map(str, area_rect))
tags.append(
f'<area shape="rect" '
f'href="#{id_prefix}{field.digest_id}{id_suffix}" '
f'coords="{coords}">'
)
return sep.join(tags)
class PDF:
def __init__(self, content):
if hasattr(content, 'read'):
content = content.read()
self.content = content
@functools.cached_property
def _pdf_reader(self):
return pdfrw.PdfReader(fdata=self.content)
@property
def number_of_pages(self):
return len(self._pdf_reader.pages)
def page(self, page_number):
return Page(pdf=self, page_number=page_number)
@property
def pages(self):
for i in range(self.number_of_pages):
yield self.page(i)
def write(self, file_object, flatten=False):
assert hasattr(file_object, 'write')
if not flatten:
pdfrw.PdfWriter().write(file_object, self._pdf_reader)
else:
Review

Cette partie (write avec flatten à True) n'est pas testée du tout. (je préfèrerais qu'on n'introduise pas trop de code inutilisé).

Cette partie (write avec flatten à True) n'est pas testée du tout. (je préfèrerais qu'on n'introduise pas trop de code inutilisé).
Review

J'ai rajouté la couverture de ce code dans le test.

J'ai rajouté la couverture de ce code dans le test.
with io.BytesIO() as fd:
pdfrw.PdfWriter().write(fd, self._pdf_reader)
original_content = fd.getvalue()
with tempfile.NamedTemporaryFile() as output:
try:
subprocess.check_output(
[
'gs',
'-dSAFER',
'-dBATCH',
'-dNOPAUSE',
'-dNOCACHE',
'-sDEVICE=pdfwrite',
'-dPreserveAnnots=false',
f'-sOutputFile={output.name}',
'-',
],
stderr=subprocess.DEVNULL,
input=original_content,
)
except subprocess.CalledProcessError as e:
raise Exception(f'gs error={e.returncode} output={e.output}')
output.seek(0)
new_content = output.read()
file_object.write(new_content)

View File

@ -20,7 +20,7 @@ Disable autoescaping.
'''
from django.core.exceptions import ValidationError
from django.template import TemplateSyntaxError
from django.template import Context, Template, TemplateSyntaxError
from django.template.backends.django import DjangoTemplates
from django.utils.translation import gettext as _
@ -46,3 +46,15 @@ def validate_template(template_string):
make_template(template_string)
except TemplateSyntaxError as e:
raise ValidationError(_('Invalid template: %s') % e)
def evaluate_condition(condition_template, context_dict):
template = Template(f'{{% if {condition_template} %}}OK{{% endif %}}')
context = Context(context_dict)
return template.render(context) == 'OK'
def evaluate_template(template, context_dict):
template = Template(template)
context = Context(context_dict)
return template.render(context)

Binary file not shown.

View File

@ -17,17 +17,17 @@
import base64
import os
import subprocess
import xml.etree.ElementTree as ET
from io import BytesIO
from unittest import mock
import pytest
from django.core.exceptions import ValidationError
from django.core.files import File
from django.urls import reverse
from django.core.files.base import ContentFile
from pdfrw import PdfReader
from passerelle.apps.pdf.models import Resource
from passerelle.utils.pdf import PDF
from tests.test_manager import login
from tests.utils import generic_endpoint_url, setup_access_rights
@ -139,145 +139,6 @@ def test_pdf_real_pdftk_assemble(app, pdf, settings):
assert PdfReader(fdata=resp.content).numPages == 2
@mock.patch('subprocess.check_output')
def test_pdf_fill_form(mocked_check_output, app, pdf):
endpoint = generic_endpoint_url('pdf', 'fill-form', slug=pdf.slug)
def check_xml(args, **kwargs):
# check XML FDF file
xfdf = ET.parse(args[3]).getroot()
assert xfdf.tag == '{http://ns.adobe.com/xfdf/}xfdf'
assert xfdf.find('{http://ns.adobe.com/xfdf/}f').attrib['href'].endswith('.pdf')
field = xfdf.find('{http://ns.adobe.com/xfdf/}fields').find('{http://ns.adobe.com/xfdf/}field')
assert field.attrib['name'] == 'fname'
assert field.find('{http://ns.adobe.com/xfdf/}value').text == 'John'
payload = {
'filename': 'foo.pdf',
'fields/fname': 'John',
'input-form': {'content': acroform_b64content},
}
mocked_check_output.side_effect = check_xml
resp = app.post_json(endpoint, params=payload, status=200)
assert resp.headers['content-type'] == 'application/pdf'
assert resp.headers['content-disposition'] == 'attachment; filename="foo.pdf"'
assert mocked_check_output.call_count == 1
pdftk_call = mocked_check_output.call_args.args[0]
assert len(pdftk_call) == 6
assert pdftk_call[0] == '/usr/bin/pdftk'
assert pdftk_call[1].endswith('/input-form.pdf')
assert pdftk_call[2] == 'fill_form'
assert pdftk_call[3].endswith('/fields.xfdf')
assert pdftk_call[4] == 'output'
assert pdftk_call[5] == '-'
assert mocked_check_output.call_args.kwargs['timeout'] == 20
pdf.fill_form_file = File(BytesIO(acroform_content), 'default.pdf')
pdf.save()
payload = {
'filename': 'bar.pdf',
'fields/fname': 'John',
}
mocked_check_output.reset_mock()
resp = app.post_json(endpoint, params=payload, status=200)
assert resp.headers['content-type'] == 'application/pdf'
assert resp.headers['content-disposition'] == 'attachment; filename="bar.pdf"'
assert mocked_check_output.call_count == 1
pdftk_call = mocked_check_output.call_args.args[0]
assert len(pdftk_call) == 6
assert pdftk_call[0] == '/usr/bin/pdftk'
assert pdftk_call[1].endswith('media/pdf/test/default.pdf')
assert pdftk_call[2] == 'fill_form'
assert pdftk_call[3].endswith('/fields.xfdf')
assert pdftk_call[4] == 'output'
assert pdftk_call[5] == '-'
assert mocked_check_output.call_args.kwargs['timeout'] == 20
# pdftk errors (faked)
payload = {
'filename': 'foo.pdf',
'fields/fname': 'Bill',
'input-form': {'content': acroform_b64content},
}
mocked_check_output.reset_mock()
mocked_check_output.side_effect = subprocess.TimeoutExpired(cmd=[], timeout=20)
resp = app.post_json(endpoint, params=payload, status=200)
assert mocked_check_output.call_count == 1
assert resp.json['err'] == 1
assert resp.json['err_desc'].startswith('pdftk timed out after 20 seconds')
mocked_check_output.reset_mock()
mocked_check_output.side_effect = subprocess.CalledProcessError(cmd=[], returncode=42, output='ooops')
resp = app.post_json(endpoint, params=payload, status=200)
assert mocked_check_output.call_count == 1
assert resp.json['err'] == 1
assert resp.json['err_desc'].startswith('pdftk returned non-zero exit status 42')
assert 'ooops' in resp.json['err_desc']
# bad calls errors
resp = app.post(endpoint, status=400)
assert resp.headers['content-type'].startswith('application/json')
assert resp.json['err'] == 1
assert resp.json['err_desc'].startswith('could not decode body to json')
payload = {}
resp = app.post_json(endpoint, params=payload, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "'filename' is a required property"
payload = {'filename': 'out.pdf'}
resp = app.post_json(endpoint, params=payload, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "'fields' is a required property"
payload = {'filename': 'out.pdf', 'fields': 'not-a-dict'}
resp = app.post_json(endpoint, params=payload, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "fields: 'not-a-dict' is not of type 'object'"
pdf.fill_form_file = None # no default PDF form
pdf.save()
payload = {
'filename': 'bar.pdf',
'fields/fname': 'Alice',
}
resp = app.post_json(endpoint, params=payload, status=400)
assert resp.json['err'] == 1
assert resp.json['err_desc'] == "missing or bad 'input-form' property"
resp = app.get(endpoint, status=405)
def test_pdf_real_pdftk_fillform(admin_user, app, pdf, settings):
if not os.path.exists(settings.PDFTK_PATH):
pytest.skip('pdftk (%s) not found' % settings.PDFTK_PATH)
endpoint = generic_endpoint_url('pdf', 'fill-form', slug=pdf.slug)
payload = {
'filename': 'filled.pdf',
'fields/fname': 'ThisIsMyFirstName',
'input-form': {'content': acroform_b64content},
}
resp = app.post_json(endpoint, params=payload, status=200)
assert resp.headers['content-type'] == 'application/pdf'
assert resp.headers['content-disposition'] == 'attachment; filename="filled.pdf"'
assert PdfReader(fdata=resp.content).numPages == 1
assert resp.content[:5] == b'%PDF-'
# TODO: found an easy way to verify 'ThisIsMyFirstName' in resp.content
# dump fields in manager view
pdf.fill_form_file = File(BytesIO(acroform_content), 'pdf-form.pdf')
pdf.save()
manage_url = reverse('view-connector', kwargs={'connector': 'pdf', 'slug': pdf.slug})
resp = app.get(manage_url)
assert 'panel-dumpfields' not in resp.text
assert '<b>fields/fname</b>' not in resp.text
app = login(app)
resp = app.get(manage_url)
assert 'panel-dumpfields' in resp.text
assert '<b>fields/fname</b>' in resp.text
def test_pdf_validator(pdf):
pdf.fill_form_file = File(BytesIO(pdf_content), 'default.pdf')
pdf.save()
@ -291,3 +152,64 @@ def test_pdf_validator(pdf):
pdf.save()
with pytest.raises(ValidationError):
pdf.full_clean()
@pytest.fixture
def cerfa_content():
with open('tests/data/cerfa_10072-02.pdf', 'rb') as fd:
return fd.read()
def test_fill_form_no_pdf(app, admin_user, pdf):
resp = app.post_json('/pdf/test/fill-form/', params={'a': 1})
assert resp.json == {
'data': None,
'err': 1,
'err_class': 'passerelle.utils.jsonresponse.APIError',
'err_desc': 'not PDF file configured',
}
def test_fill_form_no_fields_mapping(app, admin_user, pdf, cerfa_content):
pdf.fill_form_file.save('form.pdf', ContentFile(cerfa_content))
resp = app.post_json('/pdf/test/fill-form/', params={'a': 1})
assert resp.json == {
'data': None,
'err': 1,
'err_class': 'passerelle.utils.jsonresponse.APIError',
'err_desc': 'no fields mapping configured',
}
def test_fill_form_ok(app, admin_user, pdf, cerfa_content):
pdf.fill_form_file.save('form.pdf', ContentFile(cerfa_content))
app = login(app)
resp = app.get('/pdf/test/')
resp = resp.click('Fill form: Edit fields mapping')
img_tags = resp.pyquery('img')
image_resp = app.get(img_tags[0].attrib['src'])
assert b'PNG' in image_resp.content
pdf_ = PDF(cerfa_content)
page = pdf_.page(0)
checkbox_field = [field for field in page.fields if field.widget_type == 'checkbox'][0]
text_field = [field for field in page.fields if field.widget_type == 'text'][0]
assert checkbox_field.value is False
assert text_field.value == ''
resp.form.set(f'field_{checkbox_field.digest_id}', 'testme == "a"')
resp.form.set(f'field_{text_field.digest_id}', '{{ prenom }} {{ nom }}')
resp.form.submit().follow()
resp = app.post_json('/pdf/test/fill-form/', params={'testme': 'a', 'prenom': 'Jean', 'nom': 'Dupont'})
pdf_ = PDF(resp.content)
page = pdf_.page(0)
checkbox_field = [field for field in page.fields if field.widget_type == 'checkbox'][0]
text_field = [field for field in page.fields if field.widget_type == 'text'][0]
assert checkbox_field.value is True
assert text_field.value == 'Jean Dupont'
resp = app.post_json(
'/pdf/test/fill-form/?flatten=1', params={'testme': 'a', 'prenom': 'Jean', 'nom': 'Dupont'}
)
pdf_ = PDF(resp.content)
page = pdf_.page(0)
assert not page.fields

34
tests/test_utils_forms.py Normal file
View File

@ -0,0 +1,34 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 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 pytest
from django.core.exceptions import ValidationError
from passerelle.utils.forms import ConditionField, TemplateField
def test_condition_field():
field = ConditionField()
with pytest.raises(ValidationError):
field.clean('x ==')
assert field.clean('x == 1') == 'x == 1'
def test_template_field():
field = TemplateField()
with pytest.raises(ValidationError):
field.clean('{% if foo %}bar')
assert field.clean('{% if foo %}bar{% endif %}') == '{% if foo %}bar{% endif %}'

95
tests/test_utils_pdf.py Normal file
View File

@ -0,0 +1,95 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 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 io
import re
import pytest
from PIL import Image
from passerelle.utils.pdf import PDF
@pytest.fixture
def pdf():
with open('tests/data/cerfa_10072-02.pdf', 'rb') as fd:
return PDF(content=fd)
def test_number_of_pages(pdf):
assert pdf.number_of_pages == 5
def test_page(pdf):
assert pdf.page(0) is not None
assert pdf.page(0).media_box == (0, 0, 595.32, 841.92)
def test_page_len_fields(pdf):
assert len(list(pdf.page(0).fields)) == 53
def test_page_fields(pdf):
page = pdf.page(0)
field = page.fields[0]
assert field.name == 'topmostSubform[0].Page1[0].Case_à_cocher1[2]'
assert field.widget_type == 'checkbox'
assert field.rect == (550.292, 691.02, 558.292, 699.02)
assert all(field.digest_id == field.digest_id.upper() for field in page.fields)
assert all(len(field.digest_id) >= 25 for field in page.fields)
# digests are unique
assert len(page.fields) == len({field.digest_id for field in page.fields})
assert page.fields[0] != page.fields[1]
assert page.fields[0] == page.fields[0]
def test_thumbnail_png(pdf):
png = pdf.page(0).thumbnail_png()
assert png[:10] == b'\x89PNG\r\n\x1a\n\x00\x00'
image = Image.open(io.BytesIO(png))
assert (image.width, image.height) == (800, 1132)
def test_fields_image_map(pdf):
image_map = pdf.page(0).fields_image_map()
assert len(list(re.findall('area', image_map))) == 53
def test_field_set(pdf):
for field in pdf.page(0).fields:
if field.name == 'topmostSubform[0].Page1[0].Champ_de_texte1[0]':
field.set('coucou')
elif field.name == 'topmostSubform[0].Page1[0].Case_à_cocher1[0]':
field.set(True)
with io.BytesIO() as fd:
pdf.write(fd)
new_pdf = PDF(fd.getvalue())
new_page = new_pdf.page(0)
check = set()
for field in new_page.fields:
if field.name == 'topmostSubform[0].Page1[0].Champ_de_texte1[0]':
check.add(1)
assert field.value == 'coucou'
elif field.name == 'topmostSubform[0].Page1[0].Case_à_cocher1[0]':
check.add(2)
assert field.value is True
elif field.widget_type == 'checkbox':
assert field.value is False
elif field.widget_type == 'text':
assert field.value == ''
else:
raise NotImplementedError
assert check == {1, 2}

View File

@ -0,0 +1,26 @@
# passerelle - uniform access to multiple data sources and services
# Copyright (C) 2023 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/>.
from passerelle.utils.templates import evaluate_condition, evaluate_template
def test_evaluate_condition():
assert evaluate_condition('x == 1', {'x': 1}) is True
assert evaluate_condition('x == 0', {'x': 1}) is False
def test_evaluate_template():
assert evaluate_template('{% if foo %}bar{% endif %}', {'foo': True}) == 'bar'