diff --git a/addons/website/controllers/main.py b/addons/website/controllers/main.py index 622728b0e63..836b2ea4cf9 100644 --- a/addons/website/controllers/main.py +++ b/addons/website/controllers/main.py @@ -5,7 +5,6 @@ import datetime import json import os import logging -import pytz import requests import werkzeug.urls import werkzeug.utils @@ -18,7 +17,8 @@ import odoo from odoo import http, models, fields, _ from odoo.http import request -from odoo.tools import OrderedSet +from odoo.osv import expression +from odoo.tools import OrderedSet, escape_psql from odoo.addons.http_routing.models.ir_http import slug, slugify, _guess_mimetype from odoo.addons.web.controllers.main import Binary from odoo.addons.portal.controllers.portal import pager as portal_pager @@ -277,18 +277,26 @@ class Website(Home): 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): + def get_dynamic_snippet_filters(self, model_name=None, search_domain=None): + domain = request.website.website_domain() + if search_domain: + domain = expression.AND([domain, search_domain]) + if model_name: + domain = expression.AND([ + domain, + ['|', ('filter_id.model_id', '=', model_name), ('action_server_id.model_id.model', '=', model_name)] + ]) dynamic_filter = request.env['website.snippet.filter'].sudo().search_read( - request.website.website_domain(), ['id', 'name', 'limit'] + domain, ['id', 'name', 'limit', 'model_name'], order='id asc' ) return dynamic_filter @http.route('/website/snippet/filter_templates', type='json', auth='public', website=True) - def get_dynamic_snippet_templates(self, filter_id=False): - # todo: if filter_id.model -> filter template - templates = request.env['ir.ui.view'].sudo().search_read( - [['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']], ['key', 'name'] - ) + def get_dynamic_snippet_templates(self, filter_name=False): + domain = [['key', 'ilike', '.dynamic_filter_template_'], ['type', '=', 'qweb']] + if filter_name: + domain.append(['key', 'ilike', escape_psql('_%s_' % filter_name)]) + templates = request.env['ir.ui.view'].sudo().search_read(domain, ['key', 'name']) return templates # ------------------------------------------------------ diff --git a/addons/website/data/website_data.xml b/addons/website/data/website_data.xml index 0de6c2c2303..9b1e727cf18 100644 --- a/addons/website/data/website_data.xml +++ b/addons/website/data/website_data.xml @@ -715,70 +715,6 @@ - - - Countries - res.country - - - - - - - - name,code,image_url:image,phone_code:char - - - Countries - Multi-website diff --git a/addons/website/models/website_snippet_filter.py b/addons/website/models/website_snippet_filter.py index 56e407298f9..336ec100314 100644 --- a/addons/website/models/website_snippet_filter.py +++ b/addons/website/models/website_snippet_filter.py @@ -25,10 +25,15 @@ class WebsiteSnippetFilter(models.Model): filter_id = fields.Many2one('ir.filters', 'Filter', ondelete='cascade') limit = fields.Integer(help='The limit is the maximum number of records retrieved', required=True) website_id = fields.Many2one('website', string='Website', ondelete='cascade') + model_name = fields.Char(string='Model name', compute='_compute_model_name') - @api.model - def escape_falsy_as_empty(self, s): - return escape(s) if s else '' + @api.depends('filter_id', 'action_server_id') + def _compute_model_name(self): + for snippet_filter in self: + if snippet_filter.filter_id: + snippet_filter.model_name = snippet_filter.filter_id.model_id + else: # self.action_server_id + snippet_filter.model_name = snippet_filter.action_server_id.model_id.model @api.constrains('action_server_id', 'filter_id') def _check_data_source_is_provided(self): @@ -63,13 +68,11 @@ class WebsiteSnippetFilter(models.Model): records = self._prepare_values(limit, search_domain) is_sample = with_sample and not records if is_sample: - records = self._prepare_sample() + records = self._prepare_sample(limit) View = self.env['ir.ui.view'].sudo().with_context(inherit_branding=False) - escaped_records = self._record_to_escaped_values(records) content = View._render_template(template_key, dict( - records=escaped_records, + records=records, is_sample=is_sample, - to_generic=self._to_generic, )).decode('utf-8') return [ET.tostring(el, encoding='utf-8') for el in ET.fromstring('%s' % content).getchildren()] @@ -81,6 +84,8 @@ class WebsiteSnippetFilter(models.Model): if self.filter_id: filter_sudo = self.filter_id.sudo() domain = filter_sudo._get_eval_domain() + if 'website_id' in self.env[filter_sudo.model_id]: + domain = expression.AND([domain, self.env['website'].get_current_website().website_domain()]) if 'is_published' in self.env[filter_sudo.model_id]: domain = expression.AND([domain, [('is_published', '=', True)]]) if search_domain: @@ -106,78 +111,6 @@ class WebsiteSnippetFilter(models.Model): _logger.warning("The provided domain %s in 'ir.actions.server' generated a MissingError in '%s'", search_domain, self._name) return [] - def _to_generic(self, records): - """Converts a list of dictionary records into the structure used by generic templates for rendering: - - [{ - 'fields': - OrderedDict([ - ('name', 'Afghanistan'), - ('code', 'AF'), - ]), - 'image_fields': - 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 @@ -189,7 +122,7 @@ class WebsiteSnippetFilter(models.Model): """ field_name, _, field_widget = field_name.partition(":") if not field_widget: - field = model._fields.get(field_name) if model else None + field = model._fields.get(field_name) if field: field_type = field.type elif 'image' in field_name: @@ -206,15 +139,14 @@ class WebsiteSnippetFilter(models.Model): @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) + model = self.env[self.model_name] 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): + def _prepare_sample(self, length=6): """ Generates sample data and returns it the right format for render. @@ -222,17 +154,29 @@ class WebsiteSnippetFilter(models.Model): @return Array of objets with a value associated to each name in field_names """ + if not length: + return [] + records = self._prepare_sample_records(length) + return self._filter_records_to_values(records, is_sample=True) + + def _prepare_sample_records(self, length): + """ + Generates sample records. + + @param length: Number of sample records to generate + + @return List of of sample records + """ 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) + model = self.env[self.model_name] 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) + sample.append(model.new(single_sample_data)) return sample def _fill_sample(self, sample, index): @@ -243,19 +187,17 @@ class WebsiteSnippetFilter(models.Model): @param index: Index of the sample within the dataset """ meta_data = self._get_filter_meta_data() + model = self.env[self.model_name] for field_name, field_widget in meta_data.items(): - if field_name not in sample: - if field_widget == 'binary': + if field_name not in sample and field_name in model: + if field_widget in ('image', '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): @@ -268,30 +210,33 @@ class WebsiteSnippetFilter(models.Model): """ return [{}] - def _filter_records_to_values(self, records): + def _filter_records_to_values(self, records, is_sample=False): """ Extract the fields from the data source 'records' and put them into a dictionary of values @param records: Model records returned by the filter + @param is_sample: True if conversion if for sample records @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] + model = self.env[self.model_name] Website = self.env['website'] for record in records: data = {} for field_name, field_widget in meta_data.items(): field = model._fields.get(field_name) - if field.type == 'binary': - data[field_name] = Website.image_url(record, field_name) + if field and field.type in ('binary', 'image'): + if is_sample: + data[field_name] = record[field_name].decode('utf8') if field_name in record else '/web/image' + else: + data[field_name] = Website.image_url(record, field_name) elif field_widget == 'monetary': model_currency = None - if field.type == 'monetary': + if field and field.type == 'monetary': model_currency = record[record[field_name].currency_field] elif 'currency_id' in model._fields: model_currency = record['currency_id'] @@ -309,6 +254,7 @@ class WebsiteSnippetFilter(models.Model): data[field_name] = record[field_name] data['call_to_action_url'] = 'website_url' in record and record['website_url'] + data['_record'] = record values.append(data) return values diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/000.js b/addons/website/static/src/snippets/s_dynamic_snippet/000.js index 53ae9bf7dca..501ec90f31d 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/000.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/000.js @@ -40,7 +40,6 @@ const DynamicSnippet = publicWidget.Widget.extend({ return this._super.apply(this, arguments).then( () => Promise.all([ this._fetchData(), - this._manageWarningMessageVisibility() ]) ); }, @@ -53,7 +52,6 @@ const DynamicSnippet = publicWidget.Widget.extend({ .then(() => { this._setupSizeChangedManagement(true); this._render(); - this._toggleVisibility(true); }); }, /** @@ -97,6 +95,13 @@ const DynamicSnippet = publicWidget.Widget.extend({ _getSearchDomain: function () { return []; }, + /** + * Method to be overridden in child components in order to add custom parameters if needed. + * @private + */ + _getRpcParameters: function () { + return {}; + }, /** * Fetches the data. * @private @@ -106,13 +111,13 @@ const DynamicSnippet = publicWidget.Widget.extend({ return this._rpc( { 'route': '/website/snippet/filters', - 'params': { + 'params': Object.assign({ '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(), 'with_sample': this.editableMode, - }, + }, this._getRpcParameters()), }) .then( (data) => { @@ -126,23 +131,6 @@ const DynamicSnippet = publicWidget.Widget.extend({ }); } }, - /** - * - * @private - */ - _mustMessageWarningBeHidden: function() { - return this._isConfigComplete() || !this.editableMode; - }, - /** - * - * @private - */ - _manageWarningMessageVisibility: async function () { - this.$el.find('.missing_option_warning').toggleClass( - 'd-none', - this._mustMessageWarningBeHidden() - ); - }, /** * Method to be overridden in child components in order to prepare content * before rendering. @@ -178,9 +166,11 @@ const DynamicSnippet = publicWidget.Widget.extend({ * @private */ _render: function () { - if (this.data.length) { + if (this.data.length > 0 || this.editableMode) { + this.$el.removeClass('d-none'); this._prepareContent(); } else { + this.$el.addClass('d-none'); this.renderedContent = ''; } this._renderContent(); diff --git a/addons/website/static/src/snippets/s_dynamic_snippet/options.js b/addons/website/static/src/snippets/s_dynamic_snippet/options.js index 2e119f490f9..3b9e762b25f 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet/options.js +++ b/addons/website/static/src/snippets/s_dynamic_snippet/options.js @@ -11,7 +11,12 @@ const dynamicSnippetOptions = options.Class.extend({ */ init: function () { this._super.apply(this, arguments); + // specify model name in subclasses to filter the list of available model record filters + this.modelNameFilter = undefined; + this.contextualFilterDomain = []; this.dynamicFilters = {}; + // name of the model of the currently selected filter, used to fetch templates + this.currentModelName = undefined; this.dynamicFilterTemplates = {}; }, /** @@ -33,7 +38,9 @@ const dynamicSnippetOptions = options.Class.extend({ selectDataAttribute: function (previewMode, widgetValue, params) { this._super.apply(this, arguments); if (params.attributeName === 'filterId' && previewMode === false) { - this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[parseInt(widgetValue)].limit; + const filter = this.dynamicFilters[parseInt(widgetValue)]; + this.$target.get(0).dataset.numberOfRecords = filter.limit; + this._filterUpdated(filter); } }, @@ -41,13 +48,42 @@ const dynamicSnippetOptions = options.Class.extend({ // Private //-------------------------------------------------------------------------- + /** + * + * @override + * @private + */ + _computeWidgetVisibility: function (widgetName, params) { + if (widgetName === 'filter_opt') { + // Hide if exaclty one is available: show when none to help understand what is missing + return Object.keys(this.dynamicFilters).length !== 1; + } + return this._super.apply(this, arguments); + }, + /** + * @override + * @private + * @returns {Promise} + */ + _refreshPublicWidgets: function () { + return this._super.apply(this, arguments).then(() => { + const templateKeys = this.$el ? this.$el.find("we-select[data-attribute-name='templateKey'] we-selection-items we-button") : []; + this.$target.find('.missing_option_warning').toggleClass( + 'd-none', + templateKeys.length > 0 + ); + }); + }, /** * Fetches dynamic filters. * @private * @returns {Promise} */ _fetchDynamicFilters: function () { - return this._rpc({route: '/website/snippet/options_filters'}); + return this._rpc({route: '/website/snippet/options_filters', params: { + model_name: this.modelNameFilter, + search_domain: this.contextualFilterDomain, + }}); }, /** * Fetch dynamic filters templates. @@ -55,7 +91,10 @@ const dynamicSnippetOptions = options.Class.extend({ * @returns {Promise} */ _fetchDynamicFilterTemplates: function () { - return this._rpc({route: '/website/snippet/filter_templates'}); + const filter = this.dynamicFilters[this.$target.get(0).dataset['filterId']]; + return filter ? this._rpc({route: '/website/snippet/filter_templates', params: { + filter_name: filter.model_name.replaceAll('.', '_'), + }}) : []; }, /** * @@ -72,9 +111,17 @@ const dynamicSnippetOptions = options.Class.extend({ * @private */ _renderDynamicFiltersSelector: async function (uiFragment) { - const dynamicFilters = await this._fetchDynamicFilters(); - for (let index in dynamicFilters) { - this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index]; + if (!Object.keys(this.dynamicFilters).length) { + const dynamicFilters = await this._fetchDynamicFilters(); + for (let index in dynamicFilters) { + this.dynamicFilters[dynamicFilters[index].id] = dynamicFilters[index]; + } + if (dynamicFilters.length > 0) { + const selectedFilterId = this.$target.get(0).dataset['filterId']; + if (!this.dynamicFilters[selectedFilterId]) { + this.$target.get(0).dataset['filterId'] = dynamicFilters[0].id; + } + } } const filtersSelectorEl = uiFragment.querySelector('[data-name="filter_opt"]'); return this._renderSelectUserValueWidgetButtons(filtersSelectorEl, this.dynamicFilters); @@ -104,6 +151,15 @@ const dynamicSnippetOptions = options.Class.extend({ for (let index in dynamicFilterTemplates) { this.dynamicFilterTemplates[dynamicFilterTemplates[index].key] = dynamicFilterTemplates[index]; } + if (dynamicFilterTemplates.length > 0) { + const selectedTemplateId = this.$target.get(0).dataset['templateKey']; + if (!this.dynamicFilterTemplates[selectedTemplateId]) { + this.$target.get(0).dataset['templateKey'] = dynamicFilterTemplates[0].key; + this._refreshPublicWidgets(); + } + } else { + this._refreshPublicWidgets(); + } const templatesSelectorEl = uiFragment.querySelector('[data-name="template_opt"]'); return this._renderSelectUserValueWidgetButtons(templatesSelectorEl, this.dynamicFilterTemplates); }, @@ -116,6 +172,23 @@ const dynamicSnippetOptions = options.Class.extend({ _setOptionsDefaultValues: function () { this._setOptionValue('numberOfElements', 4); this._setOptionValue('numberOfElementsSmallDevices', 1); + const filterKeys = this.$el.find("we-select[data-attribute-name='filterId'] we-selection-items we-button"); + if (filterKeys.length > 0) { + this._setOptionValue('numberOfRecords', this.dynamicFilters[Object.keys(this.dynamicFilters)[0]].limit); + } + const filter = this.dynamicFilters[this.$target.get(0).dataset['filterId']]; + this._filterUpdated(filter); + }, + /** + * Take the new filter selection into account + * @param filter + * @private + */ + _filterUpdated: function (filter) { + if (filter && this.currentModelName !== filter.model_name) { + this.currentModelName = filter.model_name; + this._rerenderXML(); + } }, /** * Sets the option value. diff --git a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss index 3eceb172ab8..72e5c4cbb48 100644 --- a/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss +++ b/addons/website/static/src/snippets/s_dynamic_snippet_carousel/000.scss @@ -8,4 +8,14 @@ background: radial-gradient($white 50%, transparent 50%); } } + .dynamic_snippet_template .card-footer a, .dynamic_snippet_template .card-footer button { + z-index: 2; + } + .dynamic_snippet_template .card-body .card-text { + display: -webkit-box; + -webkit-line-clamp: 4; + -webkit-box-orient: vertical; + text-overflow: ellipsis; + overflow: hidden; + } } diff --git a/addons/website/views/snippets/s_dynamic_snippet.xml b/addons/website/views/snippets/s_dynamic_snippet.xml index 683274bca84..39166455e18 100644 --- a/addons/website/views/snippets/s_dynamic_snippet.xml +++ b/addons/website/views/snippets/s_dynamic_snippet.xml @@ -4,7 +4,7 @@
-
+
Your Dynamic Snippet will be displayed here... This message is displayed because you did not provided both a filter and a template to use.
@@ -41,14 +41,14 @@ 16 - + 1 2 3 4 6 - + 1 2 3 diff --git a/addons/website/views/snippets/s_dynamic_snippet_carousel.xml b/addons/website/views/snippets/s_dynamic_snippet_carousel.xml index ea17154fc65..a1b32247b12 100644 --- a/addons/website/views/snippets/s_dynamic_snippet_carousel.xml +++ b/addons/website/views/snippets/s_dynamic_snippet_carousel.xml @@ -10,7 +10,7 @@ Items per slide - diff --git a/addons/website/views/snippets/snippets.xml b/addons/website/views/snippets/snippets.xml index a5e4e2bba5e..73fd87c6c81 100644 --- a/addons/website/views/snippets/snippets.xml +++ b/addons/website/views/snippets/snippets.xml @@ -113,7 +113,6 @@ - diff --git a/addons/website/views/website_templates.xml b/addons/website/views/website_templates.xml index 8db98f3fe7b..7daed2d1d15 100644 --- a/addons/website/views/website_templates.xml +++ b/addons/website/views/website_templates.xml @@ -44,7 +44,7 @@ 'data-edit_translations': '1' if edit_translations else None, 'data-view-xmlid': xmlid if editable or translatable else None, 'data-viewid': viewid if editable or translatable else None, - 'data-main-object': repr(main_object) if editable or translatable else None, + 'data-main-object': repr(main_object), 'data-seo-object': repr(seo_object) if seo_object else None, 'data-oe-company-name': res_company.name, }"/> diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py index 55a229302fb..a5692a3def8 100644 --- a/addons/website_sale/__manifest__.py +++ b/addons/website_sale/__manifest__.py @@ -15,6 +15,7 @@ 'security/website_sale.xml', 'data/data.xml', 'data/mail_template_data.xml', + 'data/product_snippet_template_data.xml', 'data/digest_data.xml', 'views/product_views.xml', 'views/account_views.xml', diff --git a/addons/website_sale/data/data.xml b/addons/website_sale/data/data.xml index a14c2e47951..06cdea13de4 100644 --- a/addons/website_sale/data/data.xml +++ b/addons/website_sale/data/data.xml @@ -48,53 +48,91 @@ + + + Newest Products + product.product + + [('website_published', '=', True)] + {'display_default_code': False} + ['create_date desc'] + - - Products Dynamic Snippet + + Recently Sold Products code -ProductProduct = model.env['product.product'] -FieldMonetary = model.env['ir.qweb.field.monetary'] - -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') -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') - -max_nb_chars = 100 -res_products = [] -for product in products: - res_product = product._read_format(['id', 'name', 'website_url', 'description_sale'])[0] - res_product.update(product._get_combination_info_variant()) - - 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] - data = {} - for field_name in meta_data.keys(): - field = ProductProduct._fields.get(field_name) - if field and field.type == 'binary': - data[field_name] = website.image_url(product, field_name) - else: - data[field_name] = res_product[field_name] - data['call_to_action_url'] = product['website_url'] - res_products.append(data) - +DynamicFilter = model.env['website.snippet.filter'] +response = DynamicFilter._get_products('latest_sold', model.env.context) + + + + Recently Viewed Products + + code + +DynamicFilter = model.env['website.snippet.filter'] +res_products = DynamicFilter._get_products('latest_viewed', model.env.context) +for data in res_products: + data['_latest_viewed'] = True response = res_products + + Product Accessories + + code + +DynamicFilter = model.env['website.snippet.filter'] +model.env.context['product_template_id'] = request.params.get('productTemplateId') +response = DynamicFilter._get_products('accessories', model.env.context) + + + + Products Recently Sold With + + code + +DynamicFilter = model.env['website.snippet.filter'] +model.env.context['product_template_id'] = request.params.get('productTemplateId') +response = DynamicFilter._get_products('recently_sold_with', model.env.context) + + - - + + display_name,description_sale,image_512,price:monetary - Products + Newest Products + + + display_name,description_sale,image_512,price:monetary + + Recently Sold Products + + + + display_name,description_sale,image_512,price:monetary + + Recently Viewed Products + + + + display_name,description_sale,image_512,price:monetary + + Accessories for Product + True + + + + display_name,description_sale,image_512,price:monetary + + Products Recently Sold With Product + True + + sale.order + + + + + + + + + + + + + + diff --git a/addons/website_sale/models/website_snippet_filter.py b/addons/website_sale/models/website_snippet_filter.py index d528e175d75..1f1648c0f9c 100644 --- a/addons/website_sale/models/website_snippet_filter.py +++ b/addons/website_sale/models/website_snippet_filter.py @@ -1,11 +1,17 @@ # -*- coding: utf-8 -*- +from collections import Counter + from odoo import models, fields, api, _ +from odoo.osv import expression class WebsiteSnippetFilter(models.Model): _inherit = 'website.snippet.filter' + product_cross_selling = fields.Boolean(string="About cross selling products", default=False, + help="True only for product filters that require a product_id because they relate to cross selling") + @api.model def _get_website_currency(self): pricelist = self.env['website'].get_current_website().get_current_pricelist() @@ -13,23 +19,31 @@ class WebsiteSnippetFilter(models.Model): def _get_hardcoded_sample(self, model): samples = super()._get_hardcoded_sample(model) - if model and model.model == 'product.product': + if model._name == 'product.product': data = [{ - 'image_512': '/product/static/img/product_chair.png', + 'image_512': b'/product/static/img/product_chair.png', 'display_name': _('Chair'), 'description_sale': _('Sit comfortably'), }, { - 'image_512': '/product/static/img/product_lamp.png', + 'image_512': b'/product/static/img/product_lamp.png', 'display_name': _('Lamp'), 'description_sale': _('Lightbulb sold separately'), }, { - 'image_512': '/product/static/img/product_product_20-image.png', + 'image_512': b'/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', + 'image_512': b'/product/static/img/product_product_27-image.png', 'display_name': _('Drawer'), 'description_sale': _('On wheels'), + }, { + 'image_512': b'/product/static/img/product_product_7-image.png', + 'display_name': _('Box'), + 'description_sale': _('Reinforced for heavy loads'), + }, { + 'image_512': b'/product/static/img/product_product_9-image.png', + 'display_name': _('Bin'), + 'description_sale': _('Pedal-based opening system'), }] merged = [] for index in range(0, max(len(samples), len(data))): @@ -37,3 +51,107 @@ class WebsiteSnippetFilter(models.Model): # merge definitions samples = merged return samples + + def _filter_records_to_values(self, records, is_sample=False): + res_products = super()._filter_records_to_values(records, is_sample) + if self.model_name == 'product.product': + for res_product in res_products: + product = res_product.get('_record') + if not is_sample: + res_product.update(product._get_combination_info_variant()) + return res_products + + @api.model + def _get_products(self, mode, context): + dynamic_filter = context.get('dynamic_filter') + handler = getattr(self, '_get_products_%s' % mode, self._get_products_latest_sold) + website = self.env['website'].get_current_website() + search_domain = context.get('search_domain') + limit = context.get('limit') + domain = expression.AND([ + [('website_published', '=', True)], + website.get_current_website().website_domain(), + search_domain or [], + ]) + products = handler(website, limit, domain, context) + return dynamic_filter._filter_records_to_values(products, False) + + def _get_products_latest_sold(self, website, limit, domain, context): + products = [] + sale_orders = self.env['sale.order'].sudo().search([ + ('website_id', '=', website.id), + ('state', 'in', ('sale', 'done')), + ], limit=8, order='date_order DESC') + if sale_orders: + sold_products = [p.product_id.id for p in sale_orders.order_line] + products_ids = [id for id, _ in Counter(sold_products).most_common()] + if products_ids: + domain = expression.AND([ + domain, + [('id', 'in', products_ids)], + ]) + products = self.env['product.product'].with_context(display_default_code=False).search(domain) + products = products.sorted(key=lambda p: products_ids.index(p.id))[:limit] + return products + + def _get_products_latest_viewed(self, website, limit, domain, context): + products = [] + visitor = self.env['website.visitor']._get_visitor_from_request() + if visitor: + excluded_products = website.sale_get_order().order_line.product_id.ids + tracked_products = self.env['website.track'].sudo().read_group( + [('visitor_id', '=', visitor.id), ('product_id', '!=', False), ('product_id.website_published', '=', True), ('product_id', 'not in', excluded_products)], + ['product_id', 'visit_datetime:max'], ['product_id'], limit=limit, orderby='visit_datetime DESC') + products_ids = [product['product_id'][0] for product in tracked_products] + if products_ids: + domain = expression.AND([ + domain, + [('id', 'in', products_ids)], + ]) + products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit) + return products + + def _get_products_recently_sold_with(self, website, limit, domain, context): + products = [] + current_id = context.get('product_template_id') + if current_id: + current_id = int(current_id) + sale_orders = self.env['sale.order'].sudo().search([ + ('website_id', '=', website.id), + ('state', 'in', ('sale', 'done')), + ('order_line.product_id.product_tmpl_id', '=', current_id), + ], limit=8, order='date_order DESC') + if sale_orders: + current_template = self.env['product.template'].browse(current_id) + excluded_products = website.sale_get_order().order_line.product_id.product_tmpl_id.product_variant_ids.ids + excluded_products.extend(current_template.product_variant_ids.ids) + included_products = [] + for sale_order in sale_orders: + included_products.extend(sale_order.order_line.product_id.ids) + products_ids = list(set(included_products) - set(excluded_products)) + if products_ids: + domain = expression.AND([ + domain, + [('id', 'in', products_ids)], + ]) + products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit) + return products + + def _get_products_accessories(self, website, limit, domain, context): + products = [] + current_id = context.get('product_template_id') + if current_id: + current_id = int(current_id) + current_template = self.env['product.template'].browse(current_id) + if current_template.exists(): + excluded_products = website.sale_get_order().order_line.product_id.ids + excluded_products.extend(current_template.product_variant_ids.ids) + included_products = current_template.product_variant_ids.accessory_product_ids.filtered('website_published').ids + products_ids = list(set(included_products) - set(excluded_products)) + if products_ids: + domain = expression.AND([ + domain, + [('id', 'in', products_ids)], + ]) + products = self.env['product.product'].with_context(display_default_code=False).search(domain, limit=limit) + return products diff --git a/addons/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg b/addons/website_sale/static/src/img/snippets_thumbs/s_dynamic_products.svg similarity index 100% rename from addons/website_sale/static/src/img/snippets_thumbs/s_products_recently_viewed.svg rename to addons/website_sale/static/src/img/snippets_thumbs/s_dynamic_products.svg diff --git a/addons/website_sale/static/src/js/website_sale_recently_viewed.js b/addons/website_sale/static/src/js/website_sale_recently_viewed.js index ea9fedec840..cb34671ee69 100644 --- a/addons/website_sale/static/src/js/website_sale_recently_viewed.js +++ b/addons/website_sale/static/src/js/website_sale_recently_viewed.js @@ -1,182 +1,7 @@ odoo.define('website_sale.recently_viewed', function (require) { -var concurrency = require('web.concurrency'); -var config = require('web.config'); -var core = require('web.core'); var publicWidget = require('web.public.widget'); var utils = require('web.utils'); -var wSaleUtils = require('website_sale.utils'); - -var qweb = core.qweb; - -publicWidget.registry.productsRecentlyViewedSnippet = publicWidget.Widget.extend({ - selector: '.s_wsale_products_recently_viewed', - xmlDependencies: ['/website_sale/static/src/xml/website_sale_recently_viewed.xml'], - disabledInEditableMode: false, - read_events: { - 'click .js_add_cart': '_onAddToCart', - 'click .js_remove': '_onRemove', - }, - - /** - * @constructor - */ - init: function () { - this._super.apply(this, arguments); - this._dp = new concurrency.DropPrevious(); - this.uniqueId = _.uniqueId('o_carousel_recently_viewed_products_'); - this._onResizeChange = _.debounce(this._addCarousel, 100); - }, - /** - * @override - */ - start: function () { - this._dp.add(this._fetch()).then(this._render.bind(this)); - $(window).resize(() => { - this._onResizeChange(); - }); - return this._super.apply(this, arguments); - }, - /** - * @override - */ - destroy: function () { - this._super(...arguments); - this.$el.addClass('d-none'); - this.$el.find('.slider').html(''); - }, - - //-------------------------------------------------------------------------- - // Private - //-------------------------------------------------------------------------- - - /** - * @private - */ - _fetch: function () { - return this._rpc({ - route: '/shop/products/recently_viewed', - }).then(res => { - var products = res['products']; - - // In edit mode, if the current visitor has no recently viewed - // products, use demo data. - if (this.editableMode && (!products || !products.length)) { - return { - 'products': [{ - id: 0, - website_url: '#', - display_name: 'Product 1', - price: '$ 750.00', - }, { - id: 0, - website_url: '#', - display_name: 'Product 2', - price: '$ 750.00', - }, { - id: 0, - website_url: '#', - display_name: 'Product 3', - price: '$ 750.00', - }, { - id: 0, - website_url: '#', - display_name: 'Product 4', - price: '$ 750.00', - }], - }; - } - - return res; - }); - }, - /** - * @private - */ - _render: function (res) { - var products = res['products']; - var mobileProducts = [], webProducts = [], productsTemp = []; - _.each(products, function (product) { - if (productsTemp.length === 4) { - webProducts.push(productsTemp); - productsTemp = []; - } - productsTemp.push(product); - mobileProducts.push([product]); - }); - if (productsTemp.length) { - webProducts.push(productsTemp); - } - - this.mobileCarousel = $(qweb.render('website_sale.productsRecentlyViewed', { - uniqueId: this.uniqueId, - productFrame: 1, - productsGroups: mobileProducts, - })); - this.webCarousel = $(qweb.render('website_sale.productsRecentlyViewed', { - uniqueId: this.uniqueId, - productFrame: 4, - productsGroups: webProducts, - })); - this._addCarousel(); - this.$el.toggleClass('d-none', !(products && products.length)); - }, - /** - * Add the right carousel depending on screen size. - * @private - */ - _addCarousel: function () { - var carousel = config.device.size_class <= config.device.SIZES.SM ? this.mobileCarousel : this.webCarousel; - this.$('.slider').html(carousel).css('display', ''); // Removing display is kept for compatibility (it was hidden before) - }, - - //-------------------------------------------------------------------------- - // Handlers - //-------------------------------------------------------------------------- - - /** - * Add product to cart and reload the carousel. - * @private - * @param {Event} ev - */ - _onAddToCart: function (ev) { - var self = this; - var $card = $(ev.currentTarget).closest('.card'); - this._rpc({ - route: "/shop/cart/update_json", - params: { - product_id: $card.find('input[data-product-id]').data('product-id'), - add_qty: 1 - }, - }).then(function (data) { - wSaleUtils.updateCartNavBar(data); - var $navButton = $('header .o_wsale_my_cart').first(); - var fetch = self._fetch(); - var animation = wSaleUtils.animateClone($navButton, $(ev.currentTarget).parents('.o_carousel_product_card'), 25, 40); - Promise.all([fetch, animation]).then(function (values) { - self._render(values[0]); - }); - }); - }, - - /** - * Remove product from recently viewed products. - * @private - * @param {Event} ev - */ - _onRemove: function (ev) { - var self = this; - var $card = $(ev.currentTarget).closest('.card'); - this._rpc({ - route: "/shop/products/recently_viewed_delete", - params: { - product_id: $card.find('input[data-product-id]').data('product-id'), - }, - }).then(function (data) { - self._render(data); - }); - }, -}); publicWidget.registry.productsRecentlyViewedUpdate = publicWidget.Widget.extend({ selector: '#product_detail', diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js index 2bd13284876..4d6fd8c1569 100644 --- a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.js @@ -5,37 +5,19 @@ const config = require('web.config'); const core = require('web.core'); const publicWidget = require('web.public.widget'); const DynamicSnippetCarousel = require('website.s_dynamic_snippet_carousel'); +var wSaleUtils = require('website_sale.utils'); const DynamicSnippetProducts = DynamicSnippetCarousel.extend({ selector: '.s_dynamic_snippet_products', + read_events: { + 'click .js_add_cart': '_onAddToCart', + 'click .js_remove': '_onRemoveFromRecentlyViewed', + }, //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- - /** - * Method to be overridden in child components if additional configuration elements - * are required in order to fetch data. - * @override - * @private - */ - _isConfigComplete: function () { - return this._super.apply(this, arguments) && this.$el.get(0).dataset.productCategoryId !== undefined; - }, - /** - * - * @override - * @private - */ - _mustMessageWarningBeHidden: function() { - const isInitialDrop = this.$el.get(0).dataset.templateKey === undefined; - // This snippet has default values obtained after the initial start and render after drop. - // Because of this there is an initial refresh happening right after. - // We want to avoid showing the incomplete config message before this refresh. - // Since the refreshed call will always happen with a defined templateKey, - // if it is not set yet, we know it is the drop call and we can avoid showing the message. - return isInitialDrop || this._super.apply(this, arguments); - }, /** * Method to be overridden in child components in order to provide a search * domain if needed. @@ -44,12 +26,99 @@ const DynamicSnippetProducts = DynamicSnippetCarousel.extend({ */ _getSearchDomain: function () { const searchDomain = this._super.apply(this, arguments); - const productCategoryId = parseInt(this.$el.get(0).dataset.productCategoryId); - if (productCategoryId >= 0) { - searchDomain.push(['public_categ_ids', 'child_of', productCategoryId]); + let productCategoryId = this.$el.get(0).dataset.productCategoryId; + if (productCategoryId && productCategoryId !== 'all') { + if (productCategoryId === 'current') { + productCategoryId = undefined; + const productCategoryField = $("#product_details").find(".product_category_id"); + if (productCategoryField && productCategoryField.length) { + productCategoryId = parseInt(productCategoryField[0].value); + } + if (!productCategoryId) { + this.trigger_up('main_object_request', { + callback: function (value) { + if (value.model === "product.public.category") { + productCategoryId = value.id; + } + }, + }); + } + if (!productCategoryId) { + // Try with categories from product, unfortunately the category hierarchy is not matched with this approach + const productTemplateId = $("#product_details").find(".product_template_id"); + if (productTemplateId && productTemplateId.length) { + searchDomain.push(['public_categ_ids.product_tmpl_ids', '=', parseInt(productTemplateId[0].value)]); + } + } + } + if (productCategoryId) { + searchDomain.push(['public_categ_ids', 'child_of', parseInt(productCategoryId)]); + } + } + const productNames = this.$el.get(0).dataset.productNames; + if (productNames) { + const nameDomain = []; + for (const productName of productNames.split(',')) { + if (nameDomain.length) { + nameDomain.unshift('|'); + } + nameDomain.push(['name', 'ilike', productName]); + } + searchDomain.push(...nameDomain); } return searchDomain; }, + /** + * @override + */ + _getRpcParameters: function () { + const productTemplateId = $("#product_details").find(".product_template_id"); + return Object.assign(this._super.apply(this, arguments), { + productTemplateId: productTemplateId && productTemplateId.length ? productTemplateId[0].value : undefined, + }); + }, + /** + * Add product to cart and reload the carousel. + * @private + * @param {Event} ev + */ + _onAddToCart: function (ev) { + var self = this; + var $card = $(ev.currentTarget).closest('.card'); + this._rpc({ + route: "/shop/cart/update_json", + params: { + product_id: $card.find('input[data-product-id]').data('product-id'), + add_qty: 1 + }, + }).then(function (data) { + wSaleUtils.updateCartNavBar(data); + var $navButton = $('header .o_wsale_my_cart').first(); + var fetch = self._fetchData(); + var animation = wSaleUtils.animateClone($navButton, $(ev.currentTarget).parents('.card'), 25, 40); + Promise.all([fetch, animation]).then(function (values) { + self._render(); + }); + }); + }, + + /** + * Remove product from recently viewed products. + * @private + * @param {Event} ev + */ + _onRemoveFromRecentlyViewed: function (ev) { + var self = this; + var $card = $(ev.currentTarget).closest('.card'); + this._rpc({ + route: "/shop/products/recently_viewed_delete", + params: { + product_id: $card.find('input[data-product-id]').data('product-id'), + }, + }).then(function (data) { + self._fetchData().then(() => self._render()); + }); + }, }); publicWidget.registry.dynamic_snippet_products = DynamicSnippetProducts; diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml deleted file mode 100644 index f45bf3c51bb..00000000000 --- a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/000.xml +++ /dev/null @@ -1,8 +0,0 @@ - - - - - - - - diff --git a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js index 828daeb05de..b604aa17dfe 100644 --- a/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js +++ b/addons/website_sale/static/src/snippets/s_dynamic_snippet_products/options.js @@ -4,6 +4,8 @@ odoo.define('website_sale.s_dynamic_snippet_products_options', function (require const options = require('web_editor.snippets.options'); const s_dynamic_snippet_carousel_options = require('website.s_dynamic_snippet_carousel_options'); +var wUtils = require('website.utils'); + const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend({ /** @@ -12,44 +14,18 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend( */ init: function () { this._super.apply(this, arguments); + this.modelNameFilter = 'product.product'; + const productTemplateId = $("input.product_template_id"); + this.hasProductTemplateId = productTemplateId.val(); + if (!this.hasProductTemplateId) { + this.contextualFilterDomain.push(['product_cross_selling', '=', false]); + } this.productCategories = {}; }, - /** - * - * @override - */ - onBuilt: function () { - this._super.apply(this, arguments); - this._rpc({ - model: 'ir.model.data', - method: 'search_read', - kwargs: { - domain: [['module', '=', 'website_sale'], ['model', '=', 'website.snippet.filter']], - fields: ['id', 'res_id'], - } - }).then((data) => { - this.$target.get(0).dataset.filterId = data[0].res_id; - this.$target.get(0).dataset.numberOfRecords = this.dynamicFilters[data[0].res_id].limit; - this._refreshPublicWidgets(); - // Refresh is needed because default values are obtained after start() - }); - }, - //-------------------------------------------------------------------------- // Private //-------------------------------------------------------------------------- - /** - * - * @override - * @private - */ - _computeWidgetVisibility: function (widgetName, params) { - if (widgetName === 'filter_opt') { - return false; - } - return this._super.apply(this, arguments); - }, /** * Fetches product categories. * @private @@ -60,7 +36,7 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend( model: 'product.public.category', method: 'search_read', kwargs: { - domain: [], + domain: wUtils.websiteDomain(this), fields: ['id', 'name'], } }); @@ -87,22 +63,6 @@ const dynamicSnippetProductsOptions = s_dynamic_snippet_carousel_options.extend( const productCategoriesSelectorEl = uiFragment.querySelector('[data-name="product_category_opt"]'); return this._renderSelectUserValueWidgetButtons(productCategoriesSelectorEl, this.productCategories); }, - /** - * Sets default options values. - * @override - * @private - */ - _setOptionsDefaultValues: function () { - this._super.apply(this, arguments); - const templateKeys = this.$el.find("we-select[data-attribute-name='templateKey'] we-selection-items we-button"); - if (templateKeys.length > 0) { - this._setOptionValue('templateKey', templateKeys.attr('data-select-data-attribute')); - } - const productCategories = this.$el.find("we-select[data-attribute-name='productCategoryId'] we-selection-items we-button"); - if (productCategories.length > 0) { - this._setOptionValue('productCategoryId', productCategories.attr('data-select-data-attribute')); - } - }, }); diff --git a/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml b/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml deleted file mode 100644 index 34b9ec0ea5f..00000000000 --- a/addons/website_sale/static/src/xml/website_sale_recently_viewed.xml +++ /dev/null @@ -1,49 +0,0 @@ - - - - -