Commit eebdab78 authored by Tomáš Peterka's avatar Tomáš Peterka

[hal_json] Render Report by passing Selection.params into search method params

-  ensure serializability of such params
-  unfortunately construct Selection with given params since we do not use ReportSection.pushReport
parent b5eb2cc3
...@@ -46,7 +46,7 @@ MARKER = [] ...@@ -46,7 +46,7 @@ MARKER = []
if REQUEST is None: if REQUEST is None:
REQUEST = context.REQUEST REQUEST = context.REQUEST
# raise Unauthorized
if response is None: if response is None:
response = REQUEST.RESPONSE response = REQUEST.RESPONSE
...@@ -61,6 +61,43 @@ def byteify(string): ...@@ -61,6 +61,43 @@ def byteify(string):
else: else:
return string return string
def ensureSerializable(obj):
"""Ensure obj and all sub-objects are JSON serializable."""
if isinstance(obj, dict):
for key in obj:
obj[key] = ensureSerializable(obj[key])
# throw away date's type information and later reconstruct as Zope's DateTime
if isinstance(obj, DateTime):
return obj.ISO()
if isinstance(obj, (datetime.datetime, datetime.date, datetime.time)):
return obj.isoformat()
# we don't check other isinstances - we believe that iterables don't contain unserializable objects
return obj
datetime_iso_re = re.compile(r'^\d{4}-\d{2}-\d{2} |T\d{2}:\d{2}:\d{2}.*$')
time_iso_re = re.compile(r'^(\d{2}):(\d{2}):(\d{2}).*$')
def ensureDeserialized(obj):
"""Deserialize classes serialized by our own `ensureSerializable`.
Method `biteify` must not be called on the result because it would revert out
deserialization by calling __str__ on constructed classes.
"""
if isinstance(obj, dict):
for key in obj:
obj[key] = ensureDeserialized(obj[key])
# seems that default __str__ method is good enough
if isinstance(obj, str):
# Zope's DateTime must be good enough for everyone
if datetime_iso_re.match(obj):
return DateTime(obj)
if time_iso_re.match(obj):
match_obj = time_iso_re.match(obj)
return datetime.time(*tuple(map(int, match_obj.groups())))
return obj
def getProtectedProperty(document, select): def getProtectedProperty(document, select):
"""getProtectedProperty is a security-aware substitution for builtin `getattr` """getProtectedProperty is a security-aware substitution for builtin `getattr`
...@@ -131,7 +168,7 @@ def getUidAndAccessorForAnything(search_result, result_index, traversed_document ...@@ -131,7 +168,7 @@ def getUidAndAccessorForAnything(search_result, result_index, traversed_document
value = getter(random_object, "value") value = getter(random_object, "value")
""" """
if hasattr(search_result, "getObject"): if hasattr(search_result, "getObject"):
# search_result = search_result.getObject() # "Brain" object - which simulates DB Cursor thus result must have UID
contents_uid = search_result.uid contents_uid = search_result.uid
# every document indexed in catalog has to have relativeUrl # every document indexed in catalog has to have relativeUrl
contents_relative_url = getRealRelativeUrl(search_result) contents_relative_url = getRealRelativeUrl(search_result)
...@@ -307,18 +344,21 @@ url_template_dict = { ...@@ -307,18 +344,21 @@ url_template_dict = {
default_document_uri_template = url_template_dict["jio_get_template"] default_document_uri_template = url_template_dict["jio_get_template"]
Base_translateString = context.getPortalObject().Base_translateString Base_translateString = context.getPortalObject().Base_translateString
def getRealRelativeUrl(document): def getRealRelativeUrl(document):
return '/'.join(portal.portal_url.getRelativeContentPath(document)) return '/'.join(portal.portal_url.getRelativeContentPath(document))
def getFormRelativeUrl(form): def getFormRelativeUrl(form):
return portal.portal_catalog( return portal.portal_catalog(
portal_type="ERP5 Form", portal_type=("ERP5 Form", "ERP5 Report"),
uid=form.getUid(), uid=form.getUid(),
id=form.getId(), id=form.getId(),
limit=1, limit=1,
select_dict={'relative_url': None} select_dict={'relative_url': None}
)[0].relative_url )[0].relative_url
def getFieldDefault(traversed_document, field, key, value=None): def getFieldDefault(traversed_document, field, key, value=None):
# REQUEST.get(field.id, field.get_value("default")) # REQUEST.get(field.id, field.get_value("default"))
result = traversed_document.Field_getDefaultValue(field, key, value, REQUEST) result = traversed_document.Field_getDefaultValue(field, key, value, REQUEST)
...@@ -342,6 +382,10 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -342,6 +382,10 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
if key is None: if key is None:
key = field.generate_field_key(key_prefix=key_prefix) key = field.generate_field_key(key_prefix=key_prefix)
if meta_type == "ProxyField":
# resolve the base meta_type
meta_type = field.getRecursiveTemplateField().meta_type
result = { result = {
"type": meta_type, "type": meta_type,
"title": Base_translateString(field.get_value("title")), "title": Base_translateString(field.get_value("title")),
...@@ -351,7 +395,6 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -351,7 +395,6 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
"hidden": field.get_value("hidden"), "hidden": field.get_value("hidden"),
"description": field.get_value("description"), "description": field.get_value("description"),
} }
if "Field" in meta_type: if "Field" in meta_type:
# fields have default value and can be required (unlike boxes) # fields have default value and can be required (unlike boxes)
result.update({ result.update({
...@@ -359,12 +402,7 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -359,12 +402,7 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
"default": getFieldDefault(traversed_document, field, result["key"], value), "default": getFieldDefault(traversed_document, field, result["key"], value),
}) })
if meta_type == "ProxyField": # start the actual "switch" on field's meta_type here
return renderField(traversed_document, field, form, value,
meta_type=field.getRecursiveTemplateField().meta_type,
key=key, key_prefix=key_prefix,
selection_params=selection_params)
if meta_type in ("ListField", "RadioField", "ParallelListField", "MultiListField"): if meta_type in ("ListField", "RadioField", "ParallelListField", "MultiListField"):
result.update({ result.update({
# XXX Message can not be converted to json as is # XXX Message can not be converted to json as is
...@@ -457,7 +495,7 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -457,7 +495,7 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
except Unauthorized: except Unauthorized:
jump_reference_list = [] jump_reference_list = []
result.update({ result.update({
"editable": False "editable": False
}) })
query = url_template_dict["jio_search_template"] % { query = url_template_dict["jio_search_template"] % {
"query": make_query({"query": sql_catalog.buildQuery( "query": make_query({"query": sql_catalog.buildQuery(
...@@ -511,7 +549,6 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -511,7 +549,6 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
if rel_cache[key] is not MARKER: if rel_cache[key] is not MARKER:
REQUEST.set(key, rel_cache[key]) REQUEST.set(key, rel_cache[key])
result.update({ result.update({
"url": relative_url, "url": relative_url,
"translated_portal_types": translated_portal_type, "translated_portal_types": translated_portal_type,
...@@ -552,11 +589,20 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -552,11 +589,20 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
return result return result
if meta_type == "ListBox": if meta_type == "ListBox":
"""Display list of objects with optional search/sort capabilities on columns from catalog.""" """Display list of objects with optional search/sort capabilities on columns from catalog.
We might be inside a ReportBox which is inside a parent form BUT we still have access to
the original REQUEST with sent POST values from the parent form. We can save those
values into our query method and reconstruct them meanwhile calling asynchronous jio.allDocs.
"""
_translate = Base_translateString _translate = Base_translateString
column_list = [(name, _translate(title)) for name, title in field.get_value("columns")] # column definition in ListBox own value 'columns' is superseded by dynamic
editable_column_list = [(name, _translate(title)) for name, title in field.get_value("editable_columns")] # column definition from Selection for specific Report ListBoxes; the same for editable_columns
column_list = [(name, _translate(title)) for name, title in (selection_params.get('selection_columns', [])
or field.get_value("columns"))]
editable_column_list = [(name, _translate(title)) for name, title in (selection_params.get('editable_columns', [])
or field.get_value("editable_columns"))]
catalog_column_list = [(name, title) catalog_column_list = [(name, title)
for name, title in column_list for name, title in column_list
if sql_catalog.isValidColumn(name)] if sql_catalog.isValidColumn(name)]
...@@ -568,25 +614,29 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -568,25 +614,29 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
# try to get specified sortable columns and fail back to searchable fields # try to get specified sortable columns and fail back to searchable fields
sort_column_list = [(name, _translate(title)) sort_column_list = [(name, _translate(title))
for name, title in field.get_value("sort_columns") for name, title in (selection_params.get('selection_sort_order', [])
or field.get_value("sort_columns"))
if sql_catalog.isValidColumn(name)] or search_column_list if sql_catalog.isValidColumn(name)] or search_column_list
# portal_type list can be overriden by selection too
# since it can be intentionally empty we don't override with non-empty field value
portal_type_list = selection_params.get("portal_type", field.get_value('portal_types'))
# requirement: get only sortable/searchable columns which are already displayed in listbox # requirement: get only sortable/searchable columns which are already displayed in listbox
# see https://lab.nexedi.com/nexedi/erp5/blob/HEAD/product/ERP5Form/ListBox.py#L1004 # see https://lab.nexedi.com/nexedi/erp5/blob/HEAD/product/ERP5Form/ListBox.py#L1004
# implemented in javascript in the end # implemented in javascript in the end
# see https://lab.nexedi.com/nexedi/erp5/blob/master/bt5/erp5_web_renderjs_ui/PathTemplateItem/web_page_module/rjs_gadget_erp5_listbox_js.js#L163 # see https://lab.nexedi.com/nexedi/erp5/blob/master/bt5/erp5_web_renderjs_ui/PathTemplateItem/web_page_module/rjs_gadget_erp5_listbox_js.js#L163
portal_types = field.get_value('portal_types') default_params = dict(field.get_value('default_params')) # default_params is a list of tuples
default_params = dict(field.get_value('default_params'))
default_params['ignore_unknown_columns'] = True default_params['ignore_unknown_columns'] = True
if selection_params is not None: # we abandoned Selections in RJS thus we mix selection query parameters into
default_params.update(selection_params) # listbox's default parameters
# How to implement pagination? default_params.update(selection_params)
# default_params.update(REQUEST.form)
lines = field.get_value('lines') # ListBoxes in report view has portal_type defined already in default_params
list_method_query_dict = dict( # in that case we prefer non_empty version
portal_type=[x[1] for x in portal_types], **default_params list_method_query_dict = default_params.copy()
) if not list_method_query_dict.get("portal_type", []):
list_method_query_dict["portal_type"] = [x for x, _ in portal_type_list]
list_method_custom = None list_method_custom = None
# Search for non-editable documents - all reports goes here # Search for non-editable documents - all reports goes here
...@@ -626,8 +676,11 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -626,8 +676,11 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
"relative_url": traversed_document.getRelativeUrl().replace("/", "%2F"), "relative_url": traversed_document.getRelativeUrl().replace("/", "%2F"),
"form_relative_url": "%s/%s" % (getFormRelativeUrl(form), field.id), "form_relative_url": "%s/%s" % (getFormRelativeUrl(form), field.id),
"list_method": list_method_name, "list_method": list_method_name,
"default_param_json": urlsafe_b64encode(json.dumps(list_method_query_dict)) "default_param_json": urlsafe_b64encode(
json.dumps(ensureSerializable(list_method_query_dict)))
} }
# once we imprint `default_params` into query string of 'list method' we
# don't want them to propagate to the query as well
list_method_query_dict = {} list_method_query_dict = {}
elif (list_method_name == "portal_catalog"): elif (list_method_name == "portal_catalog"):
pass pass
...@@ -639,30 +692,10 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -639,30 +692,10 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
"script_id": script.id, "script_id": script.id,
"relative_url": traversed_document.getRelativeUrl().replace("/", "%2F"), "relative_url": traversed_document.getRelativeUrl().replace("/", "%2F"),
"list_method": list_method_name, "list_method": list_method_name,
"default_param_json": urlsafe_b64encode(json.dumps(list_method_query_dict)) "default_param_json": urlsafe_b64encode(json.dumps(ensureSerializable(list_method_query_dict)))
} }
list_method_query_dict = {} list_method_query_dict = {}
# row_list = list_method(limit=lines, portal_type=portal_types,
# **default_params)
# line_list = []
# for row in row_list:
# document = row.getObject()
# line = {
# "url": url_template_dict["document_hal"] % {
# "root_url": site_root.absolute_url(),
# "relative_url": document.getRelativeUrl(),
# "script_id": script.id
# }
# }
# for property, title in columns:
# prop = document.getProperty(property)
# if same_type(prop, DateTime()):
# prop = "XXX Serialize DateTime"
# line[title] = prop
# line["_relative_url"] = document.getRelativeUrl()
# line_list.append(line)
result.update({ result.update({
"column_list": column_list, "column_list": column_list,
"search_column_list": search_column_list, "search_column_list": search_column_list,
...@@ -670,9 +703,9 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key ...@@ -670,9 +703,9 @@ def renderField(traversed_document, field, form, value=None, meta_type=None, key
"sort_column_list": sort_column_list, "sort_column_list": sort_column_list,
"editable_column_list": editable_column_list, "editable_column_list": editable_column_list,
"show_anchor": field.get_value("anchor"), "show_anchor": field.get_value("anchor"),
"portal_type": portal_types, "portal_type": portal_type_list,
"lines": lines, "lines": field.get_value('lines'),
"default_params": default_params, "default_params": ensureSerializable(default_params),
"list_method": list_method_name, "list_method": list_method_name,
"query": url_template_dict["jio_search_template"] % { "query": url_template_dict["jio_search_template"] % {
"query": make_query({ "query": make_query({
...@@ -820,26 +853,84 @@ def renderForm(traversed_document, form, response_dict, key_prefix=None, selecti ...@@ -820,26 +853,84 @@ def renderForm(traversed_document, form, response_dict, key_prefix=None, selecti
} }
if (form.pt == 'report_view'): if (form.pt == 'report_view'):
# reports are expected to return list of ReportSection which is a wrapper
# around a form - thus we will need to render those forms
report_item_list = [] report_item_list = []
report_result_list = [] report_result_list = []
for field in form.get_fields(): for field in form.get_fields():
if field.getRecursiveTemplateField().meta_type == 'ReportBox': if field.getRecursiveTemplateField().meta_type == 'ReportBox':
# ReportBox.render returns a list of ReportSection classes which are
# just containers for FormId(s) usually containing one ListBox
# and its search/query parameters hidden in `selection_params`
# `path` contains relative_url of intended CONTEXT for underlaying ListBox
report_item_list.extend(field.render()) report_item_list.extend(field.render())
j = 0 # ERP5 Report document differs from a ERP5 Form in only one thing: it has
for report_item in report_item_list: # `report_method` attached to it - thus we call it right here
report_context = report_item.getObject(portal) if hasattr(form, 'report_method') and getattr(form, 'report_method', ""):
report_prefix = 'x%s' % j report_method_name = getattr(form, 'report_method')
j += 1 report_method = getattr(traversed_document, report_method_name)
report_item_list.extend(report_method())
for report_index, report_item in enumerate(report_item_list):
report_context = report_item.getObject(traversed_document)
report_prefix = 'x%s' % report_index
report_title = report_item.getTitle() report_title = report_item.getTitle()
# report_class = "report_title_level_%s" % report_item.getLevel() # report_class = "report_title_level_%s" % report_item.getLevel()
report_form = report_item.getFormId() report_form = report_item.getFormId()
report_result = {'_links': {}} report_result = {'_links': {}}
renderForm(traversed_document, getattr(report_context, report_item.getFormId()), # some reports save a lot of unserializable data (datetime.datetime) and
report_result, key_prefix=report_prefix, # key "portal_type" (don't confuse with "portal_types" in ListBox) into
selection_params=report_item.selection_params) # report_item.selection_params thus we need to take that into account in
# ListBox field
#
# Selection Params are parameters for embedded ListBox's List Method
# and it must be passed in `default_json_param` field (might contain
# unserializable data types thus we need to take care of that
# In order not to lose information we put all ReportSection attributes
# inside the report selection params
report_form_params = report_item.selection_params.copy() \
if report_item.selection_params is not None \
else {}
if report_item.selection_name:
selection_name = report_prefix + "_" + report_item.selection_name
report_form_params.update(selection_name=selection_name)
# this should load selections with correct values - since it is modifying
# global state in the backend we have nothing more to do here
# I could not find where the code stores params in selection with render
# prefix - maybe it in some `render` method where it should not be
# Of course it is ugly, terrible and should be removed!
selection_tool = context.getPortalObject().portal_selections
selection_tool.getSelectionFor(selection_name, REQUEST)
selection_tool.setSelectionParamsFor(selection_name, report_form_params)
selection_tool.setSelectionColumns(selection_name, report_item.selection_columns)
if report_item.selection_columns:
report_form_params.update(selection_columns=report_item.selection_columns)
if report_item.selection_sort_order:
report_form_params.update(selection_sort_order=report_item.selection_sort_order)
# Report section is just a wrapper around form thus we render it right
# we keep traversed_document because its Portal Type Class should be
# addressable by the user = have actions (object_view) attached to it
# BUT! when Report Section defines `path` that is the new context for
# form rendering and subsequent searches...
renderForm(traversed_document if not report_item.path else report_context,
getattr(report_context, report_item.getFormId()),
report_result,
key_prefix=report_prefix,
selection_params=report_form_params) # used to be only report_item.selection_params
# Report Title is important since there are more section on report page
# but often they render the same form with different data so we need to
# distinguish by the title at least.
report_result['title'] = report_title
report_result_list.append(report_result) report_result_list.append(report_result)
response_dict['report_section_list'] = report_result_list response_dict['report_section_list'] = report_result_list
# end-if report_section
for key, value in previous_request_other.items():
if value is not None:
REQUEST.set(key, value)
# XXX form action update, etc # XXX form action update, etc
def renderRawField(field): def renderRawField(field):
...@@ -882,6 +973,7 @@ def renderRawField(field): ...@@ -882,6 +973,7 @@ def renderRawField(field):
def renderFormDefinition(form, response_dict): def renderFormDefinition(form, response_dict):
"""Form "definition" is configurable in Zope admin: Form -> Order."""
group_list = [] group_list = []
for group in form.Form_getGroupTitleAndId(): for group in form.Form_getGroupTitleAndId():
...@@ -1000,19 +1092,23 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1000,19 +1092,23 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
action_dict = {} action_dict = {}
# result_dict['_relative_url'] = traversed_document.getRelativeUrl() # result_dict['_relative_url'] = traversed_document.getRelativeUrl()
result_dict['title'] = traversed_document.getTitle() result_dict['title'] = traversed_document.getTitle()
# Add a link to the portal type if possible # Add a link to the portal type if possible
if not is_portal: if not is_portal:
result_dict['_links']['type'] = { # traversed_document should always have its Portal Type in ERP5 Portal Types
"href": default_document_uri_template % { # thus attached actions to it so it is viewable
"root_url": site_root.absolute_url(), document_type_name = traversed_document.getPortalType()
"relative_url": portal.portal_types[traversed_document.getPortalType()]\ document_type = getattr(portal.portal_types, document_type_name, None)
.getRelativeUrl(), if document_type is not None:
"script_id": script.id result_dict['_links']['type'] = {
}, "href": default_document_uri_template % {
"name": Base_translateString(traversed_document.getPortalType()) "root_url": site_root.absolute_url(),
} "relative_url": document_type.getRelativeUrl(),
"script_id": script.id
},
"name": Base_translateString(traversed_document.getPortalType())
}
# Return info about container # Return info about container
if not is_portal: if not is_portal:
container = traversed_document.getParentValue() container = traversed_document.getParentValue()
...@@ -1123,7 +1219,6 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1123,7 +1219,6 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
# renderer_form = traversed_document.restrictedTraverse(form_id, None) # renderer_form = traversed_document.restrictedTraverse(form_id, None)
# XXX Proxy field are not correctly handled in traversed_document of web site # XXX Proxy field are not correctly handled in traversed_document of web site
renderer_form = getattr(traversed_document, form_id) renderer_form = getattr(traversed_document, form_id)
# traversed_document.log(form_id)
if (renderer_form is not None): if (renderer_form is not None):
embedded_dict = { embedded_dict = {
'_links': { '_links': {
...@@ -1133,19 +1228,31 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1133,19 +1228,31 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
} }
} }
# Put all query parameters (?reset:int=1&workflow_action=start_action) in request to mimic usual form display # Put all query parameters (?reset:int=1&workflow_action=start_action) in request to mimic usual form display
query_param_dict = {}
query_split = embedded_url.split('?', 1) query_split = embedded_url.split('?', 1)
if len(query_split) == 2: if len(query_split) == 2:
for query_parameter in query_split[1].split("&"): for query_parameter in query_split[1].split("&"):
query_key, query_value = query_parameter.split("=") query_key, query_value = query_parameter.split('=')
REQUEST.set(query_key, query_value) # often + is used instead of %20 so we replace for space here
query_param_dict[query_key] = query_value.replace("+", " ")
# set URL params into REQUEST (just like it was sent by form)
for query_key, query_value in query_param_dict.items():
REQUEST.set(query_key, query_value)
# unfortunatelly some people use Scripts as targets for Workflow
# transactions - thus we need to check and mitigate
if "Script" in renderer_form.meta_type:
# we suppose that the script takes only what is given in the URL params
return renderer_form(**query_param_dict)
renderForm(traversed_document, renderer_form, embedded_dict) renderForm(traversed_document, renderer_form, embedded_dict)
result_dict['_embedded'] = { result_dict['_embedded'] = {
'_view': embedded_dict '_view': embedded_dict
# embedded_action_key: embedded_dict # embedded_action_key: embedded_dict
} }
# result_dict['_links']["_view"] = {"href": embedded_url} # result_dict['_links']["_view"] = {"href": embedded_url}
# Include properties in document JSON # Include properties in document JSON
# XXX Extract from renderer form? # XXX Extract from renderer form?
""" """
...@@ -1227,7 +1334,7 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1227,7 +1334,7 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
else: else:
traversed_document_portal_type = traversed_document.getPortalType() traversed_document_portal_type = traversed_document.getPortalType()
if traversed_document_portal_type == "ERP5 Form": if traversed_document_portal_type in ("ERP5 Form", "ERP5 Report"):
renderFormDefinition(traversed_document, result_dict) renderFormDefinition(traversed_document, result_dict)
response.setHeader("Cache-Control", "private, max-age=1800") response.setHeader("Cache-Control", "private, max-age=1800")
response.setHeader("Vary", "Cookie,Authorization,Accept-Encoding") response.setHeader("Vary", "Cookie,Authorization,Accept-Encoding")
...@@ -1250,19 +1357,18 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1250,19 +1357,18 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
"template": True "template": True
} }
} }
# Define document action # Define document action
if action_dict: if action_dict:
result_dict['_actions'] = action_dict result_dict['_actions'] = action_dict
elif mode == 'search': elif mode == 'search':
################################################# #################################################
# Portal catalog search # Portal catalog search
# #
# Possible call arguments example: # Possible call arguments example:
# form_relative_url: portal_skins/erp5_web/WebSite_view/listbox # form_relative_url: portal_skins/erp5_web/WebSite_view/listbox
# list_method: objectValues (Script providing listing) # list_method: "objectValues" (Script providing items)
# default_param_json: <base64 encoded JSON> (Additional search params) # default_param_json: <base64 encoded JSON> (Additional search params)
# query: <str> (term for fulltext search) # query: <str> (term for fulltext search)
# select_list: ['int_index', 'id', 'title', ...] (column names to select) # select_list: ['int_index', 'id', 'title', ...] (column names to select)
...@@ -1309,9 +1415,13 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1309,9 +1415,13 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
"sort_on": () # default is an empty tuple "sort_on": () # default is an empty tuple
} }
if default_param_json is not None: if default_param_json is not None:
catalog_kw.update(byteify(json.loads(urlsafe_b64decode(default_param_json)))) catalog_kw.update(
ensureDeserialized(
byteify(
json.loads(urlsafe_b64decode(default_param_json)))))
if query: if query:
catalog_kw["full_text"] = query catalog_kw["full_text"] = query
if sort_on is not None: if sort_on is not None:
def parseSortOn(raw_string): def parseSortOn(raw_string):
"""Turn JSON serialized array into a tuple (col_name, order).""" """Turn JSON serialized array into a tuple (col_name, order)."""
...@@ -1436,6 +1546,7 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1436,6 +1546,7 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
} }
for select in select_list: for select in select_list:
# every `select` can have a template field or be just a exotic getter for a value
if editable_field_dict.has_key(select): if editable_field_dict.has_key(select):
# cell has a Form Field template thus render it using the field # cell has a Form Field template thus render it using the field
# fields are nice because they are standard # fields are nice because they are standard
...@@ -1461,8 +1572,12 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None, ...@@ -1461,8 +1572,12 @@ def calculateHateoas(is_portal=None, is_site_root=None, traversed_document=None,
contents_item[select] = getAttrFromAnything(search_result, select, property_getter, property_hasser, {'brain': search_result}) contents_item[select] = getAttrFromAnything(search_result, select, property_getter, property_hasser, {'brain': search_result})
# endfor select # endfor select
contents_list.append(contents_item) contents_list.append(contents_item)
result_dict['_embedded']['contents'] = contents_list result_dict['_embedded']['contents'] = ensureSerializable(contents_list)
# We should cleanup the selection if it exists in catalog params BUT
# we cannot because it requires escalated Permission.'modifyPortal' so
# the correct solution would be to ReportSection.popReport but unfortunately
# we don't have it anymore because we are asynchronous
return result_dict return result_dict
elif mode == 'form': elif mode == 'form':
...@@ -1572,6 +1687,7 @@ hateoas = calculateHateoas(is_portal=temp_is_portal, is_site_root=temp_is_site_r ...@@ -1572,6 +1687,7 @@ hateoas = calculateHateoas(is_portal=temp_is_portal, is_site_root=temp_is_site_r
restricted=restricted, list_method=list_method, restricted=restricted, list_method=list_method,
default_param_json=default_param_json, default_param_json=default_param_json,
form_relative_url=form_relative_url) form_relative_url=form_relative_url)
if hateoas == "": if hateoas == "":
return hateoas return hateoas
else: else:
......
...@@ -9,8 +9,11 @@ from functools import wraps ...@@ -9,8 +9,11 @@ from functools import wraps
from ZPublisher.HTTPRequest import HTTPRequest from ZPublisher.HTTPRequest import HTTPRequest
from ZPublisher.HTTPResponse import HTTPResponse from ZPublisher.HTTPResponse import HTTPResponse
import base64
import DateTime
import StringIO import StringIO
import json import json
import re
import urllib import urllib
def changeSkin(skin_name): def changeSkin(skin_name):
...@@ -639,6 +642,42 @@ class TestERP5Document_getHateoas_mode_traverse(ERP5HALJSONStyleSkinsMixin): ...@@ -639,6 +642,42 @@ class TestERP5Document_getHateoas_mode_traverse(ERP5HALJSONStyleSkinsMixin):
self.assertEqual(result_dict['_embedded']['_view']['_actions']['put']['method'], 'POST') self.assertEqual(result_dict['_embedded']['_view']['_actions']['put']['method'], 'POST')
@simulate('Base_getRequestUrl', '*args, **kwargs',
'return "http://example.org/bar"')
@simulate('Base_getRequestHeader', '*args, **kwargs',
'return "application/hal+json"')
@changeSkin('Hal')
def test_getHateoasDocument_listbox_vs_relation_inconsistency(self):
"""Purpose of this test is to point to inconsistencies in search-enabled field rendering.
ListBox gets its Portal Types in `portal_type` as list of tuples whether
Relation Input receives `portal_types` and `translated_portal_types`
"""
document = self._makeDocument()
# Drop editable permission
document.manage_permission('Modify portal content', [], 0)
document.Foo_view.listbox.ListBox_setPropertyList(
field_title = 'Foo Lines',
field_list_method = 'objectValues',
field_portal_types = 'Foo Line | Foo Line',
)
fake_request = do_fake_request("GET")
result = self.portal.web_site_module.hateoas.ERP5Document_getHateoas(
REQUEST=fake_request,
mode="traverse",
relative_url=document.getRelativeUrl(),
view="view")
self.assertEquals(fake_request.RESPONSE.status, 200)
self.assertEquals(fake_request.RESPONSE.getHeader('Content-Type'),
"application/hal+json"
)
result_dict = json.loads(result)
# ListBox rendering of allowed Portal Types
self.assertEqual(result_dict['_embedded']['_view']['listbox']['portal_type'], [['Foo Line', 'Foo Line']])
# Relation Input rendering of allowed Portal Types
self.assertEqual(result_dict['_embedded']['_view']['my_foo_category_title']['portal_types'], ['Category'])
self.assertEqual(result_dict['_embedded']['_view']['my_foo_category_title']['translated_portal_types'], ['Category'])
@simulate('Base_getRequestUrl', '*args, **kwargs', @simulate('Base_getRequestUrl', '*args, **kwargs',
'return "http://example.org/bar"') 'return "http://example.org/bar"')
@simulate('Base_getRequestHeader', '*args, **kwargs', @simulate('Base_getRequestHeader', '*args, **kwargs',
...@@ -712,6 +751,71 @@ class TestERP5Document_getHateoas_mode_traverse(ERP5HALJSONStyleSkinsMixin): ...@@ -712,6 +751,71 @@ class TestERP5Document_getHateoas_mode_traverse(ERP5HALJSONStyleSkinsMixin):
self.assertFalse(result_dict['_embedded']['_view'].has_key('_actions')) self.assertFalse(result_dict['_embedded']['_view'].has_key('_actions'))
@simulate('Base_getRequestUrl', '*args, **kwargs',
'return "http://example.org/bar"')
@simulate('Base_getRequestHeader', '*args, **kwargs',
'return "application/hal+json"')
@changeSkin('Hal')
def test_getHateoasDocument_listbox_list_method_params(self):
"""Ensure that `list_method` of ListBox receives specified parameters."""
document = self._makeDocument()
document.manage_permission('Modify portal content', [], 0)
# pass custom list method which expect input arguments
document.Foo_view.listbox.ListBox_setPropertyList(
field_title = 'Foo Lines',
field_list_method = 'Foo_listWithInputParams',
field_portal_types = 'Foo Line | Foo Line',
field_columns = 'id|ID\ntitle|Title\nquantity|Quantity\nstart_date|Date\ncatalog.uid|Uid')
now = DateTime.DateTime()
tomorrow = now + 1
fake_request = do_fake_request("GET", data=(
('start_date', now.ISO()),
('stop_date', tomorrow.ISO()))
)
# I tried to implement the standard way (see `data` param in do_fake_request)
# but for some reason it does not work...so we hack our way around
fake_request.set('start_date', now.ISO())
fake_request.set('stop_date', tomorrow.ISO())
result = self.portal.web_site_module.hateoas.ERP5Document_getHateoas(
REQUEST=fake_request,
mode="traverse",
relative_url=document.getRelativeUrl(),
form=document.restrictedTraverse('portal_skins/erp5_ui_test/Foo_view'),
view="view"
)
self.assertEquals(fake_request.RESPONSE.status, 200)
self.assertEquals(fake_request.RESPONSE.getHeader('Content-Type'),
"application/hal+json"
)
result_dict = json.loads(result)
list_method_template = \
result_dict['_embedded']['_view']['listbox']['list_method_template']
# default_param_json must not be empty because our custom list method
# specifies input parameters - they need to be filled from REQUEST
self.assertIn('default_param_json', list_method_template)
default_param_json = json.loads(
base64.b64decode(
re.search(r'default_param_json=([^\{&]+)',
list_method_template).group(1)))
self.assertIn("start_date", default_param_json)
self.assertEqual(default_param_json["start_date"], now.ISO())
self.assertIn("stop_date", default_param_json)
self.assertEqual(default_param_json["stop_date"], tomorrow.ISO())
# reset listbox properties to defaults
document.Foo_view.listbox.ListBox_setPropertyList(
field_title = 'Foo Lines',
field_list_method = 'objectValues',
field_portal_types = 'Foo Line | Foo Line',
field_stat_method = 'portal_catalog',
field_stat_columns = 'quantity | Foo_statQuantity',
field_editable = 1,
field_columns = 'id|ID\ntitle|Title\nquantity|Quantity\nstart_date|Date\ncatalog.uid|Uid',
field_editable_columns = 'id|ID\ntitle|Title\nquantity|quantity\nstart_date|Date',
field_search_columns = 'id|ID\ntitle|Title\nquantity|Quantity\nstart_date|Date',)
@simulate('Base_getRequestUrl', '*args, **kwargs', @simulate('Base_getRequestUrl', '*args, **kwargs',
'return "http://example.org/bar"') 'return "http://example.org/bar"')
@simulate('Base_getRequestHeader', '*args, **kwargs', @simulate('Base_getRequestHeader', '*args, **kwargs',
...@@ -1185,7 +1289,6 @@ class TestERP5Document_getHateoas_mode_bulk(ERP5HALJSONStyleSkinsMixin): ...@@ -1185,7 +1289,6 @@ class TestERP5Document_getHateoas_mode_bulk(ERP5HALJSONStyleSkinsMixin):
self.assertEquals(fake_request.RESPONSE.status, 405) self.assertEquals(fake_request.RESPONSE.status, 405)
self.assertEquals(result, "") self.assertEquals(result, "")
@simulate('Base_getRequestUrl', '*args, **kwargs', @simulate('Base_getRequestUrl', '*args, **kwargs',
'return "http://example.org/bar"') 'return "http://example.org/bar"')
@simulate('Base_getRequestHeader', '*args, **kwargs', @simulate('Base_getRequestHeader', '*args, **kwargs',
...@@ -1324,6 +1427,7 @@ class TestERP5Document_getHateoas_mode_worklist(ERP5HALJSONStyleSkinsMixin): ...@@ -1324,6 +1427,7 @@ class TestERP5Document_getHateoas_mode_worklist(ERP5HALJSONStyleSkinsMixin):
self.assertEqual(result_dict['_debug'], "worklist") self.assertEqual(result_dict['_debug'], "worklist")
class TestERP5Document_getHateoas_translation(ERP5HALJSONStyleSkinsMixin): class TestERP5Document_getHateoas_translation(ERP5HALJSONStyleSkinsMixin):
code_string = "\ code_string = "\
from Products.CMFCore.utils import getToolByName\n\ from Products.CMFCore.utils import getToolByName\n\
...@@ -1453,7 +1557,7 @@ class TestERP5Action_getHateoas(ERP5HALJSONStyleSkinsMixin): ...@@ -1453,7 +1557,7 @@ class TestERP5Action_getHateoas(ERP5HALJSONStyleSkinsMixin):
@changeSkin('Hal') @changeSkin('Hal')
def test_getHateoasDialog_dialog_failure(self, document): def test_getHateoasDialog_dialog_failure(self, document):
"""Test an dialog on Foo object with empty required for a failure. """Test an dialog on Foo object with empty required for a failure.
Expected behaviour is response Http 400 with field errors. Expected behaviour is response Http 400 with field errors.
""" """
fake_request = do_fake_request("POST") fake_request = do_fake_request("POST")
...@@ -1480,4 +1584,4 @@ class TestERP5Action_getHateoas(ERP5HALJSONStyleSkinsMixin): ...@@ -1480,4 +1584,4 @@ class TestERP5Action_getHateoas(ERP5HALJSONStyleSkinsMixin):
'your_custom_workflow_variable', response)) 'your_custom_workflow_variable', response))
self.assertIn('error_text', response['your_custom_workflow_variable'], "Invalid field must contain error message") self.assertIn('error_text', response['your_custom_workflow_variable'], "Invalid field must contain error message")
self.assertGreater(len(response['your_custom_workflow_variable']['error_text']), 0, "Error message must not be empty") self.assertGreater(len(response['your_custom_workflow_variable']['error_text']), 0, "Error message must not be empty")
\ No newline at end of file
"""Foo_listWithInputParams is here only to test passing parameters from REQUEST via introspection in RenderJS UI.
We expect DateTime parameters thus they have to undergo a serialization/deserialization process.
"""
from DateTime import DateTime
assert isinstance(start_date, DateTime), "start_date is instance of {!s} instead of DateTime!".format(type(start_date))
assert isinstance(stop_date, DateTime), "stop_date is instance of {!s} instead of DateTime!".format(type(stop_date))
return context.listFolder(portal_type='Foo Line')
<?xml version="1.0"?>
<ZopeData>
<record id="1" aka="AAAAAAAAAAE=">
<pickle>
<global name="Python Script" module="erp5.portal_type"/>
</pickle>
<pickle>
<dictionary>
<item>
<key> <string>Script_magic</string> </key>
<value> <int>3</int> </value>
</item>
<item>
<key> <string>_bind_names</string> </key>
<value>
<object>
<klass>
<global name="NameAssignments" module="Shared.DC.Scripts.Bindings"/>
</klass>
<tuple/>
<state>
<dictionary>
<item>
<key> <string>_asgns</string> </key>
<value>
<dictionary>
<item>
<key> <string>name_container</string> </key>
<value> <string>container</string> </value>
</item>
<item>
<key> <string>name_context</string> </key>
<value> <string>context</string> </value>
</item>
<item>
<key> <string>name_m_self</string> </key>
<value> <string>script</string> </value>
</item>
<item>
<key> <string>name_subpath</string> </key>
<value> <string>traverse_subpath</string> </value>
</item>
</dictionary>
</value>
</item>
</dictionary>
</state>
</object>
</value>
</item>
<item>
<key> <string>_params</string> </key>
<value> <string>start_date, stop_date=None, **kwargs</string> </value>
</item>
<item>
<key> <string>id</string> </key>
<value> <string>Foo_listWithInputParams</string> </value>
</item>
<item>
<key> <string>portal_type</string> </key>
<value> <string>Python Script</string> </value>
</item>
</dictionary>
</pickle>
</record>
</ZopeData>
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment