Initial commit: Sale packages

This commit is contained in:
Ernad Husremovic 2025-08-29 15:20:49 +02:00
commit 14e3d26998
6469 changed files with 2479670 additions and 0 deletions

View file

@ -0,0 +1,26 @@
# -*- 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 digest
from . import ir_http
from . import payment_provider
from . import product_attribute
from . import product_image
from . import product_pricelist
from . import product_product
from . import product_public_category
from . import product_ribbon
from . import product_tag
from . import product_template
from . import res_company
from . import res_config_settings
from . import res_country
from . import res_partner
from . import sale_order
from . import sale_order_line
from . import website
from . import website_base_unit
from . import website_snippet_filter
from . import website_visitor

View file

@ -0,0 +1,38 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models
from odoo.tools.sql import column_exists, create_column
class AccountMove(models.Model):
_inherit = 'account.move'
website_id = fields.Many2one(
'website', compute='_compute_website_id', string='Website',
help='Website through which this invoice was created for eCommerce orders.',
store=True, readonly=True, tracking=True)
def _auto_init(self):
if not column_exists(self.env.cr, "account_move", "website_id"):
# Creating the column via `_auto_init` prevents a MemoryError in databases where many
# invoices exist when `website_sale` is installed, as it skips the computation of the
# `website_id` field.
create_column(self.env.cr, "account_move", "website_id", "int4")
super()._auto_init()
def preview_invoice(self):
action = super().preview_invoice()
if action['url'].startswith('/'):
# URL should always be relative, safety check
action['url'] = f'/@{action["url"]}'
return action
@api.depends('partner_id') # Dummy depends to trigger compute, will be dropped in master
def _compute_website_id(self):
for move in self:
source_websites = move.line_ids.sale_line_ids.order_id.website_id
if len(source_websites) == 1:
move.website_id = source_websites
else:
move.website_id = False

View file

@ -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')
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>
'''),
}

View file

@ -0,0 +1,34 @@
# -*- 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(
sale.currency_id._convert(sale.amount_total, company.currency_id, company, sale.date_order)
for sale in confirmed_website_sales
)
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

View file

@ -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 _pre_dispatch(cls, rule, args):
super()._pre_dispatch(rule, args)
affiliate_id = request.httprequest.args.get('affiliate_id')
if affiliate_id:
request.session['affiliate_id'] = int(affiliate_id)

View file

@ -0,0 +1,25 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class PaymentProvider(models.Model):
_inherit = 'payment.provider'
@api.model
def _get_compatible_providers(self, *args, website_id=None, **kwargs):
""" Override of payment to only return providers matching website-specific criteria.
In addition to the base criteria, the website must either not be set or be the same as the
one provided in the kwargs.
:param int website_id: The provided website, as a `website` id
:return: The compatible providers
:rtype: recordset of `payment.provider`
"""
providers = super()._get_compatible_providers(*args, website_id=website_id, **kwargs)
if website_id:
providers = providers.filtered(
lambda p: not p.website_id or p.website_id.id == website_id
)
return providers

View file

@ -0,0 +1,32 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from collections import OrderedDict
from odoo import models, fields
class ProductAttribute(models.Model):
_inherit = 'product.attribute'
visibility = fields.Selection([('visible', 'Visible'), ('hidden', 'Hidden')], default='visible')
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

View file

@ -0,0 +1,71 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
from odoo import api, fields, models, tools, _
from odoo.exceptions import ValidationError
from odoo.addons.web_editor.tools import get_video_embed_code, get_video_thumbnail
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)
image_1920 = fields.Image()
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.Html(compute="_compute_embed_code", sanitize=False)
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.onchange('video_url')
def _onchange_video_url(self):
if not self.image_1920:
thumbnail = get_video_thumbnail(self.video_url)
self.image_1920 = thumbnail and base64.b64encode(thumbnail) or False
@api.depends('video_url')
def _compute_embed_code(self):
for image in self:
image.embed_code = get_video_embed_code(image.video_url) or False
@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)

View file

@ -0,0 +1,115 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from odoo.addons.website.models import ir_http
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")
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('company_id') and not vals.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)
# YTI FIXME: The fix is not at the correct place
# It be set when we actually create the pricelist
self = self.with_context(default_company_id=vals['company_id'])
pricelists = super().create(vals_list)
pricelists and pricelists.clear_caches()
return pricelists
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 and self.clear_caches()
return res
def unlink(self):
res = super(ProductPricelist, self).unlink()
self._check_website_pricelist()
self and self.clear_caches()
return res
def _get_partner_pricelist_multi_search_domain_hook(self, company_id):
domain = super()._get_partner_pricelist_multi_search_domain_hook(company_id)
website = ir_http.get_request_website()
if website:
domain += self._get_website_pricelists_domain(website)
return domain
def _get_partner_pricelist_multi_filter_hook(self):
res = super()._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))
return res
def _check_website_pricelist(self):
for website in self.env['website'].search([]):
# sudo() to be able to read pricelists/website from another company
if not website.sudo().pricelist_ids:
raise UserError(_("With this action, '%s' website would not have any pricelist available.") % (website.name))
def _is_available_on_website(self, website):
""" 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 != website.company_id:
return False
return self.website_id.id == website.id or (not self.website_id and (self.selectable or self.sudo().code))
def _is_available_in_country(self, country_code):
self.ensure_one()
if not country_code or not self.country_group_ids:
return True
return country_code in self.country_group_ids.country_ids.mapped('code')
def _get_website_pricelists_domain(self, website):
''' Check above `_is_available_on_website` for explanation.
Change in this method should be reflected in `_is_available_on_website`.
'''
return [
('active', '=', True),
('company_id', 'in', [False, website.company_id.id]),
'|', ('website_id', '=', website.id),
'&', ('website_id', '=', False),
'|', ('selectable', '=', True), ('code', '!=', False),
]
@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."""))

View file

@ -0,0 +1,104 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class Product(models.Model):
_inherit = "product.product"
_mail_post_access = 'read'
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.')
base_unit_count = fields.Float('Base Unit Count', required=True, default=1, help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.")
base_unit_id = fields.Many2one('website.base.unit', string='Custom Unit of Measure', help="Define a custom unit to display in the price per unit of measure field.")
base_unit_price = fields.Monetary("Price Per Unit", currency_field="currency_id", compute="_compute_base_unit_price")
base_unit_name = fields.Char(compute='_compute_base_unit_name', help='Displays the custom unit for the products if defined or the selected unit of measure otherwise.')
def _get_base_unit_price(self, price):
self.ensure_one()
return self.base_unit_count and price / self.base_unit_count
@api.depends('lst_price', 'base_unit_count')
def _compute_base_unit_price(self):
for product in self:
if not product.id:
product.base_unit_price = 0
else:
product.base_unit_price = product._get_base_unit_price(product.lst_price)
@api.depends('uom_name', 'base_unit_id')
def _compute_base_unit_name(self):
for product in self:
product.base_unit_name = product.base_unit_id.name or product.uom_name
@api.constrains('base_unit_count')
def _check_base_unit_count(self):
if any(product.base_unit_count < 0 for product in self):
raise ValidationError(_('The value of Base Unit Count must be greater than 0. Use 0 to hide the price per unit on this product.'))
@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 _prepare_variant_values(self, combination):
variant_dict = super()._prepare_variant_values(combination)
variant_dict['base_unit_count'] = self.base_unit_count
return variant_dict
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 (which will fall back on the main
image of the template, if unset), the Variant Extra Images, and the Template Extra Images.
"""
self.ensure_one()
variant_images = list(self.product_variant_image_ids)
template_images = list(self.product_tmpl_id.product_template_image_ids)
return [self] + variant_images + template_images
def _website_show_quick_add(self):
website = self.env['website'].get_current_website()
return self.sale_ok and (not website.prevent_zero_price_sale or self._get_contextual_price())
def _is_add_to_cart_allowed(self):
self.ensure_one()
return self.user_has_groups('base.group_system') or (self.active and self.sale_ok and self.website_published)
def _get_contextual_price_tax_selection(self):
self.ensure_one()
fpos_id = self.env['website'].sudo()._get_current_fiscal_position_id(self.env.user.partner_id)
fiscal_position_sudo = self.env['account.fiscal.position'].sudo().browse(fpos_id)
product_taxes = self.sudo().taxes_id.filtered(lambda x: x.company_id == self.env.company)
return self.env['product.template']._price_with_tax_computed(
self._get_contextual_price(),
product_taxes,
fiscal_position_sudo.map_tax(product_taxes),
self.env.company.id,
self.env['product.template']._get_contextual_pricelist(),
self,
self.env.user.partner_id,
)

View file

@ -0,0 +1,80 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.tools.translate import html_translate
class ProductPublicCategory(models.Model):
_name = "product.public.category"
_inherit = [
'website.seo.metadata',
'website.multi.mixin',
'website.searchable.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, unaccent=False)
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_overridable=True, 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
@api.model
def _search_get_detail(self, website, order, options):
with_description = options['displayDescription']
search_fields = ['name']
fetch_fields = ['id', 'name']
mapping = {
'name': {'name': 'name', 'type': 'text', 'match': True},
'website_url': {'name': 'url', 'type': 'text', 'truncate': False},
}
if with_description:
search_fields.append('website_description')
fetch_fields.append('website_description')
mapping['description'] = {'name': 'website_description', 'type': 'text', 'match': True, 'html': True}
return {
'model': 'product.public.category',
'base_domain': [website.website_domain()],
'search_fields': search_fields,
'fetch_fields': fetch_fields,
'mapping': mapping,
'icon': 'fa-folder-o',
'order': 'name desc, id desc' if 'name desc' in order else 'name asc, id desc',
}
def _search_render_results(self, fetch_fields, mapping, icon, limit):
results_data = super()._search_render_results(fetch_fields, mapping, icon, limit)
for data in results_data:
data['url'] = '/shop/category/%s' % data['id']
return results_data

View file

@ -0,0 +1,18 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models, tools
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.Html(string='Ribbon html', required=True, translate=True, sanitize=False)
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='')
product_tag_ids = fields.One2many('product.tag', 'ribbon_id', string='Product Tags')

View file

@ -0,0 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductTag(models.Model):
_name = 'product.tag'
_inherit = ['website.multi.mixin', 'product.tag']
ribbon_id = fields.Many2one('product.ribbon', string='Ribbon')

View file

@ -0,0 +1,593 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import logging
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import slug, unslug
from odoo.addons.website.models import ir_http
from odoo.tools.translate import html_translate
from odoo.osv import expression
from psycopg2.extras import execute_values
_logger = logging.getLogger(__name__)
class ProductTemplate(models.Model):
_inherit = [
"product.template",
"website.seo.metadata",
'website.published.multi.mixin',
'website.searchable.mixin',
'rating.mixin',
]
_name = 'product.template'
_mail_post_access = 'read'
_check_company_auto = True
website_description = fields.Html(
'Description for the website', translate=html_translate,
sanitize_overridable=True,
sanitize_attributes=False, 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, index=True)
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 > Edit "
"Click on the page and enable 'Categories' to view all eCommerce categories.")
product_template_image_ids = fields.One2many('product.image', 'product_tmpl_id', string="Extra Product Media", copy=True)
base_unit_count = fields.Float('Base Unit Count', required=True, default=0,
compute='_compute_base_unit_count', inverse='_set_base_unit_count', store=True,
help="Display base unit price on your eCommerce pages. Set to 0 to hide it for this product.")
base_unit_id = fields.Many2one('website.base.unit', string='Custom Unit of Measure',
compute='_compute_base_unit_id', inverse='_set_base_unit_id', store=True,
help="Define a custom unit to display in the price per unit of measure field.")
base_unit_price = fields.Monetary("Price Per Unit", currency_field="currency_id", compute="_compute_base_unit_price")
base_unit_name = fields.Char(compute='_compute_base_unit_name', help='Displays the custom unit for the products if defined or the selected unit of measure otherwise.')
compare_list_price = fields.Float(
'Compare to Price',
digits='Product Price',
help="The amount will be displayed strikethroughed on the eCommerce product page")
@api.depends('product_variant_ids', 'product_variant_ids.base_unit_count')
def _compute_base_unit_count(self):
self.base_unit_count = 0
for template in self.filtered(lambda template: len(template.product_variant_ids) == 1):
template.base_unit_count = template.product_variant_ids.base_unit_count
def _set_base_unit_count(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.base_unit_count = template.base_unit_count
@api.depends('product_variant_ids', 'product_variant_ids.base_unit_count')
def _compute_base_unit_id(self):
self.base_unit_id = self.env['website.base.unit']
for template in self.filtered(lambda template: len(template.product_variant_ids) == 1):
template.base_unit_id = template.product_variant_ids.base_unit_id
def _set_base_unit_id(self):
for template in self:
if len(template.product_variant_ids) == 1:
template.product_variant_ids.base_unit_id = template.base_unit_id
def _get_base_unit_price(self, price):
self.ensure_one()
return self.base_unit_count and price / self.base_unit_count
@api.depends('list_price', 'base_unit_count')
def _compute_base_unit_price(self):
for template in self:
template.base_unit_price = template._get_base_unit_price(template.list_price)
@api.depends('uom_name', 'base_unit_id.name')
def _compute_base_unit_name(self):
for template in self:
template.base_unit_name = template.base_unit_id.name or template.uom_name
def _prepare_variant_values(self, combination):
variant_dict = super()._prepare_variant_values(combination)
variant_dict['base_unit_count'] = self.base_unit_count
return variant_dict
def _get_website_accessory_product(self):
domain = self.env['website'].sale_product_domain()
if not self.env.user._is_internal():
domain = expression.AND([domain, [('is_published', '=', True)]])
return self.accessory_product_ids.filtered_domain(domain)
def _get_website_alternative_product(self):
domain = self.env['website'].sale_product_domain()
return self.alternative_product_ids.filtered_domain(domain)
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_sales_prices(self, pricelist):
pricelist.ensure_one()
partner_sudo = self.env.user.partner_id
# Try to fetch geoip based fpos or fallback on partner one
fpos_id = self.env['website']._get_current_fiscal_position_id(partner_sudo)
fiscal_position = self.env['account.fiscal.position'].sudo().browse(fpos_id)
sales_prices = pricelist._get_products_price(self, 1.0)
show_discount = pricelist.discount_policy == 'without_discount'
show_strike_price = self.env.user.has_group('website_sale.group_product_price_comparison')
base_sales_prices = self.price_compute('list_price', currency=pricelist.currency_id)
res = {}
for template in self:
price_reduce = sales_prices[template.id]
product_taxes = template.sudo().taxes_id.filtered(lambda t: t.company_id == t.env.company)
taxes = fiscal_position.map_tax(product_taxes)
template_price_vals = {
'price_reduce': price_reduce
}
base_price = None
price_list_contains_template = pricelist.currency_id.compare_amounts(price_reduce, base_sales_prices[template.id]) != 0
if template.compare_list_price and show_strike_price:
# The base_price becomes the compare list price and the price_reduce becomes the price
base_price = template.compare_list_price
if not price_list_contains_template:
price_reduce = base_sales_prices[template.id]
template_price_vals.update(price_reduce=price_reduce)
if template.currency_id != pricelist.currency_id:
base_price = template.currency_id._convert(
base_price,
pricelist.currency_id,
self.env.company,
fields.Datetime.now(),
round=False
)
elif show_discount and price_list_contains_template:
base_price = base_sales_prices[template.id]
if base_price and base_price != price_reduce:
if not template.compare_list_price:
# Compare_list_price are never tax included
base_price = self._price_with_tax_computed(
base_price, product_taxes, taxes, self.env.company.id,
pricelist, template, partner_sudo,
)
template_price_vals['base_price'] = base_price
template_price_vals['price_reduce'] = self._price_with_tax_computed(
template_price_vals['price_reduce'], product_taxes, taxes, self.env.company.id,
pricelist, template, partner_sudo,
)
res[template.id] = template_price_vals
return res
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'):
product = self.env['product.product'].browse(combination_info['product_id']) or self
partner = self.env.user.partner_id
company_id = current_website.company_id
fpos_id = self.env['website'].sudo()._get_current_fiscal_position_id(partner)
fiscal_position = self.env['account.fiscal.position'].sudo().browse(fpos_id)
product_taxes = product.sudo().taxes_id.filtered(lambda x: x.company_id == company_id)
taxes = fiscal_position.map_tax(product_taxes)
price = self._price_with_tax_computed(
combination_info['price'], product_taxes, taxes, company_id, pricelist, product,
partner
)
if pricelist.discount_policy == 'without_discount':
list_price = self._price_with_tax_computed(
combination_info['list_price'], product_taxes, taxes, company_id, pricelist,
product, partner
)
else:
list_price = price
price_extra = self._price_with_tax_computed(
combination_info['price_extra'], product_taxes, taxes, company_id, pricelist,
product, partner
)
has_discounted_price = pricelist.currency_id.compare_amounts(list_price, price) == 1
prevent_zero_price_sale = not price and current_website.prevent_zero_price_sale
compare_list_price = self.compare_list_price
if pricelist and pricelist.currency_id != product.currency_id:
compare_list_price = self.currency_id._convert(self.compare_list_price, pricelist.currency_id, self.env.company,
fields.Datetime.now(), round=False)
combination_info.update(
base_unit_name=product.base_unit_name,
base_unit_price=product._get_base_unit_price(list_price),
price=price,
list_price=list_price,
price_extra=price_extra,
has_discounted_price=has_discounted_price,
prevent_zero_price_sale=prevent_zero_price_sale,
compare_list_price=compare_list_price
)
return combination_info
@api.model
def _price_with_tax_computed(
self, price, product_taxes, taxes, company_id, pricelist, product, partner
):
price = self.env['product.product']._get_tax_included_unit_price_from_price(
price,
pricelist.currency_id,
product_taxes,
product_taxes_after_fp=taxes,
)
show_tax_excluded = self.user_has_groups('account.group_show_line_subtotals_tax_excluded')
tax_display = 'total_excluded' if show_tax_excluded else 'total_included'
return taxes.compute_all(
price_unit=price,
currency=pricelist.currency_id,
quantity=1, # `list_price` is always the price of one
product=product.sudo(), # tax computation may require access to restricted fields
partner=partner,
)[tax_display]
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_128:
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_128 else self
def _get_suitable_image_size(self, columns, x_size, y_size):
if x_size == 1 and y_size == 1 and columns >= 3:
return 'image_512'
return 'image_1024'
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 _init_column(self, column_name):
# to avoid generating a single default website_sequence when installing the module,
# we need to set the default row by row for this column
if column_name == "website_sequence":
_logger.debug("Table '%s': setting default value of new column %s to unique values for each row", self._table, column_name)
self.env.cr.execute("SELECT id FROM %s WHERE website_sequence IS NULL" % self._table)
prod_tmpl_ids = self.env.cr.dictfetchall()
max_seq = self._default_website_sequence()
query = """
UPDATE {table}
SET website_sequence = p.web_seq
FROM (VALUES %s) AS p(p_id, web_seq)
WHERE id = p.p_id
""".format(table=self._table)
values_args = [(prod_tmpl['id'], max_seq + i * 5) for i, prod_tmpl in enumerate(prod_tmpl_ids)]
execute_values(self.env.cr._obj, query, values_args)
else:
super(ProductTemplate, self)._init_column(column_name)
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)
def _get_website_ribbon(self):
if self.website_ribbon_id:
return self.website_ribbon_id
return self.product_tag_ids.ribbon_id[:1] or self.product_variant_ids.additional_product_tag_ids.ribbon_id[:1]
@api.model
def _get_alternative_product_filter(self):
return self.env.ref('website_sale.dynamic_filter_cross_selling_alternative_products').id
@api.model
def _get_product_types_allow_zero_price(self):
"""
Returns a list of detailed types (`product.template.detailed_type`) that can ignore the
`prevent_zero_price_sale` rule when buying products on a website.
"""
return []
# ---------------------------------------------------------
# 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)
@api.model
def _search_get_detail(self, website, order, options):
with_image = options['displayImage']
with_description = options['displayDescription']
with_category = options['displayExtraLink']
with_price = options['displayDetail']
domains = [website.sale_product_domain()]
category = options.get('category')
min_price = options.get('min_price')
max_price = options.get('max_price')
attrib_values = options.get('attrib_values')
if category:
domains.append([('public_categ_ids', 'child_of', unslug(category)[1])])
if min_price:
domains.append([('list_price', '>=', min_price)])
if max_price:
domains.append([('list_price', '<=', max_price)])
if attrib_values:
attrib = None
ids = []
for value in attrib_values:
if not attrib:
attrib = value[0]
ids.append(value[1])
elif value[0] == attrib:
ids.append(value[1])
else:
domains.append([('attribute_line_ids.value_ids', 'in', ids)])
attrib = value[0]
ids = [value[1]]
if attrib:
domains.append([('attribute_line_ids.value_ids', 'in', ids)])
search_fields = ['name', 'default_code', 'product_variant_ids.default_code']
fetch_fields = ['id', 'name', 'website_url']
mapping = {
'name': {'name': 'name', 'type': 'text', 'match': True},
'default_code': {'name': 'default_code', 'type': 'text', 'match': True},
'product_variant_ids.default_code': {'name': 'product_variant_ids.default_code', 'type': 'text', 'match': True},
'website_url': {'name': 'website_url', 'type': 'text', 'truncate': False},
}
if with_image:
mapping['image_url'] = {'name': 'image_url', 'type': 'html'}
if with_description:
# Internal note is not part of the rendering.
search_fields.append('description')
fetch_fields.append('description')
search_fields.append('description_sale')
fetch_fields.append('description_sale')
mapping['description'] = {'name': 'description_sale', 'type': 'text', 'match': True}
if with_price:
mapping['detail'] = {'name': 'price', 'type': 'html', 'display_currency': options['display_currency']}
mapping['detail_strike'] = {'name': 'list_price', 'type': 'html', 'display_currency': options['display_currency']}
if with_category:
mapping['extra_link'] = {'name': 'category', 'type': 'html'}
return {
'model': 'product.template',
'base_domain': domains,
'search_fields': search_fields,
'fetch_fields': fetch_fields,
'mapping': mapping,
'icon': 'fa-shopping-cart',
}
def _search_render_results(self, fetch_fields, mapping, icon, limit):
with_image = 'image_url' in mapping
with_category = 'extra_link' in mapping
with_price = 'detail' in mapping
results_data = super()._search_render_results(fetch_fields, mapping, icon, limit)
current_website = self.env['website'].get_current_website()
for product, data in zip(self, results_data):
categ_ids = product.public_categ_ids.filtered(lambda c: not c.website_id or c.website_id == current_website)
if with_price:
combination_info = product._get_combination_info(only_template=True)
data['price'], list_price = self._search_render_results_prices(
mapping, combination_info
)
if list_price:
data['list_price'] = list_price
if with_image:
data['image_url'] = '/web/image/product.template/%s/image_128' % data['id']
if with_category and categ_ids:
data['category'] = self.env['ir.ui.view'].sudo()._render_template(
"website_sale.product_category_extra_link",
{'categories': categ_ids, 'slug': slug}
)
return results_data
def _search_render_results_prices(self, mapping, combination_info):
monetary_options = {'display_currency': mapping['detail']['display_currency']}
if combination_info['prevent_zero_price_sale']:
website = self.env['website'].get_current_website()
return website.prevent_zero_price_sale_text, None
price = self.env['ir.qweb.field.monetary'].value_to_html(
combination_info['price'], monetary_options
)
if combination_info['has_discounted_price']:
list_price = self.env['ir.qweb.field.monetary'].value_to_html(
combination_info['list_price'], monetary_options
)
if combination_info['compare_list_price']:
list_price = self.env['ir.qweb.field.monetary'].value_to_html(
combination_info['compare_list_price'], monetary_options
)
return price, list_price if combination_info['has_discounted_price'] else None
@api.model
def get_google_analytics_data(self, combination):
product = self.env['product.product'].browse(combination['product_id'])
return {
'item_id': product.barcode or product.id,
'item_name': combination['display_name'],
'item_category': product.categ_id.name or '-',
'currency': product.currency_id.name,
'price': combination['list_price'],
}
def _get_contextual_pricelist(self):
""" Override to fallback on website current pricelist
"""
pricelist = super()._get_contextual_pricelist()
if pricelist:
return pricelist
website = ir_http.get_request_website()
if website:
return website.get_current_pricelist()
return pricelist
def _website_show_quick_add(self):
website = self.env['website'].get_current_website()
return self.sale_ok and (not website.prevent_zero_price_sale or self._get_contextual_price())

View file

@ -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_provider_state = fields.Selection([('not_done', "Not done"), ('just_done', "Just done"), ('done', "Done")], string="State of the website sale onboarding payment provider step", default='not_done')
@api.model
def action_open_website_sale_onboarding_payment_provider(self):
""" Called by onboarding panel above the quotation list."""
self.env.company.payment_onboarding_payment_method = 'stripe'
menu_id = self.env.ref('website.menu_website_dashboard').id
return self._run_payment_onboarding_step(menu_id)

View file

@ -0,0 +1,171 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
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, domain="[('share', '=', 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='account.group_delivery_invoice_address', group='base.group_portal,base.group_user,base.group_public')
group_show_uom_price = fields.Boolean(default=False, string="Base Unit Price", implied_group="website_sale.group_show_uom_price", group='base.group_portal,base.group_user,base.group_public')
group_product_price_comparison = fields.Boolean(
string="Comparison Price",
implied_group="website_sale.group_product_price_comparison",
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_autocomplete = fields.Boolean('Address Autocomplete')
module_account = fields.Boolean("Invoicing")
module_website_sale_picking = fields.Boolean('On Site Payments & Picking')
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(string="Send After", related='website_id.cart_abandoned_delay', readonly=False)
send_abandoned_cart_email = fields.Boolean('Abandoned Email', related='website_id.send_abandoned_cart_email', readonly=False)
add_to_cart_action = fields.Selection(related='website_id.add_to_cart_action', readonly=False)
terms_url = fields.Char(compute='_compute_terms_url', string="URL", help="A preview will be available at this URL.")
module_delivery = fields.Boolean(
compute='_compute_module_delivery', store=True, readonly=False)
module_delivery_mondialrelay = fields.Boolean("Mondial Relay Connector")
module_website_sale_delivery = fields.Boolean(
compute='_compute_module_delivery', store=True, readonly=False)
group_product_pricelist = fields.Boolean(
compute='_compute_group_product_pricelist', store=True, readonly=False)
enabled_extra_checkout_step = fields.Boolean(string="Extra Step During Checkout", compute='_compute_checkout_process_steps', readonly=False, store=True)
enabled_buy_now_button = fields.Boolean(string="Buy Now", compute='_compute_checkout_process_steps', readonly=False, store=True)
account_on_checkout = fields.Selection(
string="Customer Accounts",
selection=[
("optional", "Optional"),
("disabled", "Disabled (buy as guest)"),
("mandatory", "Mandatory (no guest checkout)"),
],
compute="_compute_account_on_checkout",
inverse="_inverse_account_on_checkout",
readonly=False, required=True)
website_sale_prevent_zero_price_sale = fields.Boolean(string="Prevent Sale of Zero Priced Product", related='website_id.prevent_zero_price_sale', readonly=False)
website_sale_contact_us_button_url = fields.Char(string="Button URL", related='website_id.contact_us_button_url', readonly=False)
website_sale_enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal", related='website_id.enabled_portal_reorder_button', readonly=False)
@api.depends('website_id')
def _compute_terms_url(self):
for record in self:
record.terms_url = '%s/terms' % record.website_id.get_base_url()
@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
def set_values(self):
super().set_values()
if self.website_id:
website = self.with_context(website_id=self.website_id.id).website_id
extra_step_view = website.viewref('website_sale.extra_info_option')
buy_now_view = website.viewref('website_sale.product_buy_now')
if extra_step_view.active != self.enabled_extra_checkout_step:
extra_step_view.active = self.enabled_extra_checkout_step
if buy_now_view.active != self.enabled_buy_now_button:
buy_now_view.active = self.enabled_buy_now_button
@api.depends('sale_delivery_settings')
def _compute_module_delivery(self):
for wizard in self:
wizard.module_delivery = wizard.sale_delivery_settings in ['internal', 'website']
wizard.module_website_sale_delivery = wizard.sale_delivery_settings == 'website'
@api.depends('group_discount_per_so_line')
def _compute_group_product_pricelist(self):
self.filtered(lambda w: w.group_discount_per_so_line).update({
'group_product_pricelist': True,
})
@api.depends('website_id.account_on_checkout')
def _compute_account_on_checkout(self):
for record in self:
record.account_on_checkout = record.website_id.account_on_checkout or 'disabled'
@api.depends('website_id')
def _compute_checkout_process_steps(self):
"""
Computing the extra info step and buy now settings when changing
the website in the res.config.settings page to show the correct value
in the checkbox.
"""
for record in self:
website = record.with_context(website_id=record.website_id.id).website_id
record.enabled_extra_checkout_step = website.is_view_active(
'website_sale.extra_info_option'
)
record.enabled_buy_now_button = website.is_view_active(
'website_sale.product_buy_now'
)
def _inverse_account_on_checkout(self):
for record in self:
if not record.website_id:
continue
record.website_id.account_on_checkout = record.account_on_checkout
# account_on_checkout implies different values for `auth_signup_uninvited`
if record.account_on_checkout in ['optional', 'mandatory']:
record.website_id.auth_signup_uninvited = 'b2c'
else:
record.website_id.auth_signup_uninvited = 'b2b'
def action_update_terms(self):
self.ensure_one()
return self.env["website"].get_client_action('/terms', True)
def action_open_extra_info(self):
self.ensure_one()
# Add the "edit" parameter in the url to tell the controller
# that we want to edit even if we are not in a payment flow
return self.env["website"].get_client_action('/shop/extra_info?open_editor=true', True, self.website_id.id)
def action_open_sale_mail_templates(self):
return {
'name': _('Customize Email Templates'),
'type': 'ir.actions.act_window',
'domain': [('model', '=', 'sale.order')],
'res_model': 'mail.template',
'view_id': False,
'view_mode': 'tree,form',
}
def action_open_abandoned_cart_mail_template(self):
return {
'name': _('Customize Email Templates'),
'type': 'ir.actions.act_window',
'res_model': 'mail.template',
'view_id': False,
'view_mode': 'form',
'res_id': self.env['ir.model.data']._xmlid_to_res_id("website_sale.mail_template_sale_cart_recovery"),
}

View file

@ -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

View file

@ -0,0 +1,66 @@
# -*- 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 = partner.is_public
website = ir_http.get_request_website()
if website and not is_public:
partner.last_website_so_id = SaleOrder.search([
('partner_id', '=', partner.id),
('pricelist_id', '=', partner.property_product_pricelist.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
@api.onchange('property_product_pricelist')
def _onchange_property_product_pricelist(self):
open_order = self.env['sale.order'].sudo().search([
('partner_id', '=', self._origin.id),
('pricelist_id', '=', self._origin.property_product_pricelist.id),
('pricelist_id', '!=', self.property_product_pricelist.id),
('website_id', '!=', False),
('state', '=', 'draft'),
], limit=1)
if open_order:
return {'warning': {
'title': _('Open Sale Orders'),
'message': _(
"This partner has an open cart. "
"Please note that the pricelist will not be updated on that cart. "
"Also, the cart might not be visible for the customer until you update the pricelist of that cart."
),
}}
def write(self, vals):
res = super().write(vals)
if {'country_id', 'vat', 'zip'} & vals.keys():
# Recompute fiscal position for open website orders
orders_sudo = self.env['sale.order'].sudo().search([
('state', '=', 'draft'),
('website_id', '!=', False),
'|', ('partner_id', 'in', self.ids), ('partner_shipping_id', 'in', self.ids),
])
if orders_sudo:
fpos_by_order = {so.id: so.fiscal_position_id.id for so in orders_sudo}
self.env.add_to_compute(orders_sudo._fields['fiscal_position_id'], orders_sudo)
fpos_changed = orders_sudo.filtered(
lambda so: so.fiscal_position_id.id != fpos_by_order[so.id],
)
if fpos_changed:
fpos_changed._recompute_taxes()
fpos_changed._recompute_prices()
return res

View file

@ -0,0 +1,506 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import random
from datetime import datetime
from dateutil.relativedelta import relativedelta
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.http import request
from odoo.osv import expression
from odoo.tools import float_is_zero
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',
) # 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 for eCommerce orders.')
shop_warning = fields.Char('Warning')
@api.model_create_multi
def create(self, vals_list):
for vals in vals_list:
if vals.get('website_id'):
website = self.env['website'].browse(vals['website_id'])
if 'company_id' in vals:
company = self.env['res.company'].browse(vals['company_id'])
if website.company_id.id != company.id:
raise ValueError(_("The company of the website you are trying to sale from (%s) is different than the one you want to use (%s)") % (website.company_id.name, company.name))
else:
vals['company_id'] = website.company_id.id
return super().create(vals_list)
def _compute_user_id(self):
"""Do not assign self.env.user as salesman for e-commerce orders
Leave salesman empty if no salesman is specified on partner or website
c/p of the logic in Website._prepare_sale_order_values
"""
website_orders = self.filtered('website_id')
super(SaleOrder, self - website_orders)._compute_user_id()
for order in website_orders:
if not order.user_id:
order.user_id = order.website_id.salesperson_id or order.partner_id.parent_id.user_id.id or order.partner_id.user_id.id
@api.model
def _get_note_url(self):
website_id = self._context.get('website_id')
if website_id:
return self.env['website'].browse(website_id).get_base_url()
return super()._get_note_url()
@api.depends('order_line')
def _compute_website_order_line(self):
for order in self:
order.website_order_line = order.order_line.filtered(lambda l: l._show_in_cart())
@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 == 'service' 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
@api.depends('partner_id')
def _compute_payment_term_id(self):
super()._compute_payment_term_id()
for order in self:
if order.website_id:
order.payment_term_id = order.website_id.with_company(order.company_id).sale_get_payment_term(order.partner_id)
def _search_abandoned_cart(self, operator, value):
website_ids = self.env['website'].search_read(fields=['id', 'cart_abandoned_delay', 'partner_id'])
deadlines = [[
'&', '&',
('website_id', '=', website_id['id']),
('date_order', '<=', fields.Datetime.to_string(datetime.utcnow() - relativedelta(hours=website_id['cart_abandoned_delay'] or 1.0))),
('partner_id', '!=', website_id['partner_id'][0])
] for website_id in website_ids]
abandoned_domain = [
('state', '=', 'draft'),
('order_line', '!=', False)
]
abandoned_domain.extend(expression.OR(deadlines))
abandoned_domain = expression.normalize_domain(abandoned_domain)
# 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_update_order_line(self, product_id, quantity, order_line, **kwargs):
self.ensure_one()
if order_line and quantity <= 0:
# Remove zero or negative lines
order_line.unlink()
order_line = self.env['sale.order.line']
elif order_line:
# Update existing line
update_values = self._prepare_order_line_update_values(order_line, quantity, **kwargs)
if update_values:
self._update_cart_line_values(order_line, update_values)
elif quantity > 0:
# Create new line
order_line_values = self._prepare_order_line_values(product_id, quantity, **kwargs)
order_line = self.env['sale.order.line'].sudo().create(order_line_values)
return order_line
def _cart_update_pricelist(self, pricelist_id=None, update_pricelist=False):
self.ensure_one()
previous_pricelist_id = self.pricelist_id.id
if pricelist_id:
self.pricelist_id = pricelist_id
if update_pricelist:
self._compute_pricelist_id()
if update_pricelist or previous_pricelist_id != self.pricelist_id.id:
self._recompute_prices()
def _cart_update(self, product_id, line_id=None, add_qty=0, set_qty=0, **kwargs):
""" Add or set product quantity, add_qty can be negative """
self.ensure_one()
self = self.with_company(self.company_id)
if self.state != 'draft':
request.session.pop('sale_order_id', None)
request.session.pop('website_sale_cart_quantity', None)
raise UserError(_('It is forbidden to modify a sales order which is not in draft status.'))
product = self.env['product.product'].browse(product_id).exists()
if add_qty and (not product or not product._is_add_to_cart_allowed()):
raise UserError(_("The given product does not exist therefore it cannot be added to cart."))
if line_id is not False:
order_line = self._cart_find_product_line(product_id, line_id, **kwargs)[:1]
else:
order_line = self.env['sale.order.line']
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
if set_qty:
quantity = set_qty
elif add_qty is not None:
if order_line:
quantity = order_line.product_uom_qty + (add_qty or 0)
else:
quantity = add_qty or 0
if quantity > 0:
quantity, warning = self._verify_updated_quantity(
order_line,
product_id,
quantity,
**kwargs,
)
else:
# If the line will be removed anyway, there is no need to verify
# the requested quantity update.
warning = ''
order_line = self._cart_update_order_line(product_id, quantity, order_line, **kwargs)
if (
order_line
and order_line.price_unit == 0
and self.website_id.prevent_zero_price_sale
and product.detailed_type not in self.env['product.template']._get_product_types_allow_zero_price()
):
raise UserError(_(
"The given product does not have a price therefore it cannot be added to cart.",
))
return {
'line_id': order_line.id,
'quantity': quantity,
'option_ids': list(set(order_line.option_line_ids.filtered(lambda l: l.order_id == order_line.order_id).ids)),
'warning': warning,
}
def _cart_find_product_line(self, product_id, 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()
SaleOrderLine = self.env['sale.order.line']
if not self.order_line:
return SaleOrderLine
product = self.env['product.product'].browse(product_id)
if not line_id and (
product.product_tmpl_id.has_dynamic_attributes()
or product.product_tmpl_id._has_no_variant_attributes()
):
return SaleOrderLine
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 SaleOrderLine.search(domain)
# hook to be overridden
def _verify_updated_quantity(self, order_line, product_id, new_qty, **kwargs):
return new_qty, ''
def _prepare_order_line_values(
self, product_id, quantity, linked_line_id=False,
no_variant_attribute_values=None, product_custom_attribute_values=None,
**kwargs
):
self.ensure_one()
product = self.env['product.product'].browse(product_id)
no_variant_attribute_values = 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."))
values = {
'product_id': product.id,
'product_uom_qty': quantity,
'order_id': self.id,
'linked_line_id': linked_line_id,
}
# 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,
})
if no_variant_attribute_values:
values['product_no_variant_attribute_value_ids'] = [
fields.Command.set([int(attribute['value']) for attribute in no_variant_attribute_values])
]
# add is_custom attribute values that were not received
custom_values = 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': '',
})
if custom_values:
values['product_custom_attribute_value_ids'] = [
fields.Command.create({
'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
]
return values
def _prepare_order_line_update_values(
self, order_line, quantity, linked_line_id=False, **kwargs
):
self.ensure_one()
values = {}
if quantity != order_line.product_uom_qty:
values['product_uom_qty'] = quantity
if linked_line_id and linked_line_id != order_line.linked_line_id.id:
values['linked_line_id'] = linked_line_id
return values
# hook to be overridden
def _update_cart_line_values(self, order_line, update_values):
self.ensure_one()
order_line.write(update_values)
def _cart_accessories(self):
""" Suggest accessories based on 'Accessory Products' of products in cart """
products = self.website_order_line.product_id
all_accessory_products = self.env['product.product']
for line in self.website_order_line.filtered('product_id'):
accessory_products = line.product_id.product_tmpl_id._get_website_accessory_product()
if accessory_products:
# Do not read ptavs if there is no accessory products to filter
combination = line.product_id.product_template_attribute_value_ids + line.product_no_variant_attribute_value_ids
all_accessory_products |= accessory_products.filtered(
lambda product:
product not in products and
(not product.company_id or product.company_id == line.company_id) and
product._is_variant_possible(parent_combination=combination)
and (
not self.website_id.prevent_zero_price_sale
or product._get_contextual_price()
)
)
return random.sample(all_accessory_products, len(all_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_email_layout_xmlid': 'mail.mail_notification_layout_with_responsible_signature',
'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 _notify_get_recipients_groups(self, msg_vals=None):
""" In case of cart recovery email, update link to redirect directly
to the cart (like ``mail_template_sale_cart_recovery`` template). """
groups = super(SaleOrder, self)._notify_get_recipients_groups(msg_vals=msg_vals)
if not self:
return groups
self.ensure_one()
customer_portal_group = next(group for group in groups if group[0] == 'portal_customer')
if customer_portal_group:
access_opt = customer_portal_group[2].setdefault('button_access', {})
if self._context.get('website_sale_send_recovery_email'):
access_opt['title'] = _('Resume Order')
access_opt['url'] = '%s/shop/cart?access_token=%s' % (self.get_base_url(), self.access_token)
return groups
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
def _get_shop_warning(self, clear=True):
self.ensure_one()
warn = self.shop_warning
if clear:
self.shop_warning = ''
return warn
def _is_reorder_allowed(self):
self.ensure_one()
return self.state in {'sale', 'done'} and any(line._is_reorder_allowed() for line in self.order_line if not line.display_type)
def _filter_can_send_abandoned_cart_mail(self):
self.website_id.ensure_one()
abandoned_datetime = datetime.utcnow() - relativedelta(hours=self.website_id.cart_abandoned_delay)
sales_after_abandoned_date = self.env['sale.order'].search([
('state', '=', 'sale'),
('partner_id', 'in', self.partner_id.ids),
('create_date', '>=', abandoned_datetime),
('website_id', '=', self.website_id.id),
])
latest_create_date_per_partner = dict()
for sale in self:
if sale.partner_id not in latest_create_date_per_partner:
latest_create_date_per_partner[sale.partner_id] = sale.create_date
else:
latest_create_date_per_partner[sale.partner_id] = max(latest_create_date_per_partner[sale.partner_id], sale.create_date)
has_later_sale_order = dict()
for sale in sales_after_abandoned_date:
if has_later_sale_order.get(sale.partner_id, False):
continue
has_later_sale_order[sale.partner_id] = latest_create_date_per_partner[sale.partner_id] <= sale.date_order
# Customer needs to be signed in otherwise the mail address is not known.
# We therefore consider only sales with a known mail address.
# If a payment processing error occurred when the customer tried to complete their checkout,
# then the email won't be sent.
# If all the products in the checkout are free, and the customer does not visit the shipping page to add a
# shipping fee or the shipping fee is also free, then the email won't be sent.
# If a potential customer creates one or more abandoned sale order and then completes a sale order before
# the recovery email gets sent, then the email won't be sent.
return self.filtered(
lambda abandoned_sale_order:
abandoned_sale_order.partner_id.email
and not any(transaction.state == 'error' for transaction in abandoned_sale_order.transaction_ids)
and any(not float_is_zero(line.price_unit, precision_rounding=line.currency_id.rounding) for line in abandoned_sale_order.order_line)
and not has_later_sale_order.get(abandoned_sale_order.partner_id, False)
)
def action_preview_sale_order(self):
action = super().action_preview_sale_order()
if action['url'].startswith('/'):
# URL should always be relative, safety check
action['url'] = f'/@{action["url"]}'
return action
def _get_website_sale_extra_values(self):
""" Hook to provide additional rendering values for the cart template.
:return: additional values to be passed to the cart template
:rtype: dict
"""
self.ensure_one()
return {}
def _get_lang(self):
res = super()._get_lang()
if self.website_id and request and request.is_frontend:
# Use request lang as cart lang if request comes from frontend
return request.env.lang
return res

View file

@ -0,0 +1,72 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models, fields, _
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
linked_line_id = fields.Many2one('sale.order.line', string='Linked Order Line', domain="[('order_id', '=', order_id)]", ondelete='cascade', copy=False, index=True)
option_line_ids = fields.One2many('sale.order.line', 'linked_line_id', string='Options Linked')
name_short = fields.Char(compute="_compute_name_short")
shop_warning = fields.Char('Warning')
#=== COMPUTE METHODS ===#
@api.depends('linked_line_id', 'option_line_ids')
def _compute_name(self):
"""Override to add the compute dependency.
The custom name logic can be found below in _get_sale_order_line_multiline_description_sale.
"""
super()._compute_name()
@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
#=== BUSINESS METHODS ===#
def _get_sale_order_line_multiline_description_sale(self):
description = super()._get_sale_order_line_multiline_description_sale()
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
def get_description_following_lines(self):
return self.name.splitlines()[1:]
def _get_order_date(self):
self.ensure_one()
if self.order_id.website_id and self.state == 'draft':
# cart prices must always be computed based on the current time, not on the order
# creation date.
return fields.Datetime.now()
return super()._get_order_date()
def _get_shop_warning(self, clear=True):
self.ensure_one()
warn = self.shop_warning
if clear:
self.shop_warning = ''
return warn
def _show_in_cart(self):
self.ensure_one()
return not bool(self.display_type)
def _is_reorder_allowed(self):
self.ensure_one()
return self.product_id._is_add_to_cart_allowed()

View file

@ -0,0 +1,581 @@
# -*- 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.osv import expression
from odoo.addons.http_routing.models.ir_http import url_for
_logger = logging.getLogger(__name__)
class Website(models.Model):
_inherit = 'website'
def _default_salesteam_id(self):
team = self.env.ref('sales_team.salesteam_website_sales', False)
if team and team.active:
return team.id
else:
return None
salesperson_id = fields.Many2one('res.users', string='Salesperson')
salesteam_id = fields.Many2one('crm.team',
string='Sales Team', ondelete="set null",
default=_default_salesteam_id)
pricelist_id = fields.Many2one(
'product.pricelist',
compute='_compute_pricelist_id',
string='Default Pricelist')
currency_id = fields.Many2one(
related='pricelist_id.currency_id', depends=(), related_sudo=False,
string='Default Currency', readonly=False)
pricelist_ids = fields.One2many('product.pricelist', compute="_compute_pricelist_ids",
string='Price list available for this Ecommerce/Website')
# Technical: Used to recompute pricelist_ids
all_pricelist_ids = fields.One2many('product.pricelist', 'website_id', string='All pricelists')
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(string="Abandoned Delay", default=10.0)
send_abandoned_cart_email = fields.Boolean(string="Send email to customers who abandoned their cart.")
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")
@staticmethod
def _get_product_sort_mapping():
return [
('website_sequence asc', _('Featured')),
('create_date desc', _('Newest Arrivals')),
('name asc', _('Name (A-Z)')),
('list_price asc', _('Price - Low to High')),
('list_price desc', _('Price - High to Low')),
]
shop_default_sort = fields.Selection(selection='_get_product_sort_mapping', default='website_sequence asc', required=True)
shop_extra_field_ids = fields.One2many('website.sale.extra.field', 'website_id', string='E-Commerce Extra Fields')
add_to_cart_action = fields.Selection(
selection=[
('stay', 'Stay on Product Page'),
('go_to_cart', 'Go to cart'),
],
default='stay')
auth_signup_uninvited = fields.Selection(default='b2c')
account_on_checkout = fields.Selection(
string="Customer Accounts",
selection=[
('optional', 'Optional'),
('disabled', 'Disabled (buy as guest)'),
('mandatory', 'Mandatory (no guest checkout)'),
],
default='optional')
product_page_image_layout = fields.Selection([
('carousel', 'Carousel'),
('grid', 'Grid'),
], default='carousel', required=True,
)
product_page_grid_columns = fields.Integer(default=2)
product_page_image_width = fields.Selection([
('none', 'Hidden'),
('50_pc', '50 %'),
('66_pc', '66 %'),
('100_pc', '100 %'),
], default='50_pc', required=True,
)
product_page_image_spacing = fields.Selection([
('none', 'None'),
('small', 'Small'),
('medium', 'Medium'),
('big', 'Big'),
], default='small', required=True,
)
prevent_zero_price_sale = fields.Boolean(string="Hide 'Add To Cart' when price = 0")
prevent_zero_price_sale_text = fields.Char(string="Text to show instead of price", translate=True,
default="Not Available For Sale")
contact_us_button_url = fields.Char(string="Contact Us Button URL", translate=True, default="/contactus")
enabled_portal_reorder_button = fields.Boolean(string="Re-order From Portal")
@api.depends('all_pricelist_ids')
def _compute_pricelist_ids(self):
for website in self:
website = website.with_company(website.company_id)
ProductPricelist = website.env['product.pricelist'] # with correct company in env
website.pricelist_ids = ProductPricelist.sudo().search(
ProductPricelist._get_website_pricelists_domain(website)
)
def _compute_pricelist_id(self):
for website in self:
website.pricelist_id = website.get_current_pricelist()
# NOTE VFE: moving this computation doesn't change much
# Because most of it must still be computed for the pricelist choice template (`pricelist_list`)
# Therefore, avoiding all pricelist computation is impossible in fact...
# This method is cached, must not return records! See also #8795
@tools.ormcache(
'country_code', 'show_visible',
'current_pl_id', 'website_pricelist_ids',
'partner_pl_id', 'order_pl_id',
)
def _get_pl_partner_order(
self, country_code, show_visible, current_pl_id, website_pricelist_ids,
partner_pl_id=False, order_pl_id=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 current_pl_id: The current pricelist used on the website
(If not selectable but currently used anyway, e.g. pricelist with promo code)
:param tuple website_pricelist_ids: List of ids of pricelists available for this website
:param int partner_pl_id: the partner pricelist
:param int order_pl_id: the current cart pricelist
:returns: list of product.pricelist ids
:rtype: list
"""
self.ensure_one()
pricelists = self.env['product.pricelist']
if show_visible:
# Only show selectable or currently used pricelist (cart or session)
check_pricelist = lambda pl: pl.selectable or pl.id in (current_pl_id, order_pl_id)
else:
check_pricelist = lambda _pl: True
# 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_id` parameter instead to invalidate the cache.
# If there is a GeoIP country, find a pricelist for it
if country_code:
pricelists |= self.env['res.country.group'].search(
[('country_ids.code', '=', country_code)]
).pricelist_ids.filtered(
lambda pl: pl._is_available_on_website(self) and check_pricelist(pl)
)
# no GeoIP or no pricelist for this country
if not pricelists:
pricelists = pricelists.browse(website_pricelist_ids).filtered(check_pricelist)
# if logged in, add partner pl (which is `property_product_pricelist`, might not be website compliant)
if not self.env.user._is_public():
# keep partner_pricelist only if website compliant
partner_pricelist = pricelists.browse(partner_pl_id).filtered(
lambda pl:
pl._is_available_on_website(self)
and check_pricelist(pl)
and pl._is_available_in_country(country_code)
)
pricelists |= partner_pricelist
# This method is cached, must not return records! See also #8795
# sudo is needed to ensure no records rules are applied during the sorted call,
# we only want to reorder the records on hand, not filter them.
return pricelists.sudo().sorted().ids
def get_pricelist_available(self, 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
"""
self.ensure_one()
country_code = self._get_geoip_country_code()
website = self.with_company(self.company_id)
partner_sudo = website.env.user.partner_id
is_user_public = self.env.user._is_public()
if not is_user_public:
last_order_pricelist = partner_sudo.last_website_so_id.pricelist_id
partner_pricelist = partner_sudo.property_product_pricelist
else: # public user: do not compute partner pl (not used)
last_order_pricelist = self.env['product.pricelist']
partner_pricelist = self.env['product.pricelist']
website_pricelists = website.sudo().pricelist_ids
current_pricelist_id = self._get_cached_pricelist_id()
pricelist_ids = website._get_pl_partner_order(
country_code,
show_visible,
current_pl_id=current_pricelist_id,
website_pricelist_ids=tuple(website_pricelists.ids),
partner_pl_id=partner_pricelist.id,
order_pl_id=last_order_pricelist.id)
return self.env['product.pricelist'].browse(pricelist_ids)
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_geoip_country_code(self):
return request and request.geoip.get('country_code') or False
def _get_cached_pricelist_id(self):
return request and request.session.get('website_sale_current_pl') or None
def get_current_pricelist(self):
"""
:returns: The current pricelist record
"""
self = self.with_company(self.company_id)
ProductPricelist = self.env['product.pricelist']
pricelist = ProductPricelist
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
pricelist = ProductPricelist.browse(request.session['website_sale_current_pl']).exists().sudo()
country_code = self._get_geoip_country_code()
if not pricelist or not pricelist._is_available_on_website(self) or not pricelist._is_available_in_country(country_code):
request.session.pop('website_sale_current_pl')
pricelist = ProductPricelist
if not pricelist:
partner_sudo = self.env.user.partner_id
# If the user has a saved cart, it take the pricelist of this last unconfirmed cart
pricelist = partner_sudo.last_website_so_id.pricelist_id
if not pricelist:
# The pricelist of the user set on its partner form.
# If the user is not signed in, it's the public user pricelist
pricelist = partner_sudo.property_product_pricelist
# 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()
if available_pricelists and pricelist 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.
pricelist = available_pricelists[0]
if not pricelist:
_logger.error(
'Failed to find pricelist for partner "%s" (id %s)',
partner_sudo.name, partner_sudo.id,
)
return pricelist
def sale_product_domain(self):
website_domain = self.get_current_website().website_domain()
if not self.env.user._is_internal():
website_domain = expression.AND([website_domain, [('is_published', '=', True)]])
return expression.AND([self._product_domain(), website_domain])
def _product_domain(self):
return [('sale_ok', '=', True)]
def sale_get_order(self, force_create=False, update_pricelist=False):
""" Return the current sales order after mofications specified by params.
:param bool force_create: Create sales order if not already existing
:param bool update_pricelist: Force to recompute all the lines from sales order to adapt the price with the current pricelist.
:returns: record for the current sales order (might be empty)
:rtype: `sale.order` recordset
"""
self.ensure_one()
self = self.with_company(self.company_id)
SaleOrder = self.env['sale.order'].sudo()
sale_order_id = request.session.get('sale_order_id')
if sale_order_id:
sale_order_sudo = SaleOrder.browse(sale_order_id).exists()
elif self.env.user and not self.env.user._is_public():
sale_order_sudo = self.env.user.partner_id.last_website_so_id
if sale_order_sudo:
available_pricelists = self.get_pricelist_available()
if sale_order_sudo.pricelist_id not in available_pricelists:
# Do not reload the cart of this user last visit
# if the cart uses a pricelist no longer available.
sale_order_sudo = SaleOrder
else:
# Do not reload the cart of this user last visit
# if the Fiscal Position has changed.
fpos = sale_order_sudo.env['account.fiscal.position'].with_company(
sale_order_sudo.company_id
)._get_fiscal_position(
sale_order_sudo.partner_id,
delivery=sale_order_sudo.partner_shipping_id
)
if fpos.id != sale_order_sudo.fiscal_position_id.id:
sale_order_sudo = SaleOrder
else:
sale_order_sudo = SaleOrder
# Ignore the current order if a payment has been initiated. We don't want to retrieve the
# cart and allow the user to update it when the payment is about to confirm it.
if sale_order_sudo and sale_order_sudo.get_portal_last_transaction().state in (
'pending', 'authorized', 'done'
):
sale_order_sudo = None
if not (sale_order_sudo or force_create):
# Do not create a SO record unless needed
if request.session.get('sale_order_id'):
request.session.pop('sale_order_id')
request.session.pop('website_sale_cart_quantity', None)
return self.env['sale.order']
# Only set when neeeded
pricelist_id = False
partner_sudo = self.env.user.partner_id
# cart creation was requested
if not sale_order_sudo:
so_data = self._prepare_sale_order_values(partner_sudo)
sale_order_sudo = SaleOrder.with_user(SUPERUSER_ID).create(so_data)
request.session['sale_order_id'] = sale_order_sudo.id
request.session['website_sale_cart_quantity'] = sale_order_sudo.cart_quantity
# The order was created with SUPERUSER_ID, revert back to request user.
sale_order_sudo = sale_order_sudo.with_user(self.env.user).sudo()
return sale_order_sudo
# Existing Cart:
# * For logged user
# * In session, for specified partner
# case when user emptied the cart
if not request.session.get('sale_order_id'):
request.session['sale_order_id'] = sale_order_sudo.id
request.session['website_sale_cart_quantity'] = sale_order_sudo.cart_quantity
# check for change of partner_id ie after signup
if sale_order_sudo.partner_id.id != partner_sudo.id and request.website.partner_id.id != partner_sudo.id:
previous_fiscal_position = sale_order_sudo.fiscal_position_id
previous_pricelist = sale_order_sudo.pricelist_id
# Reset the session pricelist according to logged partner pl
request.session.pop('website_sale_current_pl', None)
pricelist_id = self._get_current_pricelist_id(partner_sudo)
request.session['website_sale_current_pl'] = pricelist_id
# change the partner, and trigger the computes (fpos)
sale_order_sudo.write({
'partner_id': partner_sudo.id,
'partner_invoice_id': partner_sudo.id,
'payment_term_id': self.sale_get_payment_term(partner_sudo),
# Must be specified to ensure it is not recomputed when it shouldn't
'pricelist_id': pricelist_id,
})
if sale_order_sudo.fiscal_position_id != previous_fiscal_position:
sale_order_sudo.order_line._compute_tax_id()
if sale_order_sudo.pricelist_id != previous_pricelist:
update_pricelist = True
elif update_pricelist:
# Only compute pricelist if needed
pricelist_id = self._get_current_pricelist_id(partner_sudo)
# update the pricelist
if update_pricelist:
request.session['website_sale_current_pl'] = pricelist_id
sale_order_sudo.write({'pricelist_id': pricelist_id})
sale_order_sudo._recompute_prices()
return sale_order_sudo
def _prepare_sale_order_values(self, partner_sudo):
self.ensure_one()
addr = partner_sudo.address_get(['delivery'])
if not request.website.is_public_user():
last_sale_order = self.env['sale.order'].sudo().search(
[('partner_id', '=', partner_sudo.id), ('website_id', '=', self.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
affiliate_id = request.session.get('affiliate_id')
salesperson_user_sudo = self.env['res.users'].sudo().browse(affiliate_id).exists()
if not salesperson_user_sudo:
salesperson_user_sudo = self.salesperson_id or partner_sudo.parent_id.user_id or partner_sudo.user_id
pricelist_id = self._get_current_pricelist_id(partner_sudo)
fiscal_position_id = self._get_current_fiscal_position_id(partner_sudo)
values = {
'company_id': self.company_id.id,
'fiscal_position_id': fiscal_position_id,
'partner_id': partner_sudo.id,
'partner_invoice_id': partner_sudo.id,
'partner_shipping_id': addr['delivery'],
'pricelist_id': pricelist_id,
'payment_term_id': self.sale_get_payment_term(partner_sudo),
'team_id': self.salesteam_id.id or partner_sudo.parent_id.team_id.id or partner_sudo.team_id.id,
'user_id': salesperson_user_sudo.id,
'website_id': self.id,
}
return values
@api.model
def sale_get_payment_term(self, partner):
pt = self.env.ref('account.account_payment_term_immediate', False)
if pt:
pt = pt.sudo()
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 _get_current_pricelist_id(self, partner_sudo):
return self.get_current_pricelist().id \
or partner_sudo.property_product_pricelist.id
@api.model
def _get_current_fiscal_position_id(self, partner_sudo):
AccountFiscalPosition = self.env['account.fiscal.position'].sudo()
fpos = AccountFiscalPosition
# If the current user is the website public user, the fiscal position
# is computed according to geolocation.
if request and hasattr(request, 'website') and request.website.partner_id.id == partner_sudo.id:
country_code = request.geoip.get('country_code')
if country_code:
country_id = self.env['res.country'].search([('code', '=', country_code)], limit=1).id
fpos = AccountFiscalPosition._get_fpos_by_region(country_id)
if not fpos:
fpos = AccountFiscalPosition._get_fiscal_position(partner_sudo)
return fpos.id
def sale_reset(self):
request.session.pop('sale_order_id', None)
request.session.pop('website_sale_current_pl', None)
request.session.pop('website_sale_cart_quantity', None)
@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 _search_get_details(self, search_type, order, options):
result = super()._search_get_details(search_type, order, options)
if search_type in ['products', 'product_categories_only', 'all']:
result.append(self.env['product.public.category']._search_get_detail(self, order, options))
if search_type in ['products', 'products_only', 'all']:
result.append(self.env['product.template']._search_get_detail(self, order, options))
return result
def _get_product_page_proportions(self):
"""
Returns the number of columns (css) that both the images and the product details should take.
"""
self.ensure_one()
return {
'none': (0, 12),
'50_pc': (6, 6),
'66_pc': (8, 4),
'100_pc': (12, 12),
}.get(self.product_page_image_width)
def _get_product_page_grid_image_classes(self):
spacing_map = {
'none': 'p-0',
'small': 'p-2',
'medium': 'p-3',
'big': 'p-4',
}
columns_map = {
1: 'col-12',
2: 'col-6',
3: 'col-4',
}
return spacing_map.get(self.product_page_image_spacing) + ' ' +\
columns_map.get(self.product_page_grid_columns)
@api.model
def _send_abandoned_cart_email(self):
for website in self.search([]):
if not website.send_abandoned_cart_email:
continue
all_abandoned_carts = self.env['sale.order'].search([
('is_abandoned_cart', '=', True),
('cart_recovery_email_sent', '=', False),
('website_id', '=', website.id),
])
if not all_abandoned_carts:
continue
abandoned_carts = all_abandoned_carts._filter_can_send_abandoned_cart_mail()
# Mark abandoned carts that failed the filter as sent to avoid rechecking them again and again.
(all_abandoned_carts - abandoned_carts).cart_recovery_email_sent = True
for sale_order in abandoned_carts:
template = self.env.ref('website_sale.mail_template_sale_cart_recovery')
# fallback email_vals in case partner_to and email_to were emptied
email_vals = {} if template.email_to or template.partner_to else {
'email_to': sale_order.partner_id.email_formatted
}
template.send_mail(sale_order.id, email_values=email_vals)
sale_order.cart_recovery_email_sent = True
def _display_partner_b2b_fields(self):
""" This method is to be inherited by localizations and return
True if localization should always displayed b2b fields """
self.ensure_one()
return self.is_view_active('website_sale.address_b2b')
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'])],
required=True,
ondelete='cascade'
)
label = fields.Char(related='field_id.field_description')
name = fields.Char(related='field_id.name')

View file

@ -0,0 +1,10 @@
from odoo import fields, models
class WebsiteBaseUnit(models.Model):
_name = "website.base.unit"
_description = "Unit of Measure for price per unit on eCommerce products."
_order = "name"
name = fields.Char(help="Define a custom unit to display in the price per unit of measure field.",
required=True, translate=True)

View file

@ -0,0 +1,181 @@
# -*- 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()
return pricelist.currency_id
def _get_hardcoded_sample(self, model):
samples = super()._get_hardcoded_sample(model)
if model._name == 'product.product':
data = [{
'image_512': b'/product/static/img/product_chair.jpg',
'display_name': _('Chair'),
'description_sale': _('Sit comfortably'),
}, {
'image_512': b'/product/static/img/product_lamp.png',
'display_name': _('Lamp'),
'description_sale': _('Lightbulb sold separately'),
}, {
'image_512': b'/product/static/img/product_product_20-image.png',
'display_name': _('Whiteboard'),
'description_sale': _('With three feet'),
}, {
'image_512': b'/product/static/img/product_product_27-image.jpg',
'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.jpg',
'display_name': _('Bin'),
'description_sale': _('Pedal-based opening system'),
}]
merged = []
for index in range(0, max(len(samples), len(data))):
merged.append({**samples[index % len(samples)], **data[index % len(data)]})
# merge definitions
samples = merged
return samples
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())
if records.env.context.get('add2cart_rerender'):
res_product['_add2cart_rerender'] = True
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.website_domain(),
[('company_id', 'in', [False, website.company_id.id])],
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, add2cart_rerender=True).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._get_website_accessory_product().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_alternative_products(self, website, limit, domain, context):
products = self.env['product.product']
current_id = context.get('product_template_id')
if not current_id:
return products
current_template = self.env['product.template'].browse(int(current_id))
if current_template.exists():
excluded_products = website.sale_get_order().order_line.product_id
excluded_products |= current_template.product_variant_ids
included_products = current_template.alternative_product_ids.product_variant_ids
products = included_products - excluded_products
if website.prevent_zero_price_sale:
products = products.filtered(lambda p: p._get_contextual_price())
if products:
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

View file

@ -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', ondelete='cascade', readonly=True, index='btree_not_null')
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}
self._add_tracking(domain, website_track_values)