Compare commits
17 Commits
b7fdbd4c32
...
01f74d5c4b
Author | SHA1 | Date |
---|---|---|
Pierre Ducroquet | 01f74d5c4b | |
Pierre Ducroquet | 4dd193988a | |
Pierre Ducroquet | 1cfa7f7512 | |
Pierre Ducroquet | 561ae38d0c | |
Pierre Ducroquet | ddf73d2127 | |
Pierre Ducroquet | 0219846cd5 | |
Pierre Ducroquet | ed4ecaf8c6 | |
Pierre Ducroquet | 2e5310b86d | |
Pierre Ducroquet | ea3afc4d47 | |
Pierre Ducroquet | b90fb36db2 | |
Pierre Ducroquet | a86fa662c4 | |
Frédéric Péters | 822010b131 | |
Frédéric Péters | f355b9ca02 | |
Frédéric Péters | 389a9bd165 | |
Valentin Deniaud | 104c1c903a | |
Valentin Deniaud | 47c6188a40 | |
Valentin Deniaud | e08aaca460 |
|
@ -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
|
||||
|
||||
|
|
|
@ -35,6 +35,8 @@ def pub():
|
|||
BlockDef.wipe()
|
||||
WebserviceResponse.wipe()
|
||||
NamedWsCall.wipe()
|
||||
TestResult.wipe()
|
||||
TestDef.wipe()
|
||||
return pub
|
||||
|
||||
|
||||
|
|
|
@ -230,6 +230,11 @@ def test_workflow_tests_automatic_jump_timeout(pub):
|
|||
jump.timeout = 120 * 60 # 2 hours
|
||||
jump.condition = {'type': 'django', 'value': 'form_receipt_datetime|age_in_days >= 1'}
|
||||
|
||||
sendmail = new_status.add_action('sendmail')
|
||||
sendmail.to = ['test@example.org']
|
||||
sendmail.subject = 'In new status'
|
||||
sendmail.body = 'xxx'
|
||||
|
||||
workflow.store()
|
||||
|
||||
formdef = FormDef()
|
||||
|
|
|
@ -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,
|
||||
|
@ -754,3 +755,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
|
||||
|
||||
|
|
|
@ -4,8 +4,8 @@ msgid ""
|
|||
msgstr ""
|
||||
"Project-Id-Version: wcs 0\n"
|
||||
"Report-Msgid-Bugs-To: \n"
|
||||
"POT-Creation-Date: 2024-02-27 18:05+0100\n"
|
||||
"PO-Revision-Date: 2024-02-27 18:05+0100\n"
|
||||
"POT-Creation-Date: 2024-02-29 10:53+0100\n"
|
||||
"PO-Revision-Date: 2024-02-29 10:53+0100\n"
|
||||
"Last-Translator: Thomas Noël <tnoel@entrouvert.com>\n"
|
||||
"Language-Team: french\n"
|
||||
"Language: fr\n"
|
||||
|
@ -313,7 +313,7 @@ msgstr "Nouveau bloc de champs"
|
|||
#: admin/blocks.py admin/categories.py admin/comment_templates.py
|
||||
#: admin/data_sources.py admin/forms.py admin/mail_templates.py
|
||||
#: admin/settings.py admin/tests.py admin/workflows.py admin/wscalls.py
|
||||
#: backoffice/data_management.py backoffice/i18n.py
|
||||
#: backoffice/data_management.py backoffice/i18n.py wf/export_to_model.py
|
||||
msgid "File"
|
||||
msgstr "Fichier"
|
||||
|
||||
|
@ -1738,6 +1738,7 @@ msgid "Django Expression"
|
|||
msgstr "Expression Django"
|
||||
|
||||
#: admin/logged_errors.py backoffice/management.py qommon/form.py
|
||||
#: wf/export_to_model.py
|
||||
msgid "Template"
|
||||
msgstr "Gabarit"
|
||||
|
||||
|
@ -10027,7 +10028,7 @@ msgstr "Pas encore de résultats des tests."
|
|||
|
||||
#: templates/wcs/backoffice/test-webservice-responses.html
|
||||
#: wf/assign_carddata.py wf/create_formdata.py wf/redirect_to_url.py
|
||||
#: wf/roles.py
|
||||
#: wf/roles.py workflow_tests.py
|
||||
msgid "not configured"
|
||||
msgstr "non configurée"
|
||||
|
||||
|
@ -11248,6 +11249,10 @@ msgstr "Création de document"
|
|||
msgid "with model named %(file_name)s of %(size)s"
|
||||
msgstr "avec le modèle %(file_name)s de %(size)s"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "with model from template"
|
||||
msgstr "avec un modèle issu d’un gabarit"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "no model set"
|
||||
msgstr "aucun modèle défini"
|
||||
|
@ -11276,10 +11281,6 @@ msgstr "Interactive (bouton)"
|
|||
msgid "Non interactive"
|
||||
msgstr "Non interactive"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "Available variables"
|
||||
msgstr "Variables disponibles"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid ""
|
||||
"You can use variables in your model using the {{variable}} syntax, available "
|
||||
|
@ -11288,13 +11289,17 @@ msgstr ""
|
|||
"Vous pouvez utiliser des variables dans votre modèle avec la syntaxe "
|
||||
"{{variable}}. Les variables disponibles dépendent du formulaire."
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "Model"
|
||||
msgstr "Modèle"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "Current value"
|
||||
msgstr "Valeur actuelle"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "Model"
|
||||
msgstr "Modèle"
|
||||
msgid "Template to obtain model file"
|
||||
msgstr "Gabarit pour obtenir le ficher servant de modèle"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "Convert generated file to PDF"
|
||||
|
@ -11333,6 +11338,15 @@ msgstr "Erreur dans un gabarit lors de la création de document"
|
|||
msgid "Error in template: %s"
|
||||
msgstr "erreur dans le document modèle : %s"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
msgid "Failed to evaluate template for action"
|
||||
msgstr "Erreur à l’évaluation du gabarit pour l’action"
|
||||
|
||||
#: wf/export_to_model.py
|
||||
#, python-format
|
||||
msgid "Invalid value obtained for model file (%r)"
|
||||
msgstr "Valeur invalide obtenue pour le fichier modèle (%r)"
|
||||
|
||||
#: wf/external_workflow.py
|
||||
#, python-format
|
||||
msgid "Running external actions on \"%(label)s\" (%(count)s processed)"
|
||||
|
@ -11927,6 +11941,11 @@ msgstr "Statut de la demande quand l’erreur s’est produite : %s"
|
|||
msgid "Simulate click on action button"
|
||||
msgstr "Clic sur un bouton d’action"
|
||||
|
||||
#: workflow_tests.py
|
||||
#, python-format
|
||||
msgid "Click on \"%s\""
|
||||
msgstr "Clic sur « %s »"
|
||||
|
||||
#: workflow_tests.py
|
||||
#, python-format
|
||||
msgid "Button \"%s\" is not displayed."
|
||||
|
@ -11944,6 +11963,11 @@ msgstr "Texte du bouton"
|
|||
msgid "Assert form status"
|
||||
msgstr "Vérifier le statut de la demande"
|
||||
|
||||
#: workflow_tests.py
|
||||
#, python-format
|
||||
msgid "Status is \"%s\""
|
||||
msgstr "Le statut est « %s »"
|
||||
|
||||
#: workflow_tests.py
|
||||
#, python-format
|
||||
msgid ""
|
||||
|
|
|
@ -62,13 +62,12 @@ class fargo_post_json_async:
|
|||
def push_document(user, filename, stream):
|
||||
if not user:
|
||||
return
|
||||
publisher = get_publisher()
|
||||
payload = {}
|
||||
if user.name_identifiers:
|
||||
payload['user_nameid'] = force_str(user.name_identifiers[0], 'ascii')
|
||||
elif user.email:
|
||||
payload['user_email'] = force_str(user.email, 'ascii')
|
||||
payload['origin'] = urllib.parse.urlparse(publisher.get_frontoffice_url()).netloc
|
||||
payload['origin'] = urllib.parse.urlparse(get_publisher().get_frontoffice_url()).netloc
|
||||
payload['file_name'] = filename
|
||||
stream.seek(0)
|
||||
payload['file_b64_content'] = force_str(base64.b64encode(stream.read()))
|
||||
|
@ -78,9 +77,10 @@ def push_document(user, filename, stream):
|
|||
status = 0
|
||||
status, payload = async_post()
|
||||
if status != 200:
|
||||
publisher.record_error(
|
||||
get_publisher().record_error(
|
||||
_(
|
||||
'file %(filename)r failed to be pushed to portfolio of %(display_name)r [status: %(status)d, payload: %(payload)r]'
|
||||
'file %(filename)r failed to be pushed to portfolio of %(display_name)r '
|
||||
'[status: %(status)d, payload: %(payload)r]'
|
||||
)
|
||||
% {
|
||||
'filename': filename,
|
||||
|
|
|
@ -445,6 +445,7 @@ class WcsPublisher(QommonPublisher):
|
|||
sql.ApplicationElement.do_table()
|
||||
sql.SearchableFormDef.do_table()
|
||||
sql.TranslatableMessage.do_table()
|
||||
sql.init_search_tokens()
|
||||
sql.do_meta_table()
|
||||
from .carddef import CardDef
|
||||
from .formdef import FormDef
|
||||
|
|
|
@ -690,6 +690,10 @@ class QommonPublisher(Publisher):
|
|||
for error in self.loggederror_class.select(clause=clauses):
|
||||
self.loggederror_class.remove_object(error.id)
|
||||
|
||||
def clean_search_tokens(self, **kwargs):
|
||||
from wcs import sql
|
||||
sql.purge_obsolete_search_tokens()
|
||||
|
||||
@classmethod
|
||||
def register_cronjobs(cls):
|
||||
cls.register_cronjob(CronJob(cls.clean_sessions, minutes=[0], name='clean_sessions'))
|
||||
|
@ -702,6 +706,7 @@ class QommonPublisher(Publisher):
|
|||
cls.register_cronjob(
|
||||
CronJob(cls.clean_loggederrors, hours=[3], minutes=[0], name='clean_loggederrors')
|
||||
)
|
||||
cls.register_cronjob(CronJob(cls.clean_search_tokens, weekdays=[0], hours=[1], minutes=[0], name='clean_tokens'))
|
||||
|
||||
_initialized = False
|
||||
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
|
|
117
wcs/sql.py
117
wcs/sql.py
|
@ -95,6 +95,10 @@ SQL_TYPE_MAPPING = {
|
|||
'computed': 'jsonb',
|
||||
}
|
||||
|
||||
def _table_exists(cur, table_name):
|
||||
cur.execute("SELECT 1 FROM pg_class WHERE relname = %s;", (table_name,))
|
||||
rows = cur.fetchall()
|
||||
return len(rows) > 0;
|
||||
|
||||
class WcsPgConnection(psycopg2.extensions.connection):
|
||||
def __init__(self, *args, **kwargs):
|
||||
|
@ -1579,6 +1583,8 @@ def do_global_views(conn, cur):
|
|||
% (name, category.id)
|
||||
)
|
||||
|
||||
init_search_tokens_triggers(cur)
|
||||
|
||||
|
||||
def clean_global_views(conn, cur):
|
||||
# Purge of any dead data
|
||||
|
@ -1671,11 +1677,109 @@ def init_global_table(conn=None, cur=None):
|
|||
endpoint_status=endpoint_status_filter,
|
||||
)
|
||||
)
|
||||
init_search_tokens_data(cur)
|
||||
|
||||
if own_conn:
|
||||
cur.close()
|
||||
|
||||
|
||||
def init_search_tokens(conn=None, cur=None):
|
||||
own_cur = False
|
||||
if cur is None:
|
||||
own_cur = True
|
||||
conn, cur = get_connection_and_cursor()
|
||||
|
||||
if _table_exists(cur, 'wcs_search_tokens'):
|
||||
if own_cur:
|
||||
cur.close()
|
||||
return
|
||||
|
||||
# Create table
|
||||
cur.execute("CREATE TABLE wcs_search_tokens(token TEXT PRIMARY KEY);")
|
||||
|
||||
# Create triggers
|
||||
init_search_tokens_triggers(cur)
|
||||
|
||||
# Fill table
|
||||
init_search_tokens_data(cur)
|
||||
|
||||
# Index at the end, small performance trick... not that useful, but it's free...
|
||||
cur.execute("CREATE EXTENSION IF NOT EXISTS pg_trgm;")
|
||||
cur.execute("CREATE INDEX IF NOT EXISTS wcs_search_tokens_trgm ON wcs_search_tokens USING gin(token gin_trgm_ops);")
|
||||
|
||||
# And last: functions to use this brand new table
|
||||
cur.execute("CREATE OR REPLACE AGGREGATE tsquery_agg_or (tsquery) (sfunc=tsquery_or, stype=tsquery);")
|
||||
cur.execute("CREATE OR REPLACE AGGREGATE tsquery_agg_and (tsquery) (sfunc=tsquery_and, stype=tsquery);")
|
||||
cur.execute("""CREATE OR REPLACE FUNCTION public.wcs_tsquery(text)
|
||||
RETURNS tsquery
|
||||
LANGUAGE sql
|
||||
STABLE
|
||||
AS $function$
|
||||
with
|
||||
tokenized as (select unnest(regexp_split_to_array($1, '\s+')) w),
|
||||
super_tokenized as (
|
||||
select w,
|
||||
coalesce((select plainto_tsquery(perfect.token) from wcs_search_tokens perfect where perfect.token = plainto_tsquery(w)::text),
|
||||
tsquery_agg_or(plainto_tsquery(partial.token) order by partial.token <-> w desc),
|
||||
plainto_tsquery(w)) tokens
|
||||
from tokenized
|
||||
left join wcs_search_tokens partial on partial.token % w and w not similar to '%[0-9]{2,}%'
|
||||
group by w)
|
||||
select tsquery_agg_and(tokens) from super_tokenized;
|
||||
$function$;""")
|
||||
|
||||
if own_cur:
|
||||
cur.close()
|
||||
|
||||
def init_search_tokens_triggers(cur):
|
||||
# We define only appending triggers, ie on INSERT and UPDATE.
|
||||
# It would be far heavier to maintain deletions here, and having extra data has
|
||||
# no or marginal side effect on search performances, and absolutely no impact
|
||||
# on search results.
|
||||
# Instead, a weekly cron job will delete obsolete entries, thus making it sure no
|
||||
# personal data is kept uselessly.
|
||||
|
||||
if not(_table_exists(cur, 'wcs_search_tokens')):
|
||||
# abort trigger creation if tokens table doesn't exist yet
|
||||
return
|
||||
|
||||
# First part: the appending function
|
||||
cur.execute("""CREATE OR REPLACE FUNCTION wcs_search_tokens_trigger_fn ()
|
||||
RETURNS trigger
|
||||
LANGUAGE plpgsql
|
||||
AS $function$
|
||||
BEGIN
|
||||
INSERT INTO wcs_search_tokens SELECT unnest(tsvector_to_array(NEW.fts)) ON CONFLICT(token) DO NOTHING;
|
||||
RETURN NEW;
|
||||
END;
|
||||
$function$;""")
|
||||
|
||||
# Second part: insert and update triggers for wcs_all_forms
|
||||
cur.execute("CREATE TRIGGER wcs_all_forms_fts_trg_ins AFTER INSERT ON wcs_all_forms FOR EACH ROW WHEN (NEW.fts IS NOT NULL) EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();")
|
||||
cur.execute("CREATE TRIGGER wcs_all_forms_fts_trg_upd AFTER UPDATE OF fts ON wcs_all_forms FOR EACH ROW WHEN (NEW.fts IS NOT NULL) EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();")
|
||||
|
||||
# Third part: insert and update triggers for searchable_formdefs
|
||||
cur.execute("CREATE TRIGGER searchable_formdefs_fts_trg_ins AFTER INSERT ON searchable_formdefs FOR EACH ROW WHEN (NEW.fts IS NOT NULL) EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();")
|
||||
cur.execute("CREATE TRIGGER searchable_formdefs_fts_trg_upd AFTER UPDATE OF fts ON searchable_formdefs FOR EACH ROW WHEN (NEW.fts IS NOT NULL) EXECUTE PROCEDURE wcs_search_tokens_trigger_fn();")
|
||||
|
||||
def init_search_tokens_data(cur):
|
||||
if not(_table_exists(cur, 'wcs_search_tokens')):
|
||||
# abort table data initialization if tokens table doesn't exist yet
|
||||
return
|
||||
|
||||
cur.execute("INSERT INTO wcs_search_tokens SELECT unnest(tsvector_to_array(fts)) FROM wcs_all_forms ON CONFLICT(token) DO NOTHING;")
|
||||
cur.execute("INSERT INTO wcs_search_tokens SELECT unnest(tsvector_to_array(fts)) FROM searchable_formdefs ON CONFLICT(token) DO NOTHING;")
|
||||
|
||||
def purge_obsolete_search_tokens(cur=None):
|
||||
own_cur = False
|
||||
if cur is None:
|
||||
own_cur = True
|
||||
conn, cur = get_connection_and_cursor()
|
||||
|
||||
cur.execute("DELETE FROM wcs_search_tokens WHERE token NOT IN (SELECT unnest(tsvector_to_array(fts)) FROM wcs_all_forms) AND token NOT IN (SELECT unnest(tsvector_to_array(fts)) FROM wcs_all_forms);")
|
||||
if own_cur:
|
||||
cur.close()
|
||||
|
||||
class SqlMixin:
|
||||
_table_name = None
|
||||
_numerical_id = True
|
||||
|
@ -1751,7 +1855,7 @@ class SqlMixin:
|
|||
|
||||
sql_statement = (
|
||||
'''SELECT id FROM %s
|
||||
WHERE fts @@ plainto_tsquery(%%(value)s)'''
|
||||
WHERE fts @@ wcs_tsquery(%%(value)s)'''
|
||||
% cls._table_name
|
||||
)
|
||||
cur.execute(sql_statement, {'value': FtsMatch.get_fts_value(query)})
|
||||
|
@ -2217,7 +2321,7 @@ class SqlMixin:
|
|||
except IndexError:
|
||||
pass
|
||||
else:
|
||||
sql_statement += ' ORDER BY ts_rank(fts, plainto_tsquery(%%(c%s)s)) DESC' % id(fts.value)
|
||||
sql_statement += ' ORDER BY ts_rank(fts, wcs_tsquery(%%(c%s)s)) DESC' % id(fts.value)
|
||||
else:
|
||||
sql_statement += cls.get_order_by_clause(order_by)
|
||||
cur.execute(sql_statement, parameters)
|
||||
|
@ -4798,6 +4902,7 @@ class SearchableFormDef(SqlMixin):
|
|||
CardDef.select(ignore_errors=True), FormDef.select(ignore_errors=True)
|
||||
):
|
||||
cls.update(obj=objectdef)
|
||||
init_search_tokens_data(cur)
|
||||
|
||||
@classmethod
|
||||
def update(cls, obj=None, removed_obj_type=None, removed_obj_id=None):
|
||||
|
@ -4835,7 +4940,7 @@ class SearchableFormDef(SqlMixin):
|
|||
def search(cls, obj_type, string):
|
||||
_, cur = get_connection_and_cursor()
|
||||
cur.execute(
|
||||
'SELECT object_id FROM searchable_formdefs WHERE fts @@ plainto_tsquery(%s)',
|
||||
'SELECT object_id FROM searchable_formdefs WHERE fts @@ wcs_tsquery(%s)',
|
||||
(FtsMatch.get_fts_value(string),),
|
||||
)
|
||||
ids = [x[0] for x in cur.fetchall()]
|
||||
|
@ -5100,7 +5205,7 @@ def get_period_total(
|
|||
# latest migration, number + description (description is not used
|
||||
# programmaticaly but will make sure git conflicts if two migrations are
|
||||
# separately added with the same number)
|
||||
SQL_LEVEL = (105, 'change test result json structure')
|
||||
SQL_LEVEL = (106, 'improved fts method')
|
||||
|
||||
|
||||
def migrate_global_views(conn, cur):
|
||||
|
@ -5433,6 +5538,10 @@ def migrate():
|
|||
for formdef in FormDef.select() + CardDef.select():
|
||||
do_formdef_tables(formdef, rebuild_views=False, rebuild_global_views=False)
|
||||
|
||||
if sql_level < 106:
|
||||
# 106: new fts mechanism with tokens table
|
||||
init_search_tokens()
|
||||
|
||||
if sql_level != SQL_LEVEL[0]:
|
||||
cur.execute(
|
||||
'''UPDATE wcs_meta SET value = %s, updated_at=NOW() WHERE key = %s''',
|
||||
|
|
|
@ -370,7 +370,7 @@ class FtsMatch(Criteria):
|
|||
return unidecode.unidecode(value)
|
||||
|
||||
def as_sql(self):
|
||||
return 'fts @@ plainto_tsquery(%%(c%s)s)' % id(self.value)
|
||||
return 'fts @@ wcs_tsquery(%%(c%s)s)' % id(self.value)
|
||||
|
||||
|
||||
class ElementEqual(Criteria):
|
||||
|
|
|
@ -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')
|
||||
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:
|
||||
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'
|
||||
|
|
|
@ -160,7 +160,7 @@ class WorkflowTestAction(XmlStorableObject):
|
|||
def render_as_line(self):
|
||||
for field, dummy in self.XML_NODES:
|
||||
if field not in self.optional_fields and not getattr(self, field):
|
||||
return 'not configured'
|
||||
return _('not configured')
|
||||
|
||||
return self.details_label
|
||||
|
||||
|
@ -179,7 +179,7 @@ class ButtonClick(WorkflowTestAction):
|
|||
|
||||
@property
|
||||
def details_label(self):
|
||||
return 'Click on "%s"' % self.button_name
|
||||
return _('Click on "%s"') % self.button_name
|
||||
|
||||
def perform(self, formdata, user):
|
||||
status = formdata.get_status()
|
||||
|
@ -227,7 +227,7 @@ class AssertStatus(WorkflowTestAction):
|
|||
|
||||
@property
|
||||
def details_label(self):
|
||||
return 'Status is "%s"' % self.status_name
|
||||
return _('Status is "%s"') % self.status_name
|
||||
|
||||
def perform(self, formdata, user):
|
||||
status = formdata.get_status()
|
||||
|
@ -347,7 +347,7 @@ class SkipTime(WorkflowTestAction):
|
|||
if hasattr(item, 'has_valid_timeout') and item.has_valid_timeout():
|
||||
jump_actions.append(item)
|
||||
|
||||
delay = wf.jump.get_min_jumps_delay(status.items)
|
||||
delay = wf.jump.get_min_jumps_delay(jump_actions)
|
||||
|
||||
if formdata.last_update_time > formdata.frozen_receipt_time - datetime.timedelta(seconds=delay):
|
||||
return
|
||||
|
|
|
@ -3176,7 +3176,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()
|
||||
r += self.get_parameter_view_value(widget, parameter)
|
||||
r += htmltext('</li>')
|
||||
r += htmltext('</ul>')
|
||||
|
|
Loading…
Reference in New Issue