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