mirror of
https://github.com/bringout/oca-ocb-sale.git
synced 2026-04-27 14:32:04 +02:00
Initial commit: Sale packages
This commit is contained in:
commit
14e3d26998
6469 changed files with 2479670 additions and 0 deletions
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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>
|
||||
'''),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
|
|
@ -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."""))
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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
|
||||
|
|
@ -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')
|
||||
|
|
@ -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')
|
||||
|
|
@ -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())
|
||||
|
|
@ -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)
|
||||
|
|
@ -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"),
|
||||
}
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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
|
||||
|
|
@ -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()
|
||||
|
|
@ -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')
|
||||
|
|
@ -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)
|
||||
|
|
@ -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
|
||||
|
|
@ -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)
|
||||
Loading…
Add table
Add a link
Reference in a new issue