[IMP] website: display sample data for dynamic snippets

Before this commit when a dynamic snippet was fully configured but
returned no data, the rendered section remained empty in edit mode.

After this commit when a dynamic snippet is fully configured but has no
data to display, some sample data is generated in edit mode to give a
feel of how the page will look like when data will be available.

This commit of merge refactor also the way to get the data to render.
We split function to handle data and convert / escape it. The default
structure is now simplified and an helper to_generic exists to allow
generic template to be used with indexed list.

An other PR is coming to change the data getter, and have more generic
function to merge 'Recently Viewed' in the "Dynamic products' snippet.

task-2446024

closes odoo/odoo#65176

Pr: https://github.com/odoo/odoo/pull/65176
Signed-off-by: Jérémy Kersten (jke) <jke@openerp.com>
This commit is contained in:
Odoo's Mergebot
2021-02-16 16:37:07 +01:00
committed by GitHub
6 changed files with 238 additions and 65 deletions
+2 -2
View File
@@ -270,11 +270,11 @@ class Website(Home):
}
@http.route('/website/snippet/filters', type='json', auth='public', website=True)
def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None):
def get_dynamic_filter(self, filter_id, template_key, limit=None, search_domain=None, with_sample=False):
dynamic_filter = request.env['website.snippet.filter'].sudo().search(
[('id', '=', filter_id)] + request.website.website_domain()
)
return dynamic_filter and dynamic_filter.render(template_key, limit, search_domain) or ''
return dynamic_filter and dynamic_filter.render(template_key, limit, search_domain, with_sample) or ''
@http.route('/website/snippet/options_filters', type='json', auth='user', website=True)
def get_dynamic_snippet_filters(self):
+14 -11
View File
@@ -723,24 +723,26 @@
</record>
<!-- Template for Dynamic Snippet -->
<template id="dynamic_filter_template_image_title_footer" name="Image Title Footer Card">
<t t-foreach="records" t-as="record">
<t t-foreach="to_generic(records)" t-as="record">
<t t-set="fields" t-value="list(record['fields'].values())"/>
<t t-set="fieldsImg" t-value="list(record['image_fields'].values())"/>
<div class="card h-100" t-att-data-url="record['fields']['call_to_action_url']">
<t t-set="call_to_action_url" t-value="record['call_to_action_url']"/>
<div class="card h-100" t-att-data-url="call_to_action_url">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<img t-if="len(fieldsImg) > 0" class="card-img-top p-3" loading="lazy" t-att-src="fieldsImg[0]"/>
<div class="card-body">
<h5 t-if="len(fields) > 0" class="card-title">
<div t-if="len(fields) > 0" class="card-title h5">
<t t-raw="fields[0]"/>
</h5>
</div>
<div t-if="len(fields) > 1" class="card-text">
<t t-raw="fields[1]"/>
</div>
</div>
<div class="card-footer d-flex align-items-center">
<div t-if="len(fields) > 2" class="card-text">
<div t-if="len(fields) > 2" class="card-text text-center">
<t t-raw="fields[2]"/>
</div>
<a t-if="call_to_action" class="btn btn-primary ml-auto" t-att-href="record['fields']['call_to_action_url']">
<a t-if="call_to_action" class="btn btn-primary ml-auto" t-att-href="call_to_action_url">
<i class="fa fa-fw fa-eye"/>
</a>
</div>
@@ -748,13 +750,14 @@
</t>
</template>
<template id="dynamic_filter_template_header_image_footer_card" name="Header Image Footer Card">
<t t-foreach="records" t-as="record">
<t t-foreach="to_generic(records)" t-as="record">
<t t-set="fields" t-value="list(record['fields'].values())"/>
<t t-set="fieldsImg" t-value="list(record['image_fields'].values())"/>
<div class="card h-100" t-att-data-url="record['fields']['call_to_action_url']">
<h5 t-if="len(fields) > 0" class="card-header">
<div class="card h-100" t-att-data-url="record['call_to_action_url']">
<div t-if="is_sample" class="h5 o_ribbon_right bg-primary text-uppercase">Sample</div>
<div t-if="len(fields) > 0" class="card-header h5">
<t t-raw="fields[0]"/>
</h5>
</div>
<div class="card-body">
<img t-if="len(fieldsImg) > 0" class="card-img-top p-3" loading="lazy" t-att-src="fieldsImg[0]"/>
<div t-if="len(fields) > 1" class="card-text">
@@ -762,7 +765,7 @@
</div>
</div>
<div class="card-footer d-flex align-items-center">
<div t-if="len(fields) > 2" class="card-text">
<div t-if="len(fields) > 2" class="card-text w-100 text-center">
<t t-raw="fields[2]"/>
</div>
</div>
+186 -36
View File
@@ -8,6 +8,7 @@ from odoo.osv import expression
from odoo.tools import html_escape as escape
from lxml import etree as ET
import logging
from random import randint
_logger = logging.getLogger(__name__)
@@ -49,22 +50,33 @@ class WebsiteSnippetFilter(models.Model):
if not field_name.strip():
raise ValidationError(_("Empty field name in %r") % (record.field_names))
def render(self, template_key, limit, search_domain=[]):
def render(self, template_key, limit, search_domain=None, with_sample=False):
"""Renders the website dynamic snippet items"""
self.ensure_one()
assert '.dynamic_filter_template_' in template_key, _("You can only use template prefixed by dynamic_filter_template_ ")
if search_domain is None:
search_domain = []
if self.website_id and self.env['website'].get_current_website() != self.website_id:
return ''
records = self._prepare_values(limit, search_domain)
is_sample = with_sample and not records
if is_sample:
records = self._prepare_sample()
View = self.env['ir.ui.view'].sudo().with_context(inherit_branding=False)
content = View._render_template(template_key, dict(records=records)).decode('utf-8')
escaped_records = self._record_to_escaped_values(records)
content = View._render_template(template_key, dict(
records=escaped_records,
is_sample=is_sample,
to_generic=self._to_generic,
)).decode('utf-8')
return [ET.tostring(el) for el in ET.fromstring('<root>%s</root>' % content).getchildren()]
def _prepare_values(self, limit=None, search_domain=None):
"""Gets the data and returns it the right format for render."""
self.ensure_one()
limit = limit and min(limit, self.limit) or self.limit
if self.filter_id:
filter_sudo = self.filter_id.sudo()
@@ -79,7 +91,7 @@ class WebsiteSnippetFilter(models.Model):
order=','.join(literal_eval(filter_sudo.sort)) or None,
limit=limit
)
return self._filter_records_to_dict_values(records)
return self._filter_records_to_values(records)
except MissingError:
_logger.warning("The provided domain %s in 'ir.filters' generated a MissingError in '%s'", domain, self._name)
return []
@@ -89,21 +101,13 @@ class WebsiteSnippetFilter(models.Model):
dynamic_filter=self,
limit=limit,
search_domain=search_domain,
get_rendering_data_structure=self._get_rendering_data_structure,
).sudo().run()
).sudo().run() or []
except MissingError:
_logger.warning("The provided domain %s in 'ir.actions.server' generated a MissingError in '%s'", search_domain, self._name)
return []
@api.model
def _get_rendering_data_structure(self):
return {
'fields': OrderedDict({}),
'image_fields': OrderedDict({}),
}
def _filter_records_to_dict_values(self, records):
"""Extract the fields from the data source and put them into a dictionary of values
def _to_generic(self, records):
"""Converts a list of dictionary records into the structure used by generic templates for rendering:
[{
'fields':
@@ -115,26 +119,177 @@ class WebsiteSnippetFilter(models.Model):
OrderedDict([
('image', '/web/image/res.country/3/image?unique=5d9b44e')
]),
}, ... , ...]
'call_to_action_url': 'some/location'
}, ... , ...]
@param records: records as dictionaries of non-escaped values associated to field names
@return List of Dict matching the rendering structure for generic templates
"""
meta_data = self._get_filter_meta_data()
result = []
for record in records:
data = {
'fields': OrderedDict({}),
'image_fields': OrderedDict({}),
}
for field_name, field_widget in meta_data.items():
value = record[field_name]
if field_widget in ('binary', 'image'):
data['image_fields'][field_name] = value
else:
data['fields'][field_name] = value
data['call_to_action_url'] = record.get('call_to_action_url')
result.append(data)
return result
def _record_to_escaped_values(self, records):
"""Converts a list of dictionary records into rendered values
@param records: records as dictionaries of non-escaped values associated to field names
@return List of Dict containing rendered values of each field
"""
meta_data = self._get_filter_meta_data()
result = []
for record in records:
data = {}
for field_name, field_widget in meta_data.items():
value = record[field_name]
if field_widget in ('binary', 'image'):
data[field_name] = self.escape_falsy_as_empty(value)
elif field_widget == 'monetary':
FieldMonetary = self.env['ir.qweb.field.monetary']
website_currency = self._get_website_currency()
data[field_name] = FieldMonetary.value_to_html(
value,
{'display_currency': website_currency}
)
elif 'ir.qweb.field.%s' % field_widget in self.env:
options = {}
if field_widget == 'html':
options['template_options'] = {}
data[field_name] = self.env['ir.qweb.field.%s' % field_widget].value_to_html(
value, options)
else:
data[field_name] = self.escape_falsy_as_empty(value)
data['call_to_action_url'] = record.get('call_to_action_url')
result.append(data)
return result
def _get_field_name_and_type(self, model, field_name):
"""
Separates the name and the widget type
@param model: Model to which the field belongs, without it type is deduced from field_name
@param field_name: Name of the field possibly followed by a colon and a forced field type
@return Tuple containing the field name and the field type
"""
field_name, _, field_widget = field_name.partition(":")
if not field_widget:
field = model._fields.get(field_name) if model else None
if field:
field_type = field.type
elif 'image' in field_name:
field_type = 'image'
elif 'price' in field_name:
field_type = 'monetary'
else:
field_type = 'text'
return field_name, field_widget or field_type
def _get_filter_meta_data(self):
"""
Extracts the meta data of each field
@return OrderedDict containing the widget type for each field name
"""
model = self.env[self.filter_id.model_id] if self.filter_id else (
self.action_server_id.model_id if self.action_server_id else None)
meta_data = OrderedDict({})
for field_name in self.field_names.split(","):
field_name, field_widget = self._get_field_name_and_type(model, field_name)
meta_data[field_name] = field_widget
return meta_data
def _prepare_sample(self, length=4):
"""
Generates sample data and returns it the right format for render.
@param length: Number of sample records to generate
@return Array of objets with a value associated to each name in field_names
"""
if not length:
return []
sample = []
model = self.env[self.filter_id.model_id] if self.filter_id else (
self.action_server_id.model_id if self.action_server_id else None)
sample_data = self._get_hardcoded_sample(model)
for index in range(0, length):
single_sample_data = sample_data[index % len(sample_data)].copy()
self._fill_sample(single_sample_data, index)
sample.append(single_sample_data)
return sample
def _fill_sample(self, sample, index):
"""
Fills the missing fields of a sample
@param sample: Data structure to fill with values for each name in field_names
@param index: Index of the sample within the dataset
"""
meta_data = self._get_filter_meta_data()
for field_name, field_widget in meta_data.items():
if field_name not in sample:
if field_widget == 'binary':
sample[field_name] = None
elif field_widget == 'image':
sample[field_name] = '/web/image'
elif field_widget == 'monetary':
sample[field_name] = randint(100, 10000) / 10.0
elif field_widget in ('integer', 'float'):
sample[field_name] = index
else:
sample[field_name] = _('Sample %s', index + 1)
sample['call_to_action_url'] = ''
return sample
def _get_hardcoded_sample(self, model):
"""
Returns a hard-coded sample
@param model: Model of the currently rendered view
@return Sample data records with field values
"""
return [{}]
def _filter_records_to_values(self, records):
"""
Extract the fields from the data source 'records' and put them into a dictionary of values
@param records: Model records returned by the filter
@return List of dict associating the field value to each field name
"""
self.ensure_one()
meta_data = self._get_filter_meta_data()
values = []
model = self.env[self.filter_id.model_id]
Website = self.env['website']
for record in records:
data = self._get_rendering_data_structure()
for field_name in self.field_names.split(","):
field_name, _, field_widget = field_name.partition(":")
data = {}
for field_name, field_widget in meta_data.items():
field = model._fields.get(field_name)
field_widget = field_widget or field.type
if field.type == 'binary':
data['image_fields'][field_name] = self.escape_falsy_as_empty(Website.image_url(record, field_name))
elif field_widget == 'image':
data['image_fields'][field_name] = self.escape_falsy_as_empty(record[field_name])
data[field_name] = Website.image_url(record, field_name)
elif field_widget == 'monetary':
FieldMonetary = self.env['ir.qweb.field.monetary']
model_currency = None
if field.type == 'monetary':
model_currency = record[record[field_name].currency_field]
@@ -142,23 +297,18 @@ class WebsiteSnippetFilter(models.Model):
model_currency = record['currency_id']
if model_currency:
website_currency = self._get_website_currency()
data['fields'][field_name] = FieldMonetary.value_to_html(
model_currency._convert(
record[field_name],
website_currency,
Website.get_current_website().company_id,
fields.Date.today()
),
{'display_currency': website_currency}
data[field_name] = model_currency._convert(
record[field_name],
website_currency,
Website.get_current_website().company_id,
fields.Date.today()
)
else:
data['fields'][field_name] = self.escape_falsy_as_empty(record[field_name])
elif ('ir.qweb.field.%s' % field_widget) in self.env:
data['fields'][field_name] = self.env[('ir.qweb.field.%s' % field_widget)].record_to_html(record, field_name, {})
data[field_name] = record[field_name]
else:
data['fields'][field_name] = self.escape_falsy_as_empty(record[field_name])
data[field_name] = record[field_name]
data['fields']['call_to_action_url'] = 'website_url' in record and record['website_url']
data['call_to_action_url'] = 'website_url' in record and record['website_url']
values.append(data)
return values
@@ -110,7 +110,8 @@ const DynamicSnippet = publicWidget.Widget.extend({
'filter_id': parseInt(this.$el.get(0).dataset.filterId),
'template_key': this.$el.get(0).dataset.templateKey,
'limit': parseInt(this.$el.get(0).dataset.numberOfRecords),
'search_domain': this._getSearchDomain()
'search_domain': this._getSearchDomain(),
'with_sample': this.editableMode,
},
})
.then(
+7 -15
View File
@@ -61,17 +61,12 @@ website = request.website.get_current_website()
dynamic_filter = model.env.context.get('dynamic_filter')
limit = model.env.context.get('limit')
search_domain = model.env.context.get('search_domain')
get_rendering_data_structure = model.env.context.get('get_rendering_data_structure')
escape = dynamic_filter.escape_falsy_as_empty
meta_data = dynamic_filter._get_filter_meta_data()
domain = [('website_published', '=', True)] + website.website_domain() + (search_domain or [])
products = ProductProduct.search(domain, limit=limit)
_ = products.mapped('name')
monetary_options = {
'display_currency': request.website.get_current_pricelist().currency_id,
}
max_nb_chars = 100
res_products = []
for product in products:
@@ -80,17 +75,14 @@ for product in products:
if res_product['description_sale'] and len(res_product['description_sale']) > max_nb_chars:
res_product['description_sale'] = "%s ..." % res_product['description_sale'][:max_nb_chars]
res_product['list_price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options)
data = get_rendering_data_structure()
for field_name in dynamic_filter.field_names.split(","):
data = {}
for field_name in meta_data.keys():
field = ProductProduct._fields.get(field_name)
if field and field.type == 'binary':
data['image_fields'][field_name] = escape(website.image_url(product, field_name))
elif field_name == 'list_price':
data['fields'][field_name] = res_product[field_name]
data[field_name] = website.image_url(product, field_name)
else:
data['fields'][field_name] = escape(res_product[field_name])
data['fields']['call_to_action_url'] = escape(product['website_url'])
data[field_name] = res_product[field_name]
data['call_to_action_url'] = product['website_url']
res_products.append(data)
response = res_products
@@ -99,7 +91,7 @@ response = res_products
<!-- Dynamic Filter -->
<record id="dynamic_filter_demo_products" model="website.snippet.filter">
<field name="action_server_id" ref="website_sale.dynamic_snippet_products_action"/>
<field name="field_names">display_name,description_sale,image_512,list_price</field>
<field name="field_names">display_name,description_sale,image_512,price:monetary</field>
<field name="limit" eval="16"/>
<field name="name">Products</field>
</record>
@@ -10,3 +10,30 @@ class WebsiteSnippetFilter(models.Model):
def _get_website_currency(self):
pricelist = self.env['website'].get_current_website().get_current_pricelist()
return pricelist.currency_id
def _get_hardcoded_sample(self, model):
samples = super()._get_hardcoded_sample(model)
if model and model.model == 'product.product':
data = [{
'image_512': '/product/static/img/product_chair.png',
'display_name': _('Chair'),
'description_sale': _('Sit comfortably'),
}, {
'image_512': '/product/static/img/product_lamp.png',
'display_name': _('Lamp'),
'description_sale': _('Lightbulb sold separately'),
}, {
'image_512': '/product/static/img/product_product_20-image.png',
'display_name': _('Whiteboard'),
'description_sale': _('With three feet'),
}, {
'image_512': '/product/static/img/product_product_27-image.png',
'display_name': _('Drawer'),
'description_sale': _('On wheels'),
}]
merged = []
for index in range(0, max(len(samples), len(data))):
merged.append({**samples[index % len(samples)], **data[index % len(data)]})
# merge definitions
samples = merged
return samples