workflows: add option to get document model file using a template (#69689) #1177
|
@ -118,7 +118,8 @@ def test_workflow_inspect_page(pub):
|
|||
|
||||
resp = app.get('/backoffice/workflows/%s/inspect' % workflow.id)
|
||||
assert (
|
||||
'<span class="parameter">Model:</span> '
|
||||
'<span class="parameter">Model:</span> File</li>'
|
||||
'<li class="parameter-model_file">'
|
||||
'<a href="status/st3/items/_export_to/?file=model_file">test.odt</a></li>'
|
||||
) in resp.text
|
||||
|
||||
|
|
|
@ -14,6 +14,7 @@ from webtest import Radio, Upload
|
|||
|
||||
from wcs import sessions
|
||||
from wcs.blocks import BlockDef
|
||||
from wcs.carddef import CardDef
|
||||
from wcs.fields import (
|
||||
BlockField,
|
||||
BoolField,
|
||||
|
@ -714,3 +715,80 @@ def test_workflows_edit_export_to_model_action_check_template(pub):
|
|||
model_content = zip_out_fp.getvalue()
|
||||
resp.form['model_file'] = Upload('test.odt', model_content)
|
||||
resp.form.submit('submit').follow() # success
|
||||
|
||||
|
||||
def test_export_to_model_from_template(pub):
|
||||
CardDef.wipe()
|
||||
carddef = CardDef()
|
||||
carddef.name = 'card'
|
||||
carddef.fields = [
|
||||
FileField(id='1', label='File', varname='file'),
|
||||
StringField(id='2', label='String', varname='string'),
|
||||
]
|
||||
carddef.store()
|
||||
|
||||
template_filename = os.path.join(os.path.dirname(__file__), '..', 'template.odt')
|
||||
with open(template_filename, 'rb') as fd:
|
||||
template = fd.read()
|
||||
upload = QuixoteUpload('/foo/template.odt', content_type='application/octet-stream')
|
||||
upload.fp = io.BytesIO()
|
||||
upload.fp.write(template)
|
||||
upload.fp.seek(0)
|
||||
|
||||
carddata = carddef.data_class()()
|
||||
carddata.data = {'1': upload, '2': 'blah'}
|
||||
carddata.just_created()
|
||||
carddata.store()
|
||||
|
||||
wf = Workflow(name='test_export_to_model_from_template')
|
||||
wf.backoffice_fields_formdef = WorkflowBackofficeFieldsFormDef(wf)
|
||||
wf.backoffice_fields_formdef.fields = [
|
||||
FileField(id='bo1', label='bo field 1', varname='backoffice_file1'),
|
||||
]
|
||||
st1 = wf.add_status('Status1')
|
||||
wf.store()
|
||||
|
||||
formdef = FormDef()
|
||||
formdef.name = 'foo-export'
|
||||
formdef.fields = [
|
||||
StringField(id='1', label='String', varname='string'),
|
||||
]
|
||||
formdef.workflow_id = wf.id
|
||||
formdef.store()
|
||||
formdata = formdef.data_class()()
|
||||
formdata.data = {}
|
||||
formdata.just_created()
|
||||
formdata.store()
|
||||
pub.substitutions.feed(formdata)
|
||||
|
||||
item = ExportToModel()
|
||||
item.method = 'non-interactive'
|
||||
item.convert_to_pdf = False
|
||||
item.model_file_mode = 'template'
|
||||
item.model_file_template = '{{cards|objects:"card"|first|get:"form_var_file" }}'
|
||||
item.parent = st1
|
||||
item.backoffice_filefield_id = 'bo1'
|
||||
item.perform(formdata)
|
||||
|
||||
assert 'bo1' in formdata.data
|
||||
fbo1 = formdata.data['bo1']
|
||||
assert fbo1.base_filename == 'template.odt'
|
||||
assert fbo1.content_type == 'application/octet-stream'
|
||||
with zipfile.ZipFile(fbo1.get_file()) as zfile:
|
||||
assert b'foo-export' in zfile.read('content.xml')
|
||||
|
||||
pub.loggederror_class.wipe()
|
||||
item.model_file_template = '{{cards|objects:"card"|first|get:"form_var_string" }}'
|
||||
formdata.data = {}
|
||||
item.perform(formdata)
|
||||
assert 'bo1' not in formdata.data
|
||||
assert pub.loggederror_class.count() == 1
|
||||
assert pub.loggederror_class.select()[0].summary == 'Invalid value obtained for model file (\'blah\')'
|
||||
|
||||
pub.loggederror_class.wipe()
|
||||
item.model_file_template = '{% if foo %}{{ foo }}{% end %}' # invalid template
|
||||
formdata.data = {}
|
||||
item.perform(formdata)
|
||||
assert 'bo1' not in formdata.data
|
||||
assert pub.loggederror_class.count() == 1
|
||||
assert pub.loggederror_class.select()[0].summary == 'Failed to evaluate template for action'
|
||||
|
|
|
@ -126,7 +126,12 @@ class FileField(WidgetField):
|
|||
value = value.get_value() # unbox
|
||||
if hasattr(value, 'base_filename'):
|
||||
upload = PicklableUpload(value.base_filename, value.content_type or 'application/octet-stream')
|
||||
upload.receive([value.get_content()])
|
||||
if hasattr(value, 'get_content'):
|
||||
upload.receive([value.get_content()])
|
||||
else:
|
||||
# native quixote Upload object
|
||||
upload.receive([value.fp.read()])
|
||||
value.fp.seek(0)
|
||||
return upload
|
||||
from wcs.workflows import NamedAttachmentsSubstitutionProxy
|
||||
|
||||
|
|
|
@ -3131,3 +3131,11 @@ ul.objects-list.single-links li a.link-action-icon.duplicate {
|
|||
content: "\f24d"; /* clone */
|
||||
}
|
||||
}
|
||||
|
||||
div[role="tabpanel"] > div.infonotice:first-child {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
form div.widget[data-widget-name="model_file_mode"] {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
|
|
@ -34,7 +34,7 @@ from quixote.errors import TraversalError
|
|||
from quixote.html import htmltext
|
||||
from quixote.http_request import Upload
|
||||
|
||||
from wcs.fields import CommentField, PageField, SubtitleField, TitleField
|
||||
from wcs.fields import FileField
|
||||
from wcs.portfolio import has_portfolio, push_document
|
||||
from wcs.workflows import (
|
||||
AttachmentEvolutionPart,
|
||||
|
@ -51,6 +51,7 @@ from ..qommon.form import (
|
|||
CheckboxWidget,
|
||||
ComputedExpressionWidget,
|
||||
FileWidget,
|
||||
HtmlWidget,
|
||||
RadiobuttonsWidget,
|
||||
SingleSelectWidget,
|
||||
StringWidget,
|
||||
|
@ -198,24 +199,6 @@ class TemplatingError(TraversalError):
|
|||
self.description = description
|
||||
|
||||
|
||||
def get_varnames(fields):
|
||||
"""Extract variable names for helping people fill their templates.
|
||||
|
||||
Prefer to variable name to the numeric field name.
|
||||
"""
|
||||
varnames = []
|
||||
for field in fields:
|
||||
if isinstance(field, (SubtitleField, TitleField, CommentField, PageField)):
|
||||
continue
|
||||
# add it as f$n$
|
||||
label = field.label
|
||||
if field.varname:
|
||||
varnames.append(('var_%s' % field.varname, label))
|
||||
else:
|
||||
varnames.append(('f%s' % field.id, label))
|
||||
return varnames
|
||||
|
||||
|
||||
class ExportToModelDirectory(Directory):
|
||||
_q_exports = ['']
|
||||
|
||||
|
@ -224,21 +207,22 @@ class ExportToModelDirectory(Directory):
|
|||
self.wfstatusitem = wfstatusitem
|
||||
|
||||
def _q_index(self):
|
||||
if not self.wfstatusitem.model_file:
|
||||
if not (self.wfstatusitem.model_file or self.wfstatusitem.model_file_template):
|
||||
raise TemplatingError(_('No model defined for this action'))
|
||||
response = get_response()
|
||||
model_file = self.wfstatusitem.get_model_file()
|
||||
if self.wfstatusitem.convert_to_pdf:
|
||||
response.content_type = 'application/pdf'
|
||||
else:
|
||||
response.content_type = self.wfstatusitem.model_file.content_type
|
||||
response.content_type = model_file.content_type
|
||||
response.set_header('location', '..')
|
||||
|
||||
filename = self.wfstatusitem.get_filename()
|
||||
filename = self.wfstatusitem.get_filename(model_file)
|
||||
if self.wfstatusitem.convert_to_pdf:
|
||||
filename = filename.rsplit('.', 1)[0] + '.pdf'
|
||||
if response.content_type != 'text/html':
|
||||
response.set_header('content-disposition', 'attachment; filename="%s"' % filename)
|
||||
return self.wfstatusitem.apply_template_to_formdata(self.formdata).read()
|
||||
return self.wfstatusitem.apply_template_to_formdata(self.formdata, model_file).read()
|
||||
|
||||
|
||||
class UploadValidationError(Exception):
|
||||
|
@ -279,7 +263,9 @@ class ExportToModel(WorkflowStatusItem):
|
|||
waitpoint = True
|
||||
|
||||
label = None
|
||||
model_file_mode = 'file' # or 'template'
|
||||
model_file = None
|
||||
model_file_template = None
|
||||
attach_to_history = False
|
||||
directory_class = ExportToModelDirectory
|
||||
by = ['_receiver']
|
||||
|
@ -291,19 +277,26 @@ class ExportToModel(WorkflowStatusItem):
|
|||
backoffice_filefield_id = None
|
||||
|
||||
def get_line_details(self):
|
||||
if self.model_file:
|
||||
if self.model_file and self.model_file_mode == 'file':
|
||||
|
||||
return _('with model named %(file_name)s of %(size)s') % {
|
||||
'file_name': self.model_file.base_filename,
|
||||
'size': filesizeformat(self.model_file.size),
|
||||
}
|
||||
elif self.model_file_template and self.model_file_mode == 'template':
|
||||
return _('with model from template')
|
||||
fpeters
commented
Un attribut model_file_mode qui est 'file' ou 'template', et dessous model_file qui est le fichier directement uploadé comme aujourd'hui, et model_file_template qui est la nouveauté. Un attribut model_file_mode qui est 'file' ou 'template', et dessous model_file qui est le fichier directement uploadé comme aujourd'hui, et model_file_template qui est la nouveauté.
|
||||
else:
|
||||
return _('no model set')
|
||||
|
||||
def is_interactive(self):
|
||||
return bool(self.method == 'interactive')
|
||||
|
||||
def has_configured_model_file(self):
|
||||
return (self.model_file_mode == 'file' and self.model_file) or (
|
||||
self.model_file_mode == 'template' and self.model_file_template
|
||||
)
|
||||
|
||||
def fill_form(self, form, formdata, user, **kwargs):
|
||||
if self.method != 'interactive' or not self.model_file:
|
||||
if self.method != 'interactive' or not self.has_configured_model_file():
|
||||
return
|
||||
label = self.label
|
||||
if not label:
|
||||
|
@ -317,7 +310,7 @@ class ExportToModel(WorkflowStatusItem):
|
|||
def submit_form(self, form, formdata, user, evo):
|
||||
if self.method != 'interactive':
|
||||
return
|
||||
if not self.model_file:
|
||||
if not self.has_configured_model_file():
|
||||
return
|
||||
if form.get_submit() == 'button%s' % self.id:
|
||||
if not evo.comment:
|
||||
|
@ -387,7 +380,7 @@ class ExportToModel(WorkflowStatusItem):
|
|||
upload.fp.seek(0)
|
||||
|
||||
def get_parameters(self):
|
||||
parameters = ('model_file',)
|
||||
parameters = ('model_file_mode', 'model_file', 'model_file_template')
|
||||
if transform_to_pdf is not None:
|
||||
parameters += ('convert_to_pdf',)
|
||||
parameters += ('varname', 'backoffice_filefield_id', 'attach_to_history')
|
||||
|
@ -402,6 +395,10 @@ class ExportToModel(WorkflowStatusItem):
|
|||
parameters.remove('by')
|
||||
parameters.remove('label')
|
||||
parameters.remove('backoffice_info_text')
|
||||
if self.model_file_mode == 'file':
|
||||
parameters.remove('model_file_template')
|
||||
elif self.model_file_mode == 'template':
|
||||
parameters.remove('model_file')
|
||||
return parameters
|
||||
|
||||
def add_parameters_widgets(self, form, parameters, prefix='', formdef=None, **kwargs):
|
||||
|
@ -410,46 +407,66 @@ class ExportToModel(WorkflowStatusItem):
|
|||
methods = collections.OrderedDict(
|
||||
[('interactive', _('Interactive (button)')), ('non-interactive', _('Non interactive'))]
|
||||
)
|
||||
if 'model_file' in parameters:
|
||||
ids = (self.get_workflow().id, self.parent.id, self.id)
|
||||
if formdef:
|
||||
hint = htmltext('%s: <ul class="varnames">') % _('Available variables')
|
||||
varnames = get_varnames(formdef.fields)
|
||||
for pair in varnames:
|
||||
fpeters
commented
Il y avait cette partie sur le cas très particulier de l'édition de ce paramètre via l'ancienné mécanique d'option de workflow (qui s'édite depuis la page d'un formulaire, où on a donc un formdef), où on affichait les variables exposées (mais avec les anciens noms qui marchaient uniquement avec python). Bref je retire ça. Il y avait cette partie sur le cas très particulier de l'édition de ce paramètre via l'ancienné mécanique d'option de workflow (qui s'édite depuis la page d'un formulaire, où on a donc un formdef), où on affichait les variables exposées (mais avec les anciens noms qui marchaient uniquement avec python). Bref je retire ça.
|
||||
hint += (
|
||||
htmltext('<li><tt class="varname">{{ %s }}</tt> <label>%s</label></span></li>') % pair
|
||||
)
|
||||
hint += htmltext('</ul>')
|
||||
ids = (formdef.id,) + ids
|
||||
filename = 'export_to_model-%s-%s-%s-%s.upload' % ids
|
||||
else:
|
||||
hint = _(
|
||||
if 'model_file_mode' in parameters:
|
||||
form.add(
|
||||
HtmlWidget,
|
||||
name='note',
|
||||
title=htmltext('<div class="infonotice">%s</div>')
|
||||
% _(
|
||||
'You can use variables in your model using '
|
||||
'the {{variable}} syntax, available variables '
|
||||
'depends on the form.'
|
||||
)
|
||||
filename = 'export_to_model-%s-%s-%s.upload' % ids
|
||||
),
|
||||
)
|
||||
form.add(
|
||||
RadiobuttonsWidget,
|
||||
'%smodel_file_mode' % prefix,
|
||||
title=_('Model'),
|
||||
options=[('file', _('File'), 'file'), ('template', _('Template'), 'template')],
|
||||
value=self.model_file_mode,
|
||||
default_value=self.__class__.model_file_mode,
|
||||
attrs={'data-dynamic-display-parent': 'true'},
|
||||
extra_css_class='widget-inline-radio',
|
||||
)
|
||||
if 'model_file' in parameters:
|
||||
ids = (self.get_workflow().id, self.parent.id, self.id)
|
||||
filename = 'export_to_model-%s-%s-%s.upload' % ids
|
||||
widget_name = '%smodel_file' % prefix
|
||||
if formdef and formdef.workflow_options and formdef.workflow_options.get(widget_name) is not None:
|
||||
value = formdef.workflow_options.get(widget_name)
|
||||
else:
|
||||
value = self.model_file
|
||||
if value:
|
||||
hint_prefix = htmltext('<div>%s: <a href="?file=%s">%s</a></div>') % (
|
||||
hint = htmltext('<div>%s: <a href="?file=%s">%s</a></div>') % (
|
||||
_('Current value'),
|
||||
widget_name,
|
||||
value.base_filename,
|
||||
)
|
||||
hint = hint_prefix + force_str(hint)
|
||||
else:
|
||||
hint = None
|
||||
form.add(
|
||||
ModelFileWidget,
|
||||
widget_name,
|
||||
directory='models',
|
||||
filename=filename,
|
||||
title=_('Model'),
|
||||
hint=hint,
|
||||
validation=self.model_file_content_validation,
|
||||
value=value,
|
||||
attrs={
|
||||
'data-dynamic-display-child-of': '%smodel_file_mode' % prefix,
|
||||
'data-dynamic-display-value': 'file',
|
||||
},
|
||||
)
|
||||
if 'model_file_template' in parameters:
|
||||
form.add(
|
||||
ComputedExpressionWidget,
|
||||
name='%smodel_file_template' % prefix,
|
||||
title=_('Template to obtain model file'),
|
||||
value=self.model_file_template,
|
||||
attrs={
|
||||
'data-dynamic-display-child-of': '%smodel_file_mode' % prefix,
|
||||
'data-dynamic-display-value': 'template',
|
||||
},
|
||||
)
|
||||
if 'convert_to_pdf' in parameters:
|
||||
form.add(
|
||||
|
@ -563,12 +580,12 @@ class ExportToModel(WorkflowStatusItem):
|
|||
self.model_file.base_filename,
|
||||
)
|
||||
|
||||
def get_filename(self):
|
||||
def get_filename(self, model_file):
|
||||
filename = None
|
||||
if self.filename:
|
||||
filename = self.compute(self.filename)
|
||||
if not filename:
|
||||
filename = self.model_file.base_filename
|
||||
filename = model_file.base_filename
|
||||
filename = filename.replace('/', '-')
|
||||
return filename
|
||||
|
||||
|
@ -577,14 +594,14 @@ class ExportToModel(WorkflowStatusItem):
|
|||
|
||||
directory_name = property(get_directory_name)
|
||||
|
||||
def apply_template_to_formdata(self, formdata):
|
||||
kind = self.model_file_validation(self.model_file)
|
||||
def apply_template_to_formdata(self, formdata, model_file):
|
||||
kind = self.model_file_validation(model_file)
|
||||
if kind == 'rtf' and not get_publisher().has_site_option('disable-rtf-support'):
|
||||
outstream = self.apply_rtf_template_to_formdata(formdata)
|
||||
outstream = self.apply_rtf_template_to_formdata(formdata, model_file)
|
||||
elif kind == 'opendocument':
|
||||
outstream = self.apply_od_template_to_formdata(formdata)
|
||||
outstream = self.apply_od_template_to_formdata(formdata, model_file)
|
||||
elif kind == 'xml':
|
||||
outstream = self.apply_text_template_to_formdata(formdata)
|
||||
outstream = self.apply_text_template_to_formdata(formdata, model_file)
|
||||
else:
|
||||
raise Exception('unsupported model kind %r' % kind)
|
||||
if kind == 'xml':
|
||||
|
@ -606,17 +623,17 @@ class ExportToModel(WorkflowStatusItem):
|
|||
return transform_to_pdf(outstream)
|
||||
return outstream
|
||||
|
||||
def apply_text_template_to_formdata(self, formdata):
|
||||
def apply_text_template_to_formdata(self, formdata, model_file):
|
||||
return io.BytesIO(
|
||||
force_bytes(
|
||||
template_on_formdata(
|
||||
formdata,
|
||||
self.model_file.get_file().read().decode(errors='surrogateescape'),
|
||||
model_file.get_file().read().decode(errors='surrogateescape'),
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
def apply_rtf_template_to_formdata(self, formdata):
|
||||
def apply_rtf_template_to_formdata(self, formdata, model_file):
|
||||
try:
|
||||
# force ezt_only=True because an RTF file may contain {{ characters
|
||||
# and would be seen as a Django template
|
||||
|
@ -624,7 +641,7 @@ class ExportToModel(WorkflowStatusItem):
|
|||
force_bytes(
|
||||
template_on_formdata(
|
||||
formdata,
|
||||
force_str(self.model_file.get_file().read()),
|
||||
force_str(model_file.get_file().read()),
|
||||
ezt_format=ezt.FORMAT_RTF,
|
||||
ezt_only=True,
|
||||
)
|
||||
|
@ -636,7 +653,7 @@ class ExportToModel(WorkflowStatusItem):
|
|||
)
|
||||
raise TemplatingError(_('Error in template: %s') % str(e))
|
||||
|
||||
def apply_od_template_to_formdata(self, formdata):
|
||||
def apply_od_template_to_formdata(self, formdata, model_file):
|
||||
context = get_formdata_template_context(formdata)
|
||||
|
||||
def process_styles(root):
|
||||
|
@ -750,7 +767,7 @@ class ExportToModel(WorkflowStatusItem):
|
|||
node.tail = current_tail
|
||||
|
||||
outstream = io.BytesIO()
|
||||
transform_opendocument(self.model_file.get_file(), outstream, process_root)
|
||||
transform_opendocument(model_file.get_file(), outstream, process_root)
|
||||
outstream.seek(0)
|
||||
return outstream
|
||||
|
||||
|
@ -892,12 +909,38 @@ class ExportToModel(WorkflowStatusItem):
|
|||
return
|
||||
self.perform_real(formdata, formdata.evolution[-1])
|
||||
|
||||
def get_model_file(self):
|
||||
if self.model_file_mode == 'file':
|
||||
return self.model_file
|
||||
with get_publisher().complex_data():
|
||||
try:
|
||||
model_file = self.compute(
|
||||
self.model_file_template, allow_complex=True, record_errors=False, raises=True
|
||||
)
|
||||
except Exception as e:
|
||||
get_publisher().record_error(
|
||||
_('Failed to evaluate template for action'), exception=e, status_item=self
|
||||
)
|
||||
return None
|
||||
model_file = get_publisher().get_cached_complex_data(model_file)
|
||||
try:
|
||||
model_file = FileField.convert_value_from_anything(model_file)
|
||||
except ValueError:
|
||||
get_publisher().record_error(
|
||||
_('Invalid value obtained for model file (%r)') % model_file, status_item=self
|
||||
)
|
||||
return None
|
||||
return model_file
|
||||
|
||||
def perform_real(self, formdata, evo):
|
||||
if not self.model_file:
|
||||
if not self.has_configured_model_file():
|
||||
return
|
||||
outstream = self.apply_template_to_formdata(formdata)
|
||||
filename = self.get_filename()
|
||||
content_type = self.model_file.content_type
|
||||
model_file = self.get_model_file()
|
||||
if not model_file:
|
||||
return
|
||||
outstream = self.apply_template_to_formdata(formdata, model_file)
|
||||
filename = self.get_filename(model_file)
|
||||
content_type = model_file.content_type
|
||||
if self.convert_to_pdf:
|
||||
filename = filename.rsplit('.', 1)[0] + '.pdf'
|
||||
content_type = 'application/pdf'
|
||||
|
|
|
@ -3173,7 +3173,8 @@ class WorkflowStatusItem(XmlSerialisable):
|
|||
if not widget:
|
||||
continue
|
||||
r += htmltext('<li class="parameter-%s">' % parameter)
|
||||
r += htmltext('<span class="parameter">%s</span> ') % _('%s:') % widget.get_title()
|
||||
if widget.get_title():
|
||||
r += htmltext('<span class="parameter">%s</span> ') % _('%s:') % widget.get_title()
|
||||
fpeters
commented
Dans l'inspect du workflow, pour l'affichage des paramètres, avec model_file/model_file_template on n'a pas de titre, ça faisait un ":" vide. Dans l'inspect du workflow, pour l'affichage des paramètres, avec model_file/model_file_template on n'a pas de titre, ça faisait un ":" vide.
|
||||
r += self.get_parameter_view_value(widget, parameter)
|
||||
r += htmltext('</li>')
|
||||
r += htmltext('</ul>')
|
||||
|
|
Loading…
Reference in New Issue
pour l'existant, model_file_mode vaut None, non ?
Il ne sera pas défini sur l'objet sérialisé donc ça tombera sur la valeur posée au niveau de la classe.