[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:
@@ -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):
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user