summaryrefslogtreecommitdiff
path: root/addons/website_sale/models
diff options
context:
space:
mode:
authorstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
committerstephanchrst <stephanchrst@gmail.com>2022-05-10 21:51:50 +0700
commit3751379f1e9a4c215fb6eb898b4ccc67659b9ace (patch)
treea44932296ef4a9b71d5f010906253d8c53727726 /addons/website_sale/models
parent0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff)
initial commit 2
Diffstat (limited to 'addons/website_sale/models')
-rw-r--r--addons/website_sale/models/__init__.py20
-rw-r--r--addons/website_sale/models/account_move.py12
-rw-r--r--addons/website_sale/models/crm_team.py60
-rw-r--r--addons/website_sale/models/digest.py31
-rw-r--r--addons/website_sale/models/ir_http.py15
-rw-r--r--addons/website_sale/models/mail_compose_message.py20
-rw-r--r--addons/website_sale/models/product.py476
-rw-r--r--addons/website_sale/models/product_attribute.py26
-rw-r--r--addons/website_sale/models/product_image.py63
-rw-r--r--addons/website_sale/models/res_company.py17
-rw-r--r--addons/website_sale/models/res_config_settings.py74
-rw-r--r--addons/website_sale/models/res_country.py14
-rw-r--r--addons/website_sale/models/res_partner.py25
-rw-r--r--addons/website_sale/models/sale_order.py385
-rw-r--r--addons/website_sale/models/website.py415
-rw-r--r--addons/website_sale/models/website_page.py14
-rw-r--r--addons/website_sale/models/website_snippet_filter.py12
-rw-r--r--addons/website_sale/models/website_visitor.py48
18 files changed, 1727 insertions, 0 deletions
diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py
new file mode 100644
index 00000000..d7863895
--- /dev/null
+++ b/addons/website_sale/models/__init__.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_move
+from . import crm_team
+from . import ir_http
+from . import mail_compose_message
+from . import product_image
+from . import product
+from . import res_country
+from . import res_partner
+from . import sale_order
+from . import website
+from . import res_config_settings
+from . import digest
+from . import res_company
+from . import product_attribute
+from . import website_page
+from . import website_visitor
+from . import website_snippet_filter
diff --git a/addons/website_sale/models/account_move.py b/addons/website_sale/models/account_move.py
new file mode 100644
index 00000000..80a6b132
--- /dev/null
+++ b/addons/website_sale/models/account_move.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, api
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ website_id = fields.Many2one('website', related='partner_id.website_id', string='Website',
+ help='Website through which this invoice was created.',
+ store=True, readonly=True, tracking=True)
diff --git a/addons/website_sale/models/crm_team.py b/addons/website_sale/models/crm_team.py
new file mode 100644
index 00000000..2d48bd30
--- /dev/null
+++ b/addons/website_sale/models/crm_team.py
@@ -0,0 +1,60 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+from odoo import fields,api, models, _
+from odoo.exceptions import UserError, ValidationError
+
+
+class CrmTeam(models.Model):
+ _inherit = "crm.team"
+
+ website_ids = fields.One2many('website', 'salesteam_id', string='Websites', help="Websites using this Sales Team")
+ abandoned_carts_count = fields.Integer(
+ compute='_compute_abandoned_carts',
+ string='Number of Abandoned Carts', readonly=True)
+ abandoned_carts_amount = fields.Integer(
+ compute='_compute_abandoned_carts',
+ string='Amount of Abandoned Carts', readonly=True)
+
+ def _compute_abandoned_carts(self):
+ # abandoned carts to recover are draft sales orders that have no order lines,
+ # a partner other than the public user, and created over an hour ago
+ # and the recovery mail was not yet sent
+ counts = {}
+ amounts = {}
+ website_teams = self.filtered(lambda team: team.website_ids)
+ if website_teams:
+ abandoned_carts_data = self.env['sale.order'].read_group([
+ ('is_abandoned_cart', '=', True),
+ ('cart_recovery_email_sent', '=', False),
+ ('team_id', 'in', website_teams.ids),
+ ], ['amount_total', 'team_id'], ['team_id'])
+ counts = {data['team_id'][0]: data['team_id_count'] for data in abandoned_carts_data}
+ amounts = {data['team_id'][0]: data['amount_total'] for data in abandoned_carts_data}
+ for team in self:
+ team.abandoned_carts_count = counts.get(team.id, 0)
+ team.abandoned_carts_amount = amounts.get(team.id, 0)
+
+ def get_abandoned_carts(self):
+ self.ensure_one()
+ return {
+ 'name': _('Abandoned Carts'),
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'tree,form',
+ 'domain': [('is_abandoned_cart', '=', True)],
+ 'search_view_id': self.env.ref('sale.sale_order_view_search_inherit_sale').id,
+ 'context': {
+ 'search_default_team_id': self.id,
+ 'default_team_id': self.id,
+ 'search_default_recovery_email': 1,
+ 'create': False
+ },
+ 'res_model': 'sale.order',
+ 'help': _('''<p class="o_view_nocontent_smiling_face">
+ You can find all abandoned carts here, i.e. the carts generated by your website's visitors from over an hour ago that haven't been confirmed yet.</p>
+ <p>You should send an email to the customers to encourage them!</p>
+ '''),
+ }
diff --git a/addons/website_sale/models/digest.py b/addons/website_sale/models/digest.py
new file mode 100644
index 00000000..4d03289c
--- /dev/null
+++ b/addons/website_sale/models/digest.py
@@ -0,0 +1,31 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import fields, models, _
+from odoo.exceptions import AccessError
+
+
+class Digest(models.Model):
+ _inherit = 'digest.digest'
+
+ kpi_website_sale_total = fields.Boolean('eCommerce Sales')
+ kpi_website_sale_total_value = fields.Monetary(compute='_compute_kpi_website_sale_total_value')
+
+ def _compute_kpi_website_sale_total_value(self):
+ if not self.env.user.has_group('sales_team.group_sale_salesman_all_leads'):
+ raise AccessError(_("Do not have access, skip this data for user's digest email"))
+ for record in self:
+ start, end, company = record._get_kpi_compute_parameters()
+ confirmed_website_sales = self.env['sale.order'].search([
+ ('date_order', '>=', start),
+ ('date_order', '<', end),
+ ('state', 'not in', ['draft', 'cancel', 'sent']),
+ ('website_id', '!=', False),
+ ('company_id', '=', company.id)
+ ])
+ record.kpi_website_sale_total_value = sum(confirmed_website_sales.mapped('amount_total'))
+
+ def _compute_kpis_actions(self, company, user):
+ res = super(Digest, self)._compute_kpis_actions(company, user)
+ res['kpi_website_sale_total'] = 'website.backend_dashboard&menu_id=%s' % self.env.ref('website.menu_website_configuration').id
+ return res
diff --git a/addons/website_sale/models/ir_http.py b/addons/website_sale/models/ir_http.py
new file mode 100644
index 00000000..d1f6e306
--- /dev/null
+++ b/addons/website_sale/models/ir_http.py
@@ -0,0 +1,15 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import models
+from odoo.http import request
+
+
+class IrHttp(models.AbstractModel):
+ _inherit = 'ir.http'
+
+ @classmethod
+ def _dispatch(cls):
+ affiliate_id = request.httprequest.args.get('affiliate_id')
+ if affiliate_id:
+ request.session['affiliate_id'] = int(affiliate_id)
+ return super(IrHttp, cls)._dispatch()
diff --git a/addons/website_sale/models/mail_compose_message.py b/addons/website_sale/models/mail_compose_message.py
new file mode 100644
index 00000000..68b2fe35
--- /dev/null
+++ b/addons/website_sale/models/mail_compose_message.py
@@ -0,0 +1,20 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models
+
+
+class MailComposeMessage(models.TransientModel):
+ _inherit = 'mail.compose.message'
+
+ def send_mail(self, auto_commit=False):
+ context = self._context
+ # TODO TDE: clean that brole one day
+ if context.get('website_sale_send_recovery_email') and self.model == 'sale.order' and context.get('active_ids'):
+ self.env['sale.order'].search([
+ ('id', 'in', context.get('active_ids')),
+ ('cart_recovery_email_sent', '=', False),
+ ('is_abandoned_cart', '=', True)
+ ]).write({'cart_recovery_email_sent': True})
+ self = self.with_context(mail_post_autofollow=True)
+ return super(MailComposeMessage, self).send_mail(auto_commit=auto_commit)
diff --git a/addons/website_sale/models/product.py b/addons/website_sale/models/product.py
new file mode 100644
index 00000000..e7054a9a
--- /dev/null
+++ b/addons/website_sale/models/product.py
@@ -0,0 +1,476 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError, UserError
+from odoo.addons.http_routing.models.ir_http import slug
+from odoo.addons.website.models import ir_http
+from odoo.tools.translate import html_translate
+from odoo.osv import expression
+
+
+class ProductRibbon(models.Model):
+ _name = "product.ribbon"
+ _description = 'Product ribbon'
+
+ def name_get(self):
+ return [(ribbon.id, '%s (#%d)' % (tools.html2plaintext(ribbon.html), ribbon.id)) for ribbon in self]
+
+ html = fields.Char(string='Ribbon html', required=True, translate=True)
+ bg_color = fields.Char(string='Ribbon background color', required=False)
+ text_color = fields.Char(string='Ribbon text color', required=False)
+ html_class = fields.Char(string='Ribbon class', required=True, default='')
+
+
+class ProductPricelist(models.Model):
+ _inherit = "product.pricelist"
+
+ def _default_website(self):
+ """ Find the first company's website, if there is one. """
+ company_id = self.env.company.id
+
+ if self._context.get('default_company_id'):
+ company_id = self._context.get('default_company_id')
+
+ domain = [('company_id', '=', company_id)]
+ return self.env['website'].search(domain, limit=1)
+
+ website_id = fields.Many2one('website', string="Website", ondelete='restrict', default=_default_website, domain="[('company_id', '=?', company_id)]")
+ code = fields.Char(string='E-commerce Promotional Code', groups="base.group_user")
+ selectable = fields.Boolean(help="Allow the end user to choose this price list")
+
+ def clear_cache(self):
+ # website._get_pl_partner_order() is cached to avoid to recompute at each request the
+ # list of available pricelists. So, we need to invalidate the cache when
+ # we change the config of website price list to force to recompute.
+ website = self.env['website']
+ website._get_pl_partner_order.clear_cache(website)
+
+ @api.model
+ def create(self, data):
+ if data.get('company_id') and not data.get('website_id'):
+ # l10n modules install will change the company currency, creating a
+ # pricelist for that currency. Do not use user's company in that
+ # case as module install are done with OdooBot (company 1)
+ self = self.with_context(default_company_id=data['company_id'])
+ res = super(ProductPricelist, self).create(data)
+ self.clear_cache()
+ return res
+
+ def write(self, data):
+ res = super(ProductPricelist, self).write(data)
+ if data.keys() & {'code', 'active', 'website_id', 'selectable', 'company_id'}:
+ self._check_website_pricelist()
+ self.clear_cache()
+ return res
+
+ def unlink(self):
+ res = super(ProductPricelist, self).unlink()
+ self._check_website_pricelist()
+ self.clear_cache()
+ return res
+
+ def _get_partner_pricelist_multi_search_domain_hook(self, company_id):
+ domain = super(ProductPricelist, self)._get_partner_pricelist_multi_search_domain_hook(company_id)
+ website = ir_http.get_request_website()
+ if website:
+ domain += self._get_website_pricelists_domain(website.id)
+ return domain
+
+ def _get_partner_pricelist_multi_filter_hook(self):
+ res = super(ProductPricelist, self)._get_partner_pricelist_multi_filter_hook()
+ website = ir_http.get_request_website()
+ if website:
+ res = res.filtered(lambda pl: pl._is_available_on_website(website.id))
+ return res
+
+ def _check_website_pricelist(self):
+ for website in self.env['website'].search([]):
+ if not website.pricelist_ids:
+ raise UserError(_("With this action, '%s' website would not have any pricelist available.") % (website.name))
+
+ def _is_available_on_website(self, website_id):
+ """ To be able to be used on a website, a pricelist should either:
+ - Have its `website_id` set to current website (specific pricelist).
+ - Have no `website_id` set and should be `selectable` (generic pricelist)
+ or should have a `code` (generic promotion).
+ - Have no `company_id` or a `company_id` matching its website one.
+
+ Note: A pricelist without a website_id, not selectable and without a
+ code is a backend pricelist.
+
+ Change in this method should be reflected in `_get_website_pricelists_domain`.
+ """
+ self.ensure_one()
+ if self.company_id and self.company_id != self.env["website"].browse(website_id).company_id:
+ return False
+ return self.website_id.id == website_id or (not self.website_id and (self.selectable or self.sudo().code))
+
+ def _get_website_pricelists_domain(self, website_id):
+ ''' Check above `_is_available_on_website` for explanation.
+ Change in this method should be reflected in `_is_available_on_website`.
+ '''
+ company_id = self.env["website"].browse(website_id).company_id.id
+ return [
+ '&', ('company_id', 'in', [False, company_id]),
+ '|', ('website_id', '=', website_id),
+ '&', ('website_id', '=', False),
+ '|', ('selectable', '=', True), ('code', '!=', False),
+ ]
+
+ def _get_partner_pricelist_multi(self, partner_ids, company_id=None):
+ ''' If `property_product_pricelist` is read from website, we should use
+ the website's company and not the user's one.
+ Passing a `company_id` to super will avoid using the current user's
+ company.
+ '''
+ website = ir_http.get_request_website()
+ if not company_id and website:
+ company_id = website.company_id.id
+ return super(ProductPricelist, self)._get_partner_pricelist_multi(partner_ids, company_id)
+
+ @api.constrains('company_id', 'website_id')
+ def _check_websites_in_company(self):
+ '''Prevent misconfiguration multi-website/multi-companies.
+ If the record has a company, the website should be from that company.
+ '''
+ for record in self.filtered(lambda pl: pl.website_id and pl.company_id):
+ if record.website_id.company_id != record.company_id:
+ raise ValidationError(_("""Only the company's websites are allowed.\nLeave the Company field empty or select a website from that company."""))
+
+
+class ProductPublicCategory(models.Model):
+ _name = "product.public.category"
+ _inherit = ["website.seo.metadata", "website.multi.mixin", 'image.mixin']
+ _description = "Website Product Category"
+ _parent_store = True
+ _order = "sequence, name, id"
+
+ def _default_sequence(self):
+ cat = self.search([], limit=1, order="sequence DESC")
+ if cat:
+ return cat.sequence + 5
+ return 10000
+
+ name = fields.Char(required=True, translate=True)
+ parent_id = fields.Many2one('product.public.category', string='Parent Category', index=True, ondelete="cascade")
+ parent_path = fields.Char(index=True)
+ child_id = fields.One2many('product.public.category', 'parent_id', string='Children Categories')
+ parents_and_self = fields.Many2many('product.public.category', compute='_compute_parents_and_self')
+ sequence = fields.Integer(help="Gives the sequence order when displaying a list of product categories.", index=True, default=_default_sequence)
+ website_description = fields.Html('Category Description', sanitize_attributes=False, translate=html_translate, sanitize_form=False)
+ product_tmpl_ids = fields.Many2many('product.template', relation='product_public_category_product_template_rel')
+
+ @api.constrains('parent_id')
+ def check_parent_id(self):
+ if not self._check_recursion():
+ raise ValueError(_('Error ! You cannot create recursive categories.'))
+
+ def name_get(self):
+ res = []
+ for category in self:
+ res.append((category.id, " / ".join(category.parents_and_self.mapped('name'))))
+ return res
+
+ def _compute_parents_and_self(self):
+ for category in self:
+ if category.parent_path:
+ category.parents_and_self = self.env['product.public.category'].browse([int(p) for p in category.parent_path.split('/')[:-1]])
+ else:
+ category.parents_and_self = category
+
+
+class ProductTemplate(models.Model):
+ _inherit = ["product.template", "website.seo.metadata", 'website.published.multi.mixin', 'rating.mixin']
+ _name = 'product.template'
+ _mail_post_access = 'read'
+ _check_company_auto = True
+
+ website_description = fields.Html('Description for the website', sanitize_attributes=False, translate=html_translate, sanitize_form=False)
+ alternative_product_ids = fields.Many2many(
+ 'product.template', 'product_alternative_rel', 'src_id', 'dest_id', check_company=True,
+ string='Alternative Products', help='Suggest alternatives to your customer (upsell strategy). '
+ 'Those products show up on the product page.')
+ accessory_product_ids = fields.Many2many(
+ 'product.product', 'product_accessory_rel', 'src_id', 'dest_id', string='Accessory Products', check_company=True,
+ help='Accessories show up when the customer reviews the cart before payment (cross-sell strategy).')
+ website_size_x = fields.Integer('Size X', default=1)
+ website_size_y = fields.Integer('Size Y', default=1)
+ website_ribbon_id = fields.Many2one('product.ribbon', string='Ribbon')
+ website_sequence = fields.Integer('Website Sequence', help="Determine the display order in the Website E-commerce",
+ default=lambda self: self._default_website_sequence(), copy=False)
+ public_categ_ids = fields.Many2many(
+ 'product.public.category', relation='product_public_category_product_template_rel',
+ string='Website Product Category',
+ help="The product will be available in each mentioned eCommerce category. Go to Shop > "
+ "Customize and enable 'eCommerce categories' to view all eCommerce categories.")
+
+ product_template_image_ids = fields.One2many('product.image', 'product_tmpl_id', string="Extra Product Media", copy=True)
+
+ def _has_no_variant_attributes(self):
+ """Return whether this `product.template` has at least one no_variant
+ attribute.
+
+ :return: True if at least one no_variant attribute, False otherwise
+ :rtype: bool
+ """
+ self.ensure_one()
+ return any(a.create_variant == 'no_variant' for a in self.valid_product_template_attribute_line_ids.attribute_id)
+
+ def _has_is_custom_values(self):
+ self.ensure_one()
+ """Return whether this `product.template` has at least one is_custom
+ attribute value.
+
+ :return: True if at least one is_custom attribute value, False otherwise
+ :rtype: bool
+ """
+ return any(v.is_custom for v in self.valid_product_template_attribute_line_ids.product_template_value_ids._only_active())
+
+ def _get_possible_variants_sorted(self, parent_combination=None):
+ """Return the sorted recordset of variants that are possible.
+
+ The order is based on the order of the attributes and their values.
+
+ See `_get_possible_variants` for the limitations of this method with
+ dynamic or no_variant attributes, and also for a warning about
+ performances.
+
+ :param parent_combination: combination from which `self` is an
+ optional or accessory product
+ :type parent_combination: recordset `product.template.attribute.value`
+
+ :return: the sorted variants that are possible
+ :rtype: recordset of `product.product`
+ """
+ self.ensure_one()
+
+ def _sort_key_attribute_value(value):
+ # if you change this order, keep it in sync with _order from `product.attribute`
+ return (value.attribute_id.sequence, value.attribute_id.id)
+
+ def _sort_key_variant(variant):
+ """
+ We assume all variants will have the same attributes, with only one value for each.
+ - first level sort: same as "product.attribute"._order
+ - second level sort: same as "product.attribute.value"._order
+ """
+ keys = []
+ for attribute in variant.product_template_attribute_value_ids.sorted(_sort_key_attribute_value):
+ # if you change this order, keep it in sync with _order from `product.attribute.value`
+ keys.append(attribute.product_attribute_value_id.sequence)
+ keys.append(attribute.id)
+ return keys
+
+ return self._get_possible_variants(parent_combination).sorted(_sort_key_variant)
+
+ def _get_combination_info(self, combination=False, product_id=False, add_qty=1, pricelist=False, parent_combination=False, only_template=False):
+ """Override for website, where we want to:
+ - take the website pricelist if no pricelist is set
+ - apply the b2b/b2c setting to the result
+
+ This will work when adding website_id to the context, which is done
+ automatically when called from routes with website=True.
+ """
+ self.ensure_one()
+
+ current_website = False
+
+ if self.env.context.get('website_id'):
+ current_website = self.env['website'].get_current_website()
+ if not pricelist:
+ pricelist = current_website.get_current_pricelist()
+
+ combination_info = super(ProductTemplate, self)._get_combination_info(
+ combination=combination, product_id=product_id, add_qty=add_qty, pricelist=pricelist,
+ parent_combination=parent_combination, only_template=only_template)
+
+ if self.env.context.get('website_id'):
+ partner = self.env.user.partner_id
+ company_id = current_website.company_id
+ product = self.env['product.product'].browse(combination_info['product_id']) or self
+
+ tax_display = self.user_has_groups('account.group_show_line_subtotals_tax_excluded') and 'total_excluded' or 'total_included'
+ fpos = self.env['account.fiscal.position'].sudo().get_fiscal_position(partner.id)
+ taxes = fpos.map_tax(product.sudo().taxes_id.filtered(lambda x: x.company_id == company_id), product, partner)
+
+ # The list_price is always the price of one.
+ quantity_1 = 1
+ combination_info['price'] = self.env['account.tax']._fix_tax_included_price_company(combination_info['price'], product.sudo().taxes_id, taxes, company_id)
+ price = taxes.compute_all(combination_info['price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
+ if pricelist.discount_policy == 'without_discount':
+ combination_info['list_price'] = self.env['account.tax']._fix_tax_included_price_company(combination_info['list_price'], product.sudo().taxes_id, taxes, company_id)
+ list_price = taxes.compute_all(combination_info['list_price'], pricelist.currency_id, quantity_1, product, partner)[tax_display]
+ else:
+ list_price = price
+ has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1
+
+ combination_info.update(
+ price=price,
+ list_price=list_price,
+ has_discounted_price=has_discounted_price,
+ )
+
+ return combination_info
+
+ def _create_first_product_variant(self, log_warning=False):
+ """Create if necessary and possible and return the first product
+ variant for this template.
+
+ :param log_warning: whether a warning should be logged on fail
+ :type log_warning: bool
+
+ :return: the first product variant or none
+ :rtype: recordset of `product.product`
+ """
+ return self._create_product_variant(self._get_first_possible_combination(), log_warning)
+
+ def _get_image_holder(self):
+ """Returns the holder of the image to use as default representation.
+ If the product template has an image it is the product template,
+ otherwise if the product has variants it is the first variant
+
+ :return: this product template or the first product variant
+ :rtype: recordset of 'product.template' or recordset of 'product.product'
+ """
+ self.ensure_one()
+ if self.image_1920:
+ return self
+ variant = self.env['product.product'].browse(self._get_first_possible_variant_id())
+ # if the variant has no image anyway, spare some queries by using template
+ return variant if variant.image_variant_1920 else self
+
+ def _get_current_company_fallback(self, **kwargs):
+ """Override: if a website is set on the product or given, fallback to
+ the company of the website. Otherwise use the one from parent method."""
+ res = super(ProductTemplate, self)._get_current_company_fallback(**kwargs)
+ website = self.website_id or kwargs.get('website')
+ return website and website.company_id or res
+
+ def _default_website_sequence(self):
+ ''' We want new product to be the last (highest seq).
+ Every product should ideally have an unique sequence.
+ Default sequence (10000) should only be used for DB first product.
+ As we don't resequence the whole tree (as `sequence` does), this field
+ might have negative value.
+ '''
+ self._cr.execute("SELECT MAX(website_sequence) FROM %s" % self._table)
+ max_sequence = self._cr.fetchone()[0]
+ if max_sequence is None:
+ return 10000
+ return max_sequence + 5
+
+ def set_sequence_top(self):
+ min_sequence = self.sudo().search([], order='website_sequence ASC', limit=1)
+ self.website_sequence = min_sequence.website_sequence - 5
+
+ def set_sequence_bottom(self):
+ max_sequence = self.sudo().search([], order='website_sequence DESC', limit=1)
+ self.website_sequence = max_sequence.website_sequence + 5
+
+ def set_sequence_up(self):
+ previous_product_tmpl = self.sudo().search([
+ ('website_sequence', '<', self.website_sequence),
+ ('website_published', '=', self.website_published),
+ ], order='website_sequence DESC', limit=1)
+ if previous_product_tmpl:
+ previous_product_tmpl.website_sequence, self.website_sequence = self.website_sequence, previous_product_tmpl.website_sequence
+ else:
+ self.set_sequence_top()
+
+ def set_sequence_down(self):
+ next_prodcut_tmpl = self.search([
+ ('website_sequence', '>', self.website_sequence),
+ ('website_published', '=', self.website_published),
+ ], order='website_sequence ASC', limit=1)
+ if next_prodcut_tmpl:
+ next_prodcut_tmpl.website_sequence, self.website_sequence = self.website_sequence, next_prodcut_tmpl.website_sequence
+ else:
+ return self.set_sequence_bottom()
+
+ def _default_website_meta(self):
+ res = super(ProductTemplate, self)._default_website_meta()
+ res['default_opengraph']['og:description'] = res['default_twitter']['twitter:description'] = self.description_sale
+ res['default_opengraph']['og:title'] = res['default_twitter']['twitter:title'] = self.name
+ res['default_opengraph']['og:image'] = res['default_twitter']['twitter:image'] = self.env['website'].image_url(self, 'image_1024')
+ res['default_meta_description'] = self.description_sale
+ return res
+
+ def _compute_website_url(self):
+ super(ProductTemplate, self)._compute_website_url()
+ for product in self:
+ if product.id:
+ product.website_url = "/shop/%s" % slug(product)
+
+ # ---------------------------------------------------------
+ # Rating Mixin API
+ # ---------------------------------------------------------
+
+ def _rating_domain(self):
+ """ Only take the published rating into account to compute avg and count """
+ domain = super(ProductTemplate, self)._rating_domain()
+ return expression.AND([domain, [('is_internal', '=', False)]])
+
+ def _get_images(self):
+ """Return a list of records implementing `image.mixin` to
+ display on the carousel on the website for this template.
+
+ This returns a list and not a recordset because the records might be
+ from different models (template and image).
+
+ It contains in this order: the main image of the template and the
+ Template Extra Images.
+ """
+ self.ensure_one()
+ return [self] + list(self.product_template_image_ids)
+
+
+class Product(models.Model):
+ _inherit = "product.product"
+
+ website_id = fields.Many2one(related='product_tmpl_id.website_id', readonly=False)
+
+ product_variant_image_ids = fields.One2many('product.image', 'product_variant_id', string="Extra Variant Images")
+
+ website_url = fields.Char('Website URL', compute='_compute_product_website_url', help='The full URL to access the document through the website.')
+
+ @api.depends_context('lang')
+ @api.depends('product_tmpl_id.website_url', 'product_template_attribute_value_ids')
+ def _compute_product_website_url(self):
+ for product in self:
+ attributes = ','.join(str(x) for x in product.product_template_attribute_value_ids.ids)
+ product.website_url = "%s#attr=%s" % (product.product_tmpl_id.website_url, attributes)
+
+ def website_publish_button(self):
+ self.ensure_one()
+ return self.product_tmpl_id.website_publish_button()
+
+ def open_website_url(self):
+ self.ensure_one()
+ res = self.product_tmpl_id.open_website_url()
+ res['url'] = self.website_url
+ return res
+
+ def _get_images(self):
+ """Return a list of records implementing `image.mixin` to
+ display on the carousel on the website for this variant.
+
+ This returns a list and not a recordset because the records might be
+ from different models (template, variant and image).
+
+ It contains in this order: the main image of the variant (if set), the
+ Variant Extra Images, and the Template Extra Images.
+ """
+ self.ensure_one()
+ variant_images = list(self.product_variant_image_ids)
+ if self.image_variant_1920:
+ # if the main variant image is set, display it first
+ variant_images = [self] + variant_images
+ else:
+ # If the main variant image is empty, it will fallback to template
+ # image, in this case insert it after the other variant images, so
+ # that all variant images are first and all template images last.
+ variant_images = variant_images + [self]
+ # [1:] to remove the main image from the template, we only display
+ # the template extra images here
+ return variant_images + self.product_tmpl_id._get_images()[1:]
diff --git a/addons/website_sale/models/product_attribute.py b/addons/website_sale/models/product_attribute.py
new file mode 100644
index 00000000..67bbe3a8
--- /dev/null
+++ b/addons/website_sale/models/product_attribute.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from collections import OrderedDict
+
+from odoo import models
+
+
+class ProductTemplateAttributeLine(models.Model):
+ _inherit = 'product.template.attribute.line'
+
+ def _prepare_single_value_for_display(self):
+ """On the product page group together the attribute lines that concern
+ the same attribute and that have only one value each.
+
+ Indeed those are considered informative values, they do not generate
+ choice for the user, so they are displayed below the configurator.
+
+ The returned attributes are ordered as they appear in `self`, so based
+ on the order of the attribute lines.
+ """
+ single_value_lines = self.filtered(lambda ptal: len(ptal.value_ids) == 1)
+ single_value_attributes = OrderedDict([(pa, self.env['product.template.attribute.line']) for pa in single_value_lines.attribute_id])
+ for ptal in single_value_lines:
+ single_value_attributes[ptal.attribute_id] |= ptal
+ return single_value_attributes
diff --git a/addons/website_sale/models/product_image.py b/addons/website_sale/models/product_image.py
new file mode 100644
index 00000000..eea9afb6
--- /dev/null
+++ b/addons/website_sale/models/product_image.py
@@ -0,0 +1,63 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, tools, _
+from odoo.exceptions import ValidationError
+
+from odoo.addons.website.tools import get_video_embed_code
+
+
+class ProductImage(models.Model):
+ _name = 'product.image'
+ _description = "Product Image"
+ _inherit = ['image.mixin']
+ _order = 'sequence, id'
+
+ name = fields.Char("Name", required=True)
+ sequence = fields.Integer(default=10, index=True)
+
+ image_1920 = fields.Image(required=True)
+
+ product_tmpl_id = fields.Many2one('product.template', "Product Template", index=True, ondelete='cascade')
+ product_variant_id = fields.Many2one('product.product', "Product Variant", index=True, ondelete='cascade')
+ video_url = fields.Char('Video URL',
+ help='URL of a video for showcasing your product.')
+ embed_code = fields.Char(compute="_compute_embed_code")
+
+ can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
+
+ @api.depends('image_1920', 'image_1024')
+ def _compute_can_image_1024_be_zoomed(self):
+ for image in self:
+ image.can_image_1024_be_zoomed = image.image_1920 and tools.is_image_size_above(image.image_1920, image.image_1024)
+
+ @api.depends('video_url')
+ def _compute_embed_code(self):
+ for image in self:
+ image.embed_code = get_video_embed_code(image.video_url)
+
+ @api.constrains('video_url')
+ def _check_valid_video_url(self):
+ for image in self:
+ if image.video_url and not image.embed_code:
+ raise ValidationError(_("Provided video URL for '%s' is not valid. Please enter a valid video URL.", image.name))
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ """
+ We don't want the default_product_tmpl_id from the context
+ to be applied if we have a product_variant_id set to avoid
+ having the variant images to show also as template images.
+ But we want it if we don't have a product_variant_id set.
+ """
+ context_without_template = self.with_context({k: v for k, v in self.env.context.items() if k != 'default_product_tmpl_id'})
+ normal_vals = []
+ variant_vals_list = []
+
+ for vals in vals_list:
+ if vals.get('product_variant_id') and 'default_product_tmpl_id' in self.env.context:
+ variant_vals_list.append(vals)
+ else:
+ normal_vals.append(vals)
+
+ return super().create(normal_vals) + super(ProductImage, context_without_template).create(variant_vals_list)
diff --git a/addons/website_sale/models/res_company.py b/addons/website_sale/models/res_company.py
new file mode 100644
index 00000000..9326af67
--- /dev/null
+++ b/addons/website_sale/models/res_company.py
@@ -0,0 +1,17 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ResCompany(models.Model):
+ _inherit = 'res.company'
+
+ website_sale_onboarding_payment_acquirer_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the website sale onboarding payment acquirer step", default='not_done')
+
+ @api.model
+ def action_open_website_sale_onboarding_payment_acquirer(self):
+ """ Called by onboarding panel above the quotation list."""
+ self.env.company.get_chart_of_accounts_or_fail()
+ action = self.env["ir.actions.actions"]._for_xml_id("website_sale.action_open_website_sale_onboarding_payment_acquirer_wizard")
+ return action
diff --git a/addons/website_sale/models/res_config_settings.py b/addons/website_sale/models/res_config_settings.py
new file mode 100644
index 00000000..adfe5b0f
--- /dev/null
+++ b/addons/website_sale/models/res_config_settings.py
@@ -0,0 +1,74 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from ast import literal_eval
+
+from odoo import api, models, fields
+
+
+class ResConfigSettings(models.TransientModel):
+ _inherit = 'res.config.settings'
+
+ salesperson_id = fields.Many2one('res.users', related='website_id.salesperson_id', string='Salesperson', readonly=False)
+ salesteam_id = fields.Many2one('crm.team', related='website_id.salesteam_id', string='Sales Team', readonly=False)
+ module_website_sale_delivery = fields.Boolean("eCommerce Shipping Costs")
+ # field used to have a nice radio in form view, resuming the 2 fields above
+ sale_delivery_settings = fields.Selection([
+ ('none', 'No shipping management on website'),
+ ('internal', "Delivery methods are only used internally: the customer doesn't pay for shipping costs"),
+ ('website', "Delivery methods are selectable on the website: the customer pays for shipping costs"),
+ ], string="Shipping Management")
+
+ group_delivery_invoice_address = fields.Boolean(string="Shipping Address", implied_group='sale.group_delivery_invoice_address', group='base.group_portal,base.group_user,base.group_public')
+
+ module_website_sale_digital = fields.Boolean("Digital Content")
+ module_website_sale_wishlist = fields.Boolean("Wishlists")
+ module_website_sale_comparison = fields.Boolean("Product Comparison Tool")
+ module_website_sale_stock = fields.Boolean("Inventory", help='Installs the "Website Delivery Information" application')
+
+ module_account = fields.Boolean("Invoicing")
+
+ cart_recovery_mail_template = fields.Many2one('mail.template', string='Cart Recovery Email', domain="[('model', '=', 'sale.order')]",
+ related='website_id.cart_recovery_mail_template_id', readonly=False)
+ cart_abandoned_delay = fields.Float("Abandoned Delay", help="Number of hours after which the cart is considered abandoned.",
+ related='website_id.cart_abandoned_delay', readonly=False)
+
+ @api.model
+ def get_values(self):
+ res = super(ResConfigSettings, self).get_values()
+
+ sale_delivery_settings = 'none'
+ if self.env['ir.module.module'].search([('name', '=', 'delivery')], limit=1).state in ('installed', 'to install', 'to upgrade'):
+ sale_delivery_settings = 'internal'
+ if self.env['ir.module.module'].search([('name', '=', 'website_sale_delivery')], limit=1).state in ('installed', 'to install', 'to upgrade'):
+ sale_delivery_settings = 'website'
+
+ res.update(
+ sale_delivery_settings=sale_delivery_settings,
+ )
+ return res
+
+ @api.onchange('sale_delivery_settings')
+ def _onchange_sale_delivery_settings(self):
+ if self.sale_delivery_settings == 'none':
+ self.update({
+ 'module_delivery': False,
+ 'module_website_sale_delivery': False,
+ })
+ elif self.sale_delivery_settings == 'internal':
+ self.update({
+ 'module_delivery': True,
+ 'module_website_sale_delivery': False,
+ })
+ else:
+ self.update({
+ 'module_delivery': True,
+ 'module_website_sale_delivery': True,
+ })
+
+ @api.onchange('group_discount_per_so_line')
+ def _onchange_group_discount_per_so_line(self):
+ if self.group_discount_per_so_line:
+ self.update({
+ 'group_product_pricelist': True,
+ })
diff --git a/addons/website_sale/models/res_country.py b/addons/website_sale/models/res_country.py
new file mode 100644
index 00000000..d22846fa
--- /dev/null
+++ b/addons/website_sale/models/res_country.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class ResCountry(models.Model):
+ _inherit = 'res.country'
+
+ def get_website_sale_countries(self, mode='billing'):
+ return self.sudo().search([])
+
+ def get_website_sale_states(self, mode='billing'):
+ return self.sudo().state_ids
diff --git a/addons/website_sale/models/res_partner.py b/addons/website_sale/models/res_partner.py
new file mode 100644
index 00000000..5ef92cf6
--- /dev/null
+++ b/addons/website_sale/models/res_partner.py
@@ -0,0 +1,25 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+from odoo.addons.website.models import ir_http
+
+
+class ResPartner(models.Model):
+ _inherit = 'res.partner'
+
+ last_website_so_id = fields.Many2one('sale.order', compute='_compute_last_website_so_id', string='Last Online Sales Order')
+
+ def _compute_last_website_so_id(self):
+ SaleOrder = self.env['sale.order']
+ for partner in self:
+ is_public = any(u._is_public() for u in partner.with_context(active_test=False).user_ids)
+ website = ir_http.get_request_website()
+ if website and not is_public:
+ partner.last_website_so_id = SaleOrder.search([
+ ('partner_id', '=', partner.id),
+ ('website_id', '=', website.id),
+ ('state', '=', 'draft'),
+ ], order='write_date desc', limit=1)
+ else:
+ partner.last_website_so_id = SaleOrder # Not in a website context or public User
diff --git a/addons/website_sale/models/sale_order.py b/addons/website_sale/models/sale_order.py
new file mode 100644
index 00000000..d270c517
--- /dev/null
+++ b/addons/website_sale/models/sale_order.py
@@ -0,0 +1,385 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+import logging
+import random
+from datetime import datetime
+from dateutil.relativedelta import relativedelta
+
+from odoo import api, models, fields, _
+from odoo.http import request
+from odoo.osv import expression
+from odoo.exceptions import UserError, ValidationError
+
+_logger = logging.getLogger(__name__)
+
+
+class SaleOrder(models.Model):
+ _inherit = "sale.order"
+
+ website_order_line = fields.One2many(
+ 'sale.order.line',
+ compute='_compute_website_order_line',
+ string='Order Lines displayed on Website',
+ help='Order Lines to be displayed on the website. They should not be used for computation purpose.',
+ )
+ cart_quantity = fields.Integer(compute='_compute_cart_info', string='Cart Quantity')
+ only_services = fields.Boolean(compute='_compute_cart_info', string='Only Services')
+ is_abandoned_cart = fields.Boolean('Abandoned Cart', compute='_compute_abandoned_cart', search='_search_abandoned_cart')
+ cart_recovery_email_sent = fields.Boolean('Cart recovery email already sent')
+ website_id = fields.Many2one('website', string='Website', readonly=True,
+ help='Website through which this order was placed.')
+
+ @api.depends('order_line')
+ def _compute_website_order_line(self):
+ for order in self:
+ order.website_order_line = order.order_line
+
+ @api.depends('order_line.product_uom_qty', 'order_line.product_id')
+ def _compute_cart_info(self):
+ for order in self:
+ order.cart_quantity = int(sum(order.mapped('website_order_line.product_uom_qty')))
+ order.only_services = all(l.product_id.type in ('service', 'digital') for l in order.website_order_line)
+
+ @api.depends('website_id', 'date_order', 'order_line', 'state', 'partner_id')
+ def _compute_abandoned_cart(self):
+ for order in self:
+ # a quotation can be considered as an abandonned cart if it is linked to a website,
+ # is in the 'draft' state and has an expiration date
+ if order.website_id and order.state == 'draft' and order.date_order:
+ public_partner_id = order.website_id.user_id.partner_id
+ # by default the expiration date is 1 hour if not specified on the website configuration
+ abandoned_delay = order.website_id.cart_abandoned_delay or 1.0
+ abandoned_datetime = datetime.utcnow() - relativedelta(hours=abandoned_delay)
+ order.is_abandoned_cart = bool(order.date_order <= abandoned_datetime and order.partner_id != public_partner_id and order.order_line)
+ else:
+ order.is_abandoned_cart = False
+
+ def _search_abandoned_cart(self, operator, value):
+ abandoned_delay = self.website_id and self.website_id.cart_abandoned_delay or 1.0
+ abandoned_datetime = fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=abandoned_delay))
+ abandoned_domain = expression.normalize_domain([
+ ('date_order', '<=', abandoned_datetime),
+ ('website_id', '!=', False),
+ ('state', '=', 'draft'),
+ ('partner_id', '!=', self.env.ref('base.public_partner').id),
+ ('order_line', '!=', False)
+ ])
+ # is_abandoned domain possibilities
+ if (operator not in expression.NEGATIVE_TERM_OPERATORS and value) or (operator in expression.NEGATIVE_TERM_OPERATORS and not value):
+ return abandoned_domain
+ return expression.distribute_not(['!'] + abandoned_domain) # negative domain
+
+ def _cart_find_product_line(self, product_id=None, line_id=None, **kwargs):
+ """Find the cart line matching the given parameters.
+
+ If a product_id is given, the line will match the product only if the
+ line also has the same special attributes: `no_variant` attributes and
+ `is_custom` values.
+ """
+ self.ensure_one()
+ product = self.env['product.product'].browse(product_id)
+
+ # split lines with the same product if it has untracked attributes
+ if product and (product.product_tmpl_id.has_dynamic_attributes() or product.product_tmpl_id._has_no_variant_attributes()) and not line_id:
+ return self.env['sale.order.line']
+
+ domain = [('order_id', '=', self.id), ('product_id', '=', product_id)]
+ if line_id:
+ domain += [('id', '=', line_id)]
+ else:
+ domain += [('product_custom_attribute_value_ids', '=', False)]
+
+ return self.env['sale.order.line'].sudo().search(domain)
+
+ def _website_product_id_change(self, order_id, product_id, qty=0):
+ order = self.sudo().browse(order_id)
+ product_context = dict(self.env.context)
+ product_context.setdefault('lang', order.partner_id.lang)
+ product_context.update({
+ 'partner': order.partner_id,
+ 'quantity': qty,
+ 'date': order.date_order,
+ 'pricelist': order.pricelist_id.id,
+ })
+ product = self.env['product.product'].with_context(product_context).with_company(order.company_id.id).browse(product_id)
+ discount = 0
+
+ if order.pricelist_id.discount_policy == 'without_discount':
+ # This part is pretty much a copy-paste of the method '_onchange_discount' of
+ # 'sale.order.line'.
+ price, rule_id = order.pricelist_id.with_context(product_context).get_product_price_rule(product, qty or 1.0, order.partner_id)
+ pu, currency = request.env['sale.order.line'].with_context(product_context)._get_real_price_currency(product, rule_id, qty, product.uom_id, order.pricelist_id.id)
+ if pu != 0:
+ if order.pricelist_id.currency_id != currency:
+ # we need new_list_price in the same currency as price, which is in the SO's pricelist's currency
+ date = order.date_order or fields.Date.today()
+ pu = currency._convert(pu, order.pricelist_id.currency_id, order.company_id, date)
+ discount = (pu - price) / pu * 100
+ if discount < 0:
+ # In case the discount is negative, we don't want to show it to the customer,
+ # but we still want to use the price defined on the pricelist
+ discount = 0
+ pu = price
+ else:
+ pu = product.price
+ if order.pricelist_id and order.partner_id:
+ order_line = order._cart_find_product_line(product.id)
+ if order_line:
+ pu = self.env['account.tax']._fix_tax_included_price_company(pu, product.taxes_id, order_line[0].tax_id, self.company_id)
+
+ return {
+ 'product_id': product_id,
+ 'product_uom_qty': qty,
+ 'order_id': order_id,
+ 'product_uom': product.uom_id.id,
+ 'price_unit': pu,
+ 'discount': discount,
+ }
+
+ def _cart_update(self, product_id=None, line_id=None, add_qty=0, set_qty=0, **kwargs):
+ """ Add or set product quantity, add_qty can be negative """
+ self.ensure_one()
+ product_context = dict(self.env.context)
+ product_context.setdefault('lang', self.sudo().partner_id.lang)
+ SaleOrderLineSudo = self.env['sale.order.line'].sudo().with_context(product_context)
+ # change lang to get correct name of attributes/values
+ product_with_context = self.env['product.product'].with_context(product_context)
+ product = product_with_context.browse(int(product_id))
+
+ try:
+ if add_qty:
+ add_qty = int(add_qty)
+ except ValueError:
+ add_qty = 1
+ try:
+ if set_qty:
+ set_qty = int(set_qty)
+ except ValueError:
+ set_qty = 0
+ quantity = 0
+ order_line = False
+ if self.state != 'draft':
+ request.session['sale_order_id'] = None
+ raise UserError(_('It is forbidden to modify a sales order which is not in draft status.'))
+ if line_id is not False:
+ order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1]
+
+ # Create line if no line with product_id can be located
+ if not order_line:
+ if not product:
+ raise UserError(_("The given product does not exist therefore it cannot be added to cart."))
+
+ no_variant_attribute_values = kwargs.get('no_variant_attribute_values') or []
+ received_no_variant_values = product.env['product.template.attribute.value'].browse([int(ptav['value']) for ptav in no_variant_attribute_values])
+ received_combination = product.product_template_attribute_value_ids | received_no_variant_values
+ product_template = product.product_tmpl_id
+
+ # handle all cases where incorrect or incomplete data are received
+ combination = product_template._get_closest_possible_combination(received_combination)
+
+ # get or create (if dynamic) the correct variant
+ product = product_template._create_product_variant(combination)
+
+ if not product:
+ raise UserError(_("The given combination does not exist therefore it cannot be added to cart."))
+
+ product_id = product.id
+
+ values = self._website_product_id_change(self.id, product_id, qty=1)
+
+ # add no_variant attributes that were not received
+ for ptav in combination.filtered(lambda ptav: ptav.attribute_id.create_variant == 'no_variant' and ptav not in received_no_variant_values):
+ no_variant_attribute_values.append({
+ 'value': ptav.id,
+ })
+
+ # save no_variant attributes values
+ if no_variant_attribute_values:
+ values['product_no_variant_attribute_value_ids'] = [
+ (6, 0, [int(attribute['value']) for attribute in no_variant_attribute_values])
+ ]
+
+ # add is_custom attribute values that were not received
+ custom_values = kwargs.get('product_custom_attribute_values') or []
+ received_custom_values = product.env['product.template.attribute.value'].browse([int(ptav['custom_product_template_attribute_value_id']) for ptav in custom_values])
+
+ for ptav in combination.filtered(lambda ptav: ptav.is_custom and ptav not in received_custom_values):
+ custom_values.append({
+ 'custom_product_template_attribute_value_id': ptav.id,
+ 'custom_value': '',
+ })
+
+ # save is_custom attributes values
+ if custom_values:
+ values['product_custom_attribute_value_ids'] = [(0, 0, {
+ 'custom_product_template_attribute_value_id': custom_value['custom_product_template_attribute_value_id'],
+ 'custom_value': custom_value['custom_value']
+ }) for custom_value in custom_values]
+
+ # create the line
+ order_line = SaleOrderLineSudo.create(values)
+
+ try:
+ order_line._compute_tax_id()
+ except ValidationError as e:
+ # The validation may occur in backend (eg: taxcloud) but should fail silently in frontend
+ _logger.debug("ValidationError occurs during tax compute. %s" % (e))
+ if add_qty:
+ add_qty -= 1
+
+ # compute new quantity
+ if set_qty:
+ quantity = set_qty
+ elif add_qty is not None:
+ quantity = order_line.product_uom_qty + (add_qty or 0)
+
+ # Remove zero of negative lines
+ if quantity <= 0:
+ linked_line = order_line.linked_line_id
+ order_line.unlink()
+ if linked_line:
+ # update description of the parent
+ linked_product = product_with_context.browse(linked_line.product_id.id)
+ linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product)
+ else:
+ # update line
+ no_variant_attributes_price_extra = [ptav.price_extra for ptav in order_line.product_no_variant_attribute_value_ids]
+ values = self.with_context(no_variant_attributes_price_extra=tuple(no_variant_attributes_price_extra))._website_product_id_change(self.id, product_id, qty=quantity)
+ order = self.sudo().browse(self.id)
+ if self.pricelist_id.discount_policy == 'with_discount' and not self.env.context.get('fixed_price'):
+ product_context.update({
+ 'partner': order.partner_id,
+ 'quantity': quantity,
+ 'date': order.date_order,
+ 'pricelist': order.pricelist_id.id,
+ })
+ product_with_context = self.env['product.product'].with_context(product_context).with_company(order.company_id.id)
+ product = product_with_context.browse(product_id)
+ values['price_unit'] = self.env['account.tax']._fix_tax_included_price_company(
+ order_line._get_display_price(product),
+ order_line.product_id.taxes_id,
+ order_line.tax_id,
+ self.company_id
+ )
+
+ order_line.write(values)
+
+ # link a product to the sales order
+ if kwargs.get('linked_line_id'):
+ linked_line = SaleOrderLineSudo.browse(kwargs['linked_line_id'])
+ order_line.write({
+ 'linked_line_id': linked_line.id,
+ })
+ linked_product = product_with_context.browse(linked_line.product_id.id)
+ linked_line.name = linked_line.get_sale_order_line_multiline_description_sale(linked_product)
+ # Generate the description with everything. This is done after
+ # creating because the following related fields have to be set:
+ # - product_no_variant_attribute_value_ids
+ # - product_custom_attribute_value_ids
+ # - linked_line_id
+ order_line.name = order_line.get_sale_order_line_multiline_description_sale(product)
+
+ option_lines = self.order_line.filtered(lambda l: l.linked_line_id.id == order_line.id)
+
+ return {'line_id': order_line.id, 'quantity': quantity, 'option_ids': list(set(option_lines.ids))}
+
+ def _cart_accessories(self):
+ """ Suggest accessories based on 'Accessory Products' of products in cart """
+ for order in self:
+ products = order.website_order_line.mapped('product_id')
+ accessory_products = self.env['product.product']
+ for line in order.website_order_line.filtered(lambda l: l.product_id):
+ combination = line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids
+ accessory_products |= line.product_id.accessory_product_ids.filtered(lambda product:
+ product.website_published and
+ product not in products and
+ product._is_variant_possible(parent_combination=combination) and
+ (product.company_id == line.company_id or not product.company_id)
+ )
+
+ return random.sample(accessory_products, len(accessory_products))
+
+ def action_recovery_email_send(self):
+ for order in self:
+ order._portal_ensure_token()
+ composer_form_view_id = self.env.ref('mail.email_compose_message_wizard_form').id
+
+ template_id = self._get_cart_recovery_template().id
+
+ return {
+ 'type': 'ir.actions.act_window',
+ 'view_mode': 'form',
+ 'res_model': 'mail.compose.message',
+ 'view_id': composer_form_view_id,
+ 'target': 'new',
+ 'context': {
+ 'default_composition_mode': 'mass_mail' if len(self.ids) > 1 else 'comment',
+ 'default_res_id': self.ids[0],
+ 'default_model': 'sale.order',
+ 'default_use_template': bool(template_id),
+ 'default_template_id': template_id,
+ 'website_sale_send_recovery_email': True,
+ 'active_ids': self.ids,
+ },
+ }
+
+ def _get_cart_recovery_template(self):
+ """
+ Return the cart recovery template record for a set of orders.
+ If they all belong to the same website, we return the website-specific template;
+ otherwise we return the default template.
+ If the default is not found, the empty ['mail.template'] is returned.
+ """
+ websites = self.mapped('website_id')
+ template = websites.cart_recovery_mail_template_id if len(websites) == 1 else False
+ template = template or self.env.ref('website_sale.mail_template_sale_cart_recovery', raise_if_not_found=False)
+ return template or self.env['mail.template']
+
+ def _cart_recovery_email_send(self):
+ """Send the cart recovery email on the current recordset,
+ making sure that the portal token exists to avoid broken links, and marking the email as sent.
+ Similar method to action_recovery_email_send, made to be called in automated actions.
+ Contrary to the former, it will use the website-specific template for each order."""
+ sent_orders = self.env['sale.order']
+ for order in self:
+ template = order._get_cart_recovery_template()
+ if template:
+ order._portal_ensure_token()
+ template.send_mail(order.id)
+ sent_orders |= order
+ sent_orders.write({'cart_recovery_email_sent': True})
+
+ def action_confirm(self):
+ res = super(SaleOrder, self).action_confirm()
+ for order in self:
+ if not order.transaction_ids and not order.amount_total and self._context.get('send_email'):
+ order._send_order_confirmation_mail()
+ return res
+
+
+class SaleOrderLine(models.Model):
+ _inherit = "sale.order.line"
+
+ name_short = fields.Char(compute="_compute_name_short")
+
+ linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '!=', order_id)]", ondelete='cascade')
+ option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked')
+
+ def get_sale_order_line_multiline_description_sale(self, product):
+ description = super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
+ if self.linked_line_id:
+ description += "\n" + _("Option for: %s", self.linked_line_id.product_id.display_name)
+ if self.option_line_ids:
+ description += "\n" + '\n'.join([_("Option: %s", option_line.product_id.display_name) for option_line in self.option_line_ids])
+ return description
+
+ @api.depends('product_id.display_name')
+ def _compute_name_short(self):
+ """ Compute a short name for this sale order line, to be used on the website where we don't have much space.
+ To keep it short, instead of using the first line of the description, we take the product name without the internal reference.
+ """
+ for record in self:
+ record.name_short = record.product_id.with_context(display_default_code=False).display_name
+
+ def get_description_following_lines(self):
+ return self.name.splitlines()[1:]
diff --git a/addons/website_sale/models/website.py b/addons/website_sale/models/website.py
new file mode 100644
index 00000000..007325f9
--- /dev/null
+++ b/addons/website_sale/models/website.py
@@ -0,0 +1,415 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import api, fields, models, tools, SUPERUSER_ID, _
+
+from odoo.http import request
+from odoo.addons.website.models import ir_http
+from odoo.addons.http_routing.models.ir_http import url_for
+
+_logger = logging.getLogger(__name__)
+
+
+class Website(models.Model):
+ _inherit = 'website'
+
+ pricelist_id = fields.Many2one('product.pricelist', compute='_compute_pricelist_id', string='Default Pricelist')
+ currency_id = fields.Many2one('res.currency',
+ related='pricelist_id.currency_id', depends=(), related_sudo=False,
+ string='Default Currency', readonly=False)
+ salesperson_id = fields.Many2one('res.users', string='Salesperson')
+
+ def _get_default_website_team(self):
+ try:
+ team = self.env.ref('sales_team.salesteam_website_sales')
+ return team if team.active else None
+ except ValueError:
+ return None
+
+ salesteam_id = fields.Many2one('crm.team',
+ string='Sales Team',
+ default=_get_default_website_team)
+ pricelist_ids = fields.One2many('product.pricelist', compute="_compute_pricelist_ids",
+ string='Price list available for this Ecommerce/Website')
+ all_pricelist_ids = fields.One2many('product.pricelist', 'website_id', string='All pricelists',
+ help='Technical: Used to recompute pricelist_ids')
+
+ def _default_recovery_mail_template(self):
+ try:
+ return self.env.ref('website_sale.mail_template_sale_cart_recovery').id
+ except ValueError:
+ return False
+
+ cart_recovery_mail_template_id = fields.Many2one('mail.template', string='Cart Recovery Email', default=_default_recovery_mail_template, domain="[('model', '=', 'sale.order')]")
+ cart_abandoned_delay = fields.Float("Abandoned Delay", default=1.0)
+
+ shop_ppg = fields.Integer(default=20, string="Number of products in the grid on the shop")
+ shop_ppr = fields.Integer(default=4, string="Number of grid columns on the shop")
+
+ shop_extra_field_ids = fields.One2many('website.sale.extra.field', 'website_id', string='E-Commerce Extra Fields')
+
+ @api.depends('all_pricelist_ids')
+ def _compute_pricelist_ids(self):
+ Pricelist = self.env['product.pricelist']
+ for website in self:
+ website.pricelist_ids = Pricelist.search(
+ Pricelist._get_website_pricelists_domain(website.id)
+ )
+
+ def _compute_pricelist_id(self):
+ for website in self:
+ website.pricelist_id = website.with_context(website_id=website.id).get_current_pricelist()
+
+ # This method is cached, must not return records! See also #8795
+ @tools.ormcache('self.env.uid', 'country_code', 'show_visible', 'website_pl', 'current_pl', 'all_pl', 'partner_pl', 'order_pl')
+ def _get_pl_partner_order(self, country_code, show_visible, website_pl, current_pl, all_pl, partner_pl=False, order_pl=False):
+ """ Return the list of pricelists that can be used on website for the current user.
+ :param str country_code: code iso or False, If set, we search only price list available for this country
+ :param bool show_visible: if True, we don't display pricelist where selectable is False (Eg: Code promo)
+ :param int website_pl: The default pricelist used on this website
+ :param int current_pl: The current pricelist used on the website
+ (If not selectable but the current pricelist we had this pricelist anyway)
+ :param list all_pl: List of all pricelist available for this website
+ :param int partner_pl: the partner pricelist
+ :param int order_pl: the current cart pricelist
+ :returns: list of pricelist ids
+ """
+ def _check_show_visible(pl):
+ """ If `show_visible` is True, we will only show the pricelist if
+ one of this condition is met:
+ - The pricelist is `selectable`.
+ - The pricelist is either the currently used pricelist or the
+ current cart pricelist, we should consider it as available even if
+ it might not be website compliant (eg: it is not selectable anymore,
+ it is a backend pricelist, it is not active anymore..).
+ """
+ return (not show_visible or pl.selectable or pl.id in (current_pl, order_pl))
+
+ # Note: 1. pricelists from all_pl are already website compliant (went through
+ # `_get_website_pricelists_domain`)
+ # 2. do not read `property_product_pricelist` here as `_get_pl_partner_order`
+ # is cached and the result of this method will be impacted by that field value.
+ # Pass it through `partner_pl` parameter instead to invalidate the cache.
+
+ # If there is a GeoIP country, find a pricelist for it
+ self.ensure_one()
+ pricelists = self.env['product.pricelist']
+ if country_code:
+ for cgroup in self.env['res.country.group'].search([('country_ids.code', '=', country_code)]):
+ pricelists |= cgroup.pricelist_ids.filtered(
+ lambda pl: pl._is_available_on_website(self.id) and _check_show_visible(pl)
+ )
+
+ # no GeoIP or no pricelist for this country
+ if not country_code or not pricelists:
+ pricelists |= all_pl.filtered(lambda pl: _check_show_visible(pl))
+
+ # if logged in, add partner pl (which is `property_product_pricelist`, might not be website compliant)
+ is_public = self.user_id.id == self.env.user.id
+ if not is_public:
+ # keep partner_pl only if website compliant
+ partner_pl = pricelists.browse(partner_pl).filtered(lambda pl: pl._is_available_on_website(self.id) and _check_show_visible(pl))
+ if country_code:
+ # keep partner_pl only if GeoIP compliant in case of GeoIP enabled
+ partner_pl = partner_pl.filtered(
+ lambda pl: pl.country_group_ids and country_code in pl.country_group_ids.mapped('country_ids.code') or not pl.country_group_ids
+ )
+ pricelists |= partner_pl
+
+ # This method is cached, must not return records! See also #8795
+ return pricelists.ids
+
+ def _get_pricelist_available(self, req, show_visible=False):
+ """ Return the list of pricelists that can be used on website for the current user.
+ Country restrictions will be detected with GeoIP (if installed).
+ :param bool show_visible: if True, we don't display pricelist where selectable is False (Eg: Code promo)
+ :returns: pricelist recordset
+ """
+ website = ir_http.get_request_website()
+ if not website:
+ if self.env.context.get('website_id'):
+ website = self.browse(self.env.context['website_id'])
+ else:
+ # In the weird case we are coming from the backend (https://github.com/odoo/odoo/issues/20245)
+ website = len(self) == 1 and self or self.search([], limit=1)
+ isocountry = req and req.session.geoip and req.session.geoip.get('country_code') or False
+ partner = self.env.user.partner_id
+ last_order_pl = partner.last_website_so_id.pricelist_id
+ partner_pl = partner.property_product_pricelist
+ pricelists = website._get_pl_partner_order(isocountry, show_visible,
+ website.user_id.sudo().partner_id.property_product_pricelist.id,
+ req and req.session.get('website_sale_current_pl') or None,
+ website.pricelist_ids,
+ partner_pl=partner_pl and partner_pl.id or None,
+ order_pl=last_order_pl and last_order_pl.id or None)
+ return self.env['product.pricelist'].browse(pricelists)
+
+ def get_pricelist_available(self, show_visible=False):
+ return self._get_pricelist_available(request, show_visible)
+
+ def is_pricelist_available(self, pl_id):
+ """ Return a boolean to specify if a specific pricelist can be manually set on the website.
+ Warning: It check only if pricelist is in the 'selectable' pricelists or the current pricelist.
+ :param int pl_id: The pricelist id to check
+ :returns: Boolean, True if valid / available
+ """
+ return pl_id in self.get_pricelist_available(show_visible=False).ids
+
+ def get_current_pricelist(self):
+ """
+ :returns: The current pricelist record
+ """
+ # The list of available pricelists for this user.
+ # If the user is signed in, and has a pricelist set different than the public user pricelist
+ # then this pricelist will always be considered as available
+ available_pricelists = self.get_pricelist_available()
+ pl = None
+ partner = self.env.user.partner_id
+ if request and request.session.get('website_sale_current_pl'):
+ # `website_sale_current_pl` is set only if the user specifically chose it:
+ # - Either, he chose it from the pricelist selection
+ # - Either, he entered a coupon code
+ pl = self.env['product.pricelist'].browse(request.session['website_sale_current_pl'])
+ if pl not in available_pricelists:
+ pl = None
+ request.session.pop('website_sale_current_pl')
+ if not pl:
+ # If the user has a saved cart, it take the pricelist of this last unconfirmed cart
+ pl = partner.last_website_so_id.pricelist_id
+ if not pl:
+ # The pricelist of the user set on its partner form.
+ # If the user is not signed in, it's the public user pricelist
+ pl = partner.property_product_pricelist
+ if available_pricelists and pl not in available_pricelists:
+ # If there is at least one pricelist in the available pricelists
+ # and the chosen pricelist is not within them
+ # it then choose the first available pricelist.
+ # This can only happen when the pricelist is the public user pricelist and this pricelist is not in the available pricelist for this localization
+ # If the user is signed in, and has a special pricelist (different than the public user pricelist),
+ # then this special pricelist is amongs these available pricelists, and therefore it won't fall in this case.
+ pl = available_pricelists[0]
+
+ if not pl:
+ _logger.error('Fail to find pricelist for partner "%s" (id %s)', partner.name, partner.id)
+ return pl
+
+ def sale_product_domain(self):
+ return [("sale_ok", "=", True)] + self.get_current_website().website_domain()
+
+ @api.model
+ def sale_get_payment_term(self, partner):
+ pt = self.env.ref('account.account_payment_term_immediate', False).sudo()
+ if pt:
+ pt = (not pt.company_id.id or self.company_id.id == pt.company_id.id) and pt
+ return (
+ partner.property_payment_term_id or
+ pt or
+ self.env['account.payment.term'].sudo().search([('company_id', '=', self.company_id.id)], limit=1)
+ ).id
+
+ def _prepare_sale_order_values(self, partner, pricelist):
+ self.ensure_one()
+ affiliate_id = request.session.get('affiliate_id')
+ salesperson_id = affiliate_id if self.env['res.users'].sudo().browse(affiliate_id).exists() else request.website.salesperson_id.id
+ addr = partner.address_get(['delivery'])
+ if not request.website.is_public_user():
+ last_sale_order = self.env['sale.order'].sudo().search([('partner_id', '=', partner.id)], limit=1, order="date_order desc, id desc")
+ if last_sale_order and last_sale_order.partner_shipping_id.active: # first = me
+ addr['delivery'] = last_sale_order.partner_shipping_id.id
+ default_user_id = partner.parent_id.user_id.id or partner.user_id.id
+ values = {
+ 'partner_id': partner.id,
+ 'pricelist_id': pricelist.id,
+ 'payment_term_id': self.sale_get_payment_term(partner),
+ 'team_id': self.salesteam_id.id or partner.parent_id.team_id.id or partner.team_id.id,
+ 'partner_invoice_id': partner.id,
+ 'partner_shipping_id': addr['delivery'],
+ 'user_id': salesperson_id or self.salesperson_id.id or default_user_id,
+ 'website_id': self._context.get('website_id'),
+ }
+ company = self.company_id or pricelist.company_id
+ if company:
+ values['company_id'] = company.id
+ if self.env['ir.config_parameter'].sudo().get_param('sale.use_sale_note'):
+ values['note'] = company.sale_note or ""
+
+ return values
+
+ def sale_get_order(self, force_create=False, code=None, update_pricelist=False, force_pricelist=False):
+ """ Return the current sales order after mofications specified by params.
+ :param bool force_create: Create sales order if not already existing
+ :param str code: Code to force a pricelist (promo code)
+ If empty, it's a special case to reset the pricelist with the first available else the default.
+ :param bool update_pricelist: Force to recompute all the lines from sales order to adapt the price with the current pricelist.
+ :param int force_pricelist: pricelist_id - if set, we change the pricelist with this one
+ :returns: browse record for the current sales order
+ """
+ self.ensure_one()
+ partner = self.env.user.partner_id
+ sale_order_id = request.session.get('sale_order_id')
+ check_fpos = False
+ if not sale_order_id and not self.env.user._is_public():
+ last_order = partner.last_website_so_id
+ if last_order:
+ available_pricelists = self.get_pricelist_available()
+ # Do not reload the cart of this user last visit if the cart uses a pricelist no longer available.
+ sale_order_id = last_order.pricelist_id in available_pricelists and last_order.id
+ check_fpos = True
+
+ # Test validity of the sale_order_id
+ sale_order = self.env['sale.order'].with_company(request.website.company_id.id).sudo().browse(sale_order_id).exists() if sale_order_id else None
+
+ # Do not reload the cart of this user last visit if the Fiscal Position has changed.
+ if check_fpos and sale_order:
+ fpos_id = (
+ self.env['account.fiscal.position'].sudo()
+ .with_company(sale_order.company_id.id)
+ .get_fiscal_position(sale_order.partner_id.id, delivery_id=sale_order.partner_shipping_id.id)
+ ).id
+ if sale_order.fiscal_position_id.id != fpos_id:
+ sale_order = None
+
+ if not (sale_order or force_create or code):
+ if request.session.get('sale_order_id'):
+ request.session['sale_order_id'] = None
+ return self.env['sale.order']
+
+ if self.env['product.pricelist'].browse(force_pricelist).exists():
+ pricelist_id = force_pricelist
+ request.session['website_sale_current_pl'] = pricelist_id
+ update_pricelist = True
+ else:
+ pricelist_id = request.session.get('website_sale_current_pl') or self.get_current_pricelist().id
+
+ if not self._context.get('pricelist'):
+ self = self.with_context(pricelist=pricelist_id)
+
+ # cart creation was requested (either explicitly or to configure a promo code)
+ if not sale_order:
+ # TODO cache partner_id session
+ pricelist = self.env['product.pricelist'].browse(pricelist_id).sudo()
+ so_data = self._prepare_sale_order_values(partner, pricelist)
+ sale_order = self.env['sale.order'].with_company(request.website.company_id.id).with_user(SUPERUSER_ID).create(so_data)
+
+ # set fiscal position
+ if request.website.partner_id.id != partner.id:
+ sale_order.onchange_partner_shipping_id()
+ else: # For public user, fiscal position based on geolocation
+ country_code = request.session['geoip'].get('country_code')
+ if country_code:
+ country_id = request.env['res.country'].search([('code', '=', country_code)], limit=1).id
+ sale_order.fiscal_position_id = request.env['account.fiscal.position'].sudo().with_company(request.website.company_id.id)._get_fpos_by_region(country_id)
+ else:
+ # if no geolocation, use the public user fp
+ sale_order.onchange_partner_shipping_id()
+
+ request.session['sale_order_id'] = sale_order.id
+
+ # case when user emptied the cart
+ if not request.session.get('sale_order_id'):
+ request.session['sale_order_id'] = sale_order.id
+
+ # check for change of pricelist with a coupon
+ pricelist_id = pricelist_id or partner.property_product_pricelist.id
+
+ # check for change of partner_id ie after signup
+ if sale_order.partner_id.id != partner.id and request.website.partner_id.id != partner.id:
+ flag_pricelist = False
+ if pricelist_id != sale_order.pricelist_id.id:
+ flag_pricelist = True
+ fiscal_position = sale_order.fiscal_position_id.id
+
+ # change the partner, and trigger the onchange
+ sale_order.write({'partner_id': partner.id})
+ sale_order.with_context(not_self_saleperson=True).onchange_partner_id()
+ sale_order.write({'partner_invoice_id': partner.id})
+ sale_order.onchange_partner_shipping_id() # fiscal position
+ sale_order['payment_term_id'] = self.sale_get_payment_term(partner)
+
+ # check the pricelist : update it if the pricelist is not the 'forced' one
+ values = {}
+ if sale_order.pricelist_id:
+ if sale_order.pricelist_id.id != pricelist_id:
+ values['pricelist_id'] = pricelist_id
+ update_pricelist = True
+
+ # if fiscal position, update the order lines taxes
+ if sale_order.fiscal_position_id:
+ sale_order._compute_tax_id()
+
+ # if values, then make the SO update
+ if values:
+ sale_order.write(values)
+
+ # check if the fiscal position has changed with the partner_id update
+ recent_fiscal_position = sale_order.fiscal_position_id.id
+ # when buying a free product with public user and trying to log in, SO state is not draft
+ if (flag_pricelist or recent_fiscal_position != fiscal_position) and sale_order.state == 'draft':
+ update_pricelist = True
+
+ if code and code != sale_order.pricelist_id.code:
+ code_pricelist = self.env['product.pricelist'].sudo().search([('code', '=', code)], limit=1)
+ if code_pricelist:
+ pricelist_id = code_pricelist.id
+ update_pricelist = True
+ elif code is not None and sale_order.pricelist_id.code and code != sale_order.pricelist_id.code:
+ # code is not None when user removes code and click on "Apply"
+ pricelist_id = partner.property_product_pricelist.id
+ update_pricelist = True
+
+ # update the pricelist
+ if update_pricelist:
+ request.session['website_sale_current_pl'] = pricelist_id
+ values = {'pricelist_id': pricelist_id}
+ sale_order.write(values)
+ for line in sale_order.order_line:
+ if line.exists():
+ sale_order._cart_update(product_id=line.product_id.id, line_id=line.id, add_qty=0)
+
+ return sale_order
+
+ def sale_reset(self):
+ request.session.update({
+ 'sale_order_id': False,
+ 'website_sale_current_pl': False,
+ })
+
+ @api.model
+ def action_dashboard_redirect(self):
+ if self.env.user.has_group('sales_team.group_sale_salesman'):
+ return self.env["ir.actions.actions"]._for_xml_id("website.backend_dashboard")
+ return super(Website, self).action_dashboard_redirect()
+
+ def get_suggested_controllers(self):
+ suggested_controllers = super(Website, self).get_suggested_controllers()
+ suggested_controllers.append((_('eCommerce'), url_for('/shop'), 'website_sale'))
+ return suggested_controllers
+
+ def _bootstrap_snippet_filters(self):
+ super(Website, self)._bootstrap_snippet_filters()
+ action = self.env.ref('website_sale.dynamic_snippet_products_action', raise_if_not_found=False)
+ if action:
+ self.env['website.snippet.filter'].create({
+ 'action_server_id': action.id,
+ 'field_names': 'display_name,description_sale,image_512,list_price',
+ 'limit': 16,
+ 'name': _('Products'),
+ 'website_id': self.id,
+ })
+
+
+class WebsiteSaleExtraField(models.Model):
+ _name = 'website.sale.extra.field'
+ _description = 'E-Commerce Extra Info Shown on product page'
+ _order = 'sequence'
+
+ website_id = fields.Many2one('website')
+ sequence = fields.Integer(default=10)
+ field_id = fields.Many2one(
+ 'ir.model.fields',
+ domain=[('model_id.model', '=', 'product.template'), ('ttype', 'in', ['char', 'binary'])]
+ )
+ label = fields.Char(related='field_id.field_description')
+ name = fields.Char(related='field_id.name')
diff --git a/addons/website_sale/models/website_page.py b/addons/website_sale/models/website_page.py
new file mode 100644
index 00000000..14fa5b7e
--- /dev/null
+++ b/addons/website_sale/models/website_page.py
@@ -0,0 +1,14 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from odoo import models
+from odoo.http import request
+
+
+class WabsitePage(models.AbstractModel):
+ _inherit = 'website.page'
+
+ def _get_cache_key(self, req):
+ cart = request.website.sale_get_order()
+ cache_key = (cart and cart.cart_quantity or 0,)
+ cache_key += super()._get_cache_key(req)
+ return cache_key
diff --git a/addons/website_sale/models/website_snippet_filter.py b/addons/website_sale/models/website_snippet_filter.py
new file mode 100644
index 00000000..7b8206b1
--- /dev/null
+++ b/addons/website_sale/models/website_snippet_filter.py
@@ -0,0 +1,12 @@
+# -*- coding: utf-8 -*-
+
+from odoo import models, fields, api, _
+
+
+class WebsiteSnippetFilter(models.Model):
+ _inherit = 'website.snippet.filter'
+
+ @api.model
+ def _get_website_currency(self):
+ pricelist = self.env['website'].get_current_website().get_current_pricelist()
+ return pricelist.currency_id
diff --git a/addons/website_sale/models/website_visitor.py b/addons/website_sale/models/website_visitor.py
new file mode 100644
index 00000000..24461058
--- /dev/null
+++ b/addons/website_sale/models/website_visitor.py
@@ -0,0 +1,48 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+
+from odoo import fields, models, api
+
+class WebsiteTrack(models.Model):
+ _inherit = 'website.track'
+
+ product_id = fields.Many2one('product.product', index=True, ondelete='cascade', readonly=True)
+
+
+class WebsiteVisitor(models.Model):
+ _inherit = 'website.visitor'
+
+ visitor_product_count = fields.Integer('Product Views', compute="_compute_product_statistics", help="Total number of views on products")
+ product_ids = fields.Many2many('product.product', string="Visited Products", compute="_compute_product_statistics")
+ product_count = fields.Integer('Products Views', compute="_compute_product_statistics", help="Total number of product viewed")
+
+ @api.depends('website_track_ids')
+ def _compute_product_statistics(self):
+ results = self.env['website.track'].read_group(
+ [('visitor_id', 'in', self.ids), ('product_id', '!=', False),
+ '|', ('product_id.company_id', 'in', self.env.companies.ids), ('product_id.company_id', '=', False)],
+ ['visitor_id', 'product_id'], ['visitor_id', 'product_id'],
+ lazy=False)
+ mapped_data = {}
+ for result in results:
+ visitor_info = mapped_data.get(result['visitor_id'][0], {'product_count': 0, 'product_ids': set()})
+ visitor_info['product_count'] += result['__count']
+ visitor_info['product_ids'].add(result['product_id'][0])
+ mapped_data[result['visitor_id'][0]] = visitor_info
+
+ for visitor in self:
+ visitor_info = mapped_data.get(visitor.id, {'product_ids': [], 'product_count': 0})
+
+ visitor.product_ids = [(6, 0, visitor_info['product_ids'])]
+ visitor.visitor_product_count = visitor_info['product_count']
+ visitor.product_count = len(visitor_info['product_ids'])
+
+ def _add_viewed_product(self, product_id):
+ """ add a website_track with a page marked as viewed"""
+ self.ensure_one()
+ if product_id and self.env['product.product'].browse(product_id)._is_variant_possible():
+ domain = [('product_id', '=', product_id)]
+ website_track_values = {'product_id': product_id, 'visit_datetime': datetime.now()}
+ self._add_tracking(domain, website_track_values)