workflows: add option to get document model file using a template (#69689) #1177

Merged
fpeters merged 1 commits from wip/69689-doc-template-from-template into main 2024-02-29 10:44:23 +01:00
6 changed files with 202 additions and 66 deletions

View File

@ -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

View File

@ -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'

View File

@ -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

View File

@ -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;
}

View File

@ -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':

pour l'existant, model_file_mode vaut None, non ?

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.

Il ne sera pas défini sur l'objet sérialisé donc ça tombera sur la valeur posée au niveau de la classe.
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')

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:

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'

View File

@ -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()

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>')