# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import json import logging from werkzeug.exceptions import Forbidden, NotFound from werkzeug.urls import url_decode, url_encode, url_parse from odoo import fields, http, SUPERUSER_ID, tools, _ from odoo.fields import Command from odoo.http import request from odoo.addons.base.models.ir_qweb_fields import nl2br from odoo.addons.http_routing.models.ir_http import slug from odoo.addons.payment.controllers import portal as payment_portal from odoo.addons.payment.controllers.post_processing import PaymentPostProcessing from odoo.addons.website.controllers.main import QueryURL from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.exceptions import AccessError, MissingError, ValidationError from odoo.addons.portal.controllers.portal import _build_url_w_params from odoo.addons.website.controllers import main from odoo.addons.website.controllers.form import WebsiteForm from odoo.osv import expression from odoo.tools.json import scriptsafe as json_scriptsafe _logger = logging.getLogger(__name__) class TableCompute(object): def __init__(self): self.table = {} def _check_place(self, posx, posy, sizex, sizey, ppr): res = True for y in range(sizey): for x in range(sizex): if posx + x >= ppr: res = False break row = self.table.setdefault(posy + y, {}) if row.setdefault(posx + x) is not None: res = False break for x in range(ppr): self.table[posy + y].setdefault(x, None) return res def process(self, products, ppg=20, ppr=4): # Compute products positions on the grid minpos = 0 index = 0 maxy = 0 x = 0 for p in products: x = min(max(p.website_size_x, 1), ppr) y = min(max(p.website_size_y, 1), ppr) if index >= ppg: x = y = 1 pos = minpos while not self._check_place(pos % ppr, pos // ppr, x, y, ppr): pos += 1 # if 21st products (index 20) and the last line is full (ppr products in it), break # (pos + 1.0) / ppr is the line where the product would be inserted # maxy is the number of existing lines # + 1.0 is because pos begins at 0, thus pos 20 is actually the 21st block # and to force python to not round the division operation if index >= ppg and ((pos + 1.0) // ppr) > maxy: break if x == 1 and y == 1: # simple heuristic for CPU optimization minpos = pos // ppr for y2 in range(y): for x2 in range(x): self.table[(pos // ppr) + y2][(pos % ppr) + x2] = False self.table[pos // ppr][pos % ppr] = { 'product': p, 'x': x, 'y': y, 'ribbon': p._get_website_ribbon(), } if index <= ppg: maxy = max(maxy, y + (pos // ppr)) index += 1 # Format table according to HTML needs rows = sorted(self.table.items()) rows = [r[1] for r in rows] for col in range(len(rows)): cols = sorted(rows[col].items()) x += len(cols) rows[col] = [r[1] for r in cols if r[1]] return rows class WebsiteSaleForm(WebsiteForm): @http.route('/website/form/shop.sale.order', type='http', auth="public", methods=['POST'], website=True) def website_form_saleorder(self, **kwargs): model_record = request.env.ref('sale.model_sale_order') try: data = self.extract_data(model_record, kwargs) except ValidationError as e: return json.dumps({'error_fields': e.args[0]}) order = request.website.sale_get_order() if data['record']: order.write(data['record']) if data['custom']: values = { 'body': nl2br(data['custom']), 'model': 'sale.order', 'message_type': 'comment', 'res_id': order.id, } request.env['mail.message'].with_user(SUPERUSER_ID).create(values) if data['attachments']: self.insert_attachment(model_record, order.id, data['attachments']) return json.dumps({'id': order.id}) class Website(main.Website): @http.route() def autocomplete(self, search_type=None, term=None, order=None, limit=5, max_nb_chars=999, options=None): options = options or {} if 'display_currency' not in options: options['display_currency'] = request.website.get_current_pricelist().currency_id return super().autocomplete(search_type, term, order, limit, max_nb_chars, options) @http.route() def get_switchable_related_views(self, key): views = super(Website, self).get_switchable_related_views(key) if key == 'website_sale.product': if not request.env.user.has_group('product.group_product_variant'): view_product_variants = request.website.viewref('website_sale.product_variants') views = [v for v in views if v['id'] != view_product_variants.id] return views @http.route() def toggle_switchable_view(self, view_key): super(Website, self).toggle_switchable_view(view_key) if view_key in ('website_sale.products_list_view', 'website_sale.add_grid_or_list_option'): request.session.pop('website_sale_shop_layout_mode', None) @http.route() def get_current_currency(self, **kwargs): return { 'id': request.website.currency_id.id, 'symbol': request.website.currency_id.symbol, 'position': request.website.currency_id.position, } class WebsiteSale(http.Controller): def _get_pricelist_context(self): pricelist_context = dict(request.env.context) pricelist = False if not pricelist_context.get('pricelist'): pricelist = request.website.get_current_pricelist() pricelist_context['pricelist'] = pricelist.id else: pricelist = request.env['product.pricelist'].browse(pricelist_context['pricelist']) return pricelist_context, pricelist def _get_search_order(self, post): # OrderBy will be parsed in orm and so no direct sql injection # id is added to be sure that order is a unique sort key order = post.get('order') or request.env['website'].get_current_website().shop_default_sort return 'is_published desc, %s, id desc' % order def _get_search_domain(self, search, category, attrib_values, search_in_description=True): domains = [request.website.sale_product_domain()] if search: for srch in search.split(" "): subdomains = [ [('name', 'ilike', srch)], [('product_variant_ids.default_code', 'ilike', srch)] ] if search_in_description: subdomains.append([('website_description', 'ilike', srch)]) subdomains.append([('description_sale', 'ilike', srch)]) domains.append(expression.OR(subdomains)) if category: domains.append([('public_categ_ids', 'child_of', int(category))]) 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)]) return expression.AND(domains) def sitemap_shop(env, rule, qs): if not qs or qs.lower() in '/shop': yield {'loc': '/shop'} Category = env['product.public.category'] dom = sitemap_qs2dom(qs, '/shop/category', Category._rec_name) dom += env['website'].get_current_website().website_domain() for cat in Category.search(dom): loc = '/shop/category/%s' % slug(cat) if not qs or qs.lower() in loc: yield {'loc': loc} @http.route([ '''/shop''', '''/shop/page/''', '''/shop/category/''', '''/shop/category//page/''' ], type='http', auth="public", website=True, sitemap=sitemap_shop) def shop(self, page=0, category=None, search='', min_price=0.0, max_price=0.0, ppg=False, **post): add_qty = int(post.get('add_qty', 1)) try: min_price = float(min_price) except ValueError: min_price = 0 try: max_price = float(max_price) except ValueError: max_price = 0 Category = request.env['product.public.category'] if category: category = Category.search([('id', '=', int(category))], limit=1) if not category or not category.can_access_from_current_website(): raise NotFound() else: category = Category if ppg: try: ppg = int(ppg) post['ppg'] = ppg except ValueError: ppg = False if not ppg: ppg = request.env['website'].get_current_website().shop_ppg or 20 ppr = request.env['website'].get_current_website().shop_ppr or 4 attrib_list = request.httprequest.args.getlist('attrib') attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v] attributes_ids = {v[0] for v in attrib_values} attrib_set = {v[1] for v in attrib_values} keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, min_price=min_price, max_price=max_price, order=post.get('order')) pricelist_context, pricelist = self._get_pricelist_context() request.context = dict(request.context, pricelist=pricelist.id, partner=request.env.user.partner_id) filter_by_price_enabled = request.website.is_view_active('website_sale.filter_products_price') if filter_by_price_enabled: company_currency = request.website.company_id.currency_id conversion_rate = request.env['res.currency']._get_conversion_rate(company_currency, pricelist.currency_id, request.website.company_id, fields.Date.today()) else: conversion_rate = 1 url = "/shop" if search: post["search"] = search if attrib_list: post['attrib'] = attrib_list options = { 'displayDescription': True, 'displayDetail': True, 'displayExtraDetail': True, 'displayExtraLink': True, 'displayImage': True, 'allowFuzzy': not post.get('noFuzzy'), 'category': str(category.id) if category else None, 'min_price': min_price / conversion_rate, 'max_price': max_price / conversion_rate, 'attrib_values': attrib_values, 'display_currency': pricelist.currency_id, } # No limit because attributes are obtained from complete product list product_count, details, fuzzy_search_term = request.website._search_with_fuzzy("products_only", search, limit=None, order=self._get_search_order(post), options=options) search_product = details[0].get('results', request.env['product.template']).with_context(bin_size=True) filter_by_price_enabled = request.website.is_view_active('website_sale.filter_products_price') if filter_by_price_enabled: # TODO Find an alternative way to obtain the domain through the search metadata. Product = request.env['product.template'].with_context(bin_size=True) domain = self._get_search_domain(search, category, attrib_values) # This is ~4 times more efficient than a search for the cheapest and most expensive products from_clause, where_clause, where_params = Product._where_calc(domain).get_sql() query = f""" SELECT COALESCE(MIN(list_price), 0) * {conversion_rate}, COALESCE(MAX(list_price), 0) * {conversion_rate} FROM {from_clause} WHERE {where_clause} """ request.env.cr.execute(query, where_params) available_min_price, available_max_price = request.env.cr.fetchone() if min_price or max_price: # The if/else condition in the min_price / max_price value assignment # tackles the case where we switch to a list of products with different # available min / max prices than the ones set in the previous page. # In order to have logical results and not yield empty product lists, the # price filter is set to their respective available prices when the specified # min exceeds the max, and / or the specified max is lower than the available min. if min_price: min_price = min_price if min_price <= available_max_price else available_min_price post['min_price'] = min_price if max_price: max_price = max_price if max_price >= available_min_price else available_max_price post['max_price'] = max_price website_domain = request.website.website_domain() categs_domain = [('parent_id', '=', False)] + website_domain if search: search_categories = Category.search([('product_tmpl_ids', 'in', search_product.ids)] + website_domain).parents_and_self categs_domain.append(('id', 'in', search_categories.ids)) else: search_categories = Category categs = Category.search(categs_domain) if category: url = "/shop/category/%s" % slug(category) pager = request.website.pager(url=url, total=product_count, page=page, step=ppg, scope=7, url_args=post) offset = pager['offset'] products = search_product[offset:offset + ppg] ProductAttribute = request.env['product.attribute'] if products: # get all products without limit attributes = ProductAttribute.search([ ('product_tmpl_ids', 'in', search_product.ids), ('visibility', '=', 'visible'), ]) else: attributes = ProductAttribute.browse(attributes_ids) layout_mode = request.session.get('website_sale_shop_layout_mode') if not layout_mode: if request.website.viewref('website_sale.products_list_view').active: layout_mode = 'list' else: layout_mode = 'grid' values = { 'search': fuzzy_search_term or search, 'original_search': fuzzy_search_term and search, 'category': category, 'attrib_values': attrib_values, 'attrib_set': attrib_set, 'pager': pager, 'pricelist': pricelist, 'add_qty': add_qty, 'products': products, 'search_count': product_count, # common for all searchbox 'bins': TableCompute().process(products, ppg, ppr), 'ppg': ppg, 'ppr': ppr, 'categories': categs, 'attributes': attributes, 'keep': keep, 'search_categories_ids': search_categories.ids, 'layout_mode': layout_mode, } if filter_by_price_enabled: values['min_price'] = min_price or available_min_price values['max_price'] = max_price or available_max_price values['available_min_price'] = tools.float_round(available_min_price, 2) values['available_max_price'] = tools.float_round(available_max_price, 2) if category: values['main_object'] = category return request.render("website_sale.products", values) @http.route(['/shop/'], type='http', auth="public", website=True, sitemap=True) def product(self, product, category='', search='', **kwargs): return request.render("website_sale.product", self._prepare_product_values(product, category, search, **kwargs)) @http.route(['/shop/product/'], type='http', auth="public", website=True, sitemap=False) def old_product(self, product, category='', search='', **kwargs): # Compatibility pre-v14 return request.redirect(_build_url_w_params("/shop/%s" % slug(product), request.params), code=301) def _prepare_product_values(self, product, category, search, **kwargs): add_qty = int(kwargs.get('add_qty', 1)) product_context = dict(request.env.context, quantity=add_qty, active_id=product.id, partner=request.env.user.partner_id) ProductCategory = request.env['product.public.category'] if category: category = ProductCategory.browse(int(category)).exists() attrib_list = request.httprequest.args.getlist('attrib') min_price = request.params.get('min_price') max_price = request.params.get('max_price') attrib_values = [[int(x) for x in v.split("-")] for v in attrib_list if v] attrib_set = {v[1] for v in attrib_values} keep = QueryURL('/shop', category=category and category.id, search=search, attrib=attrib_list, min_price=min_price, max_price=max_price) categs = ProductCategory.search([('parent_id', '=', False)]) pricelist = request.website.get_current_pricelist() if not product_context.get('pricelist'): product_context['pricelist'] = pricelist.id product = product.with_context(product_context) # Needed to trigger the recently viewed product rpc view_track = request.website.viewref("website_sale.product").track return { 'search': search, 'category': category, 'pricelist': pricelist, 'attrib_values': attrib_values, 'attrib_set': attrib_set, 'keep': keep, 'categories': categs, 'main_object': product, 'product': product, 'add_qty': add_qty, 'view_track': view_track, } @http.route(['/shop/change_pricelist/'], type='http', auth="public", website=True, sitemap=False) def pricelist_change(self, pl_id, **post): if (pl_id.selectable or pl_id == request.env.user.partner_id.property_product_pricelist) \ and request.website.is_pricelist_available(pl_id.id): redirect_url = request.httprequest.referrer if redirect_url and request.website.is_view_active('website_sale.filter_products_price'): decoded_url = url_parse(redirect_url) args = url_decode(decoded_url.query) min_price = args.get('min_price') max_price = args.get('max_price') if min_price or max_price: previous_price_list = request.website.get_current_pricelist() try: min_price = float(min_price) args['min_price'] = min_price and str( previous_price_list.currency_id._convert(min_price, pl_id.currency_id, request.website.company_id, fields.Date.today(), round=False) ) except (ValueError, TypeError): pass try: max_price = float(max_price) args['max_price'] = max_price and str( previous_price_list.currency_id._convert(max_price, pl_id.currency_id, request.website.company_id, fields.Date.today(), round=False) ) except (ValueError, TypeError): pass redirect_url = decoded_url.replace(query=url_encode(args)).to_url() request.session['website_sale_current_pl'] = pl_id.id request.website.sale_get_order(force_pricelist=pl_id.id) return request.redirect(redirect_url or '/shop') @http.route(['/shop/pricelist'], type='http', auth="public", website=True, sitemap=False) def pricelist(self, promo, **post): redirect = post.get('r', '/shop/cart') # empty promo code is used to reset/remove pricelist (see `sale_get_order()`) if promo: pricelist = request.env['product.pricelist'].sudo().search([('code', '=', promo)], limit=1) if (not pricelist or (pricelist and not request.website.is_pricelist_available(pricelist.id))): return request.redirect("%s?code_not_available=1" % redirect) request.website.sale_get_order(code=promo) return request.redirect(redirect) @http.route(['/shop/cart'], type='http', auth="public", website=True, sitemap=False) def cart(self, access_token=None, revive='', **post): """ Main cart management + abandoned cart revival access_token: Abandoned cart SO access token revive: Revival method when abandoned cart. Can be 'merge' or 'squash' """ order = request.website.sale_get_order() if order and order.state != 'draft': request.session['sale_order_id'] = None order = request.website.sale_get_order() values = {} if access_token: abandoned_order = request.env['sale.order'].sudo().search([('access_token', '=', access_token)], limit=1) if not abandoned_order: # wrong token (or SO has been deleted) raise NotFound() if abandoned_order.state != 'draft': # abandoned cart already finished values.update({'abandoned_proceed': True}) elif revive == 'squash' or (revive == 'merge' and not request.session.get('sale_order_id')): # restore old cart or merge with unexistant request.session['sale_order_id'] = abandoned_order.id return request.redirect('/shop/cart') elif revive == 'merge': abandoned_order.order_line.write({'order_id': request.session['sale_order_id']}) abandoned_order.action_cancel() elif abandoned_order.id != request.session.get('sale_order_id'): # abandoned cart found, user have to choose what to do values.update({'access_token': abandoned_order.access_token}) values.update({ 'website_sale_order': order, 'date': fields.Date.today(), 'suggested_products': [], }) if order: order.order_line.filtered(lambda l: not l.product_id.active).unlink() _order = order if not request.env.context.get('pricelist'): _order = order.with_context(pricelist=order.pricelist_id.id) values['suggested_products'] = _order._cart_accessories() if post.get('type') == 'popover': # force no-cache so IE11 doesn't cache this XHR return request.render("website_sale.cart_popover", values, headers={'Cache-Control': 'no-cache'}) return request.render("website_sale.cart", values) @http.route(['/shop/cart/update'], type='http', auth="public", methods=['POST'], website=True) def cart_update(self, product_id, add_qty=1, set_qty=0, **kw): """This route is called when adding a product to cart (no options).""" sale_order = request.website.sale_get_order(force_create=True) if sale_order.state != 'draft': request.session['sale_order_id'] = None sale_order = request.website.sale_get_order(force_create=True) product_custom_attribute_values = None if kw.get('product_custom_attribute_values'): product_custom_attribute_values = json_scriptsafe.loads(kw.get('product_custom_attribute_values')) no_variant_attribute_values = None if kw.get('no_variant_attribute_values'): no_variant_attribute_values = json_scriptsafe.loads(kw.get('no_variant_attribute_values')) sale_order._cart_update( product_id=int(product_id), add_qty=add_qty, set_qty=set_qty, product_custom_attribute_values=product_custom_attribute_values, no_variant_attribute_values=no_variant_attribute_values ) if kw.get('express'): return request.redirect("/shop/checkout?express=1") return request.redirect("/shop/cart") @http.route(['/shop/cart/update_json'], type='json', auth="public", methods=['POST'], website=True, csrf=False) def cart_update_json(self, product_id, line_id=None, add_qty=None, set_qty=None, display=True, **kw): """ This route is called : - When changing quantity from the cart. - When adding a product from the wishlist. - When adding a product to cart on the same page (without redirection). """ order = request.website.sale_get_order(force_create=1) if order.state != 'draft': request.website.sale_reset() if kw.get('force_create'): order = request.website.sale_get_order(force_create=1) else: return {} pcav = kw.get('product_custom_attribute_values') nvav = kw.get('no_variant_attribute_values') value = order._cart_update( product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty, product_custom_attribute_values=json_scriptsafe.loads(pcav) if pcav else None, no_variant_attribute_values=json_scriptsafe.loads(nvav) if nvav else None ) if not order.cart_quantity: request.website.sale_reset() return value order = request.website.sale_get_order() value['cart_quantity'] = order.cart_quantity if not display: return value value['website_sale.cart_lines'] = request.env['ir.ui.view']._render_template("website_sale.cart_lines", { 'website_sale_order': order, 'date': fields.Date.today(), 'suggested_products': order._cart_accessories() }) value['website_sale.short_cart_summary'] = request.env['ir.ui.view']._render_template("website_sale.short_cart_summary", { 'website_sale_order': order, }) return value @http.route('/shop/save_shop_layout_mode', type='json', auth='public', website=True) def save_shop_layout_mode(self, layout_mode): assert layout_mode in ('grid', 'list'), "Invalid shop layout mode" request.session['website_sale_shop_layout_mode'] = layout_mode # ------------------------------------------------------ # Checkout # ------------------------------------------------------ def checkout_check_address(self, order): billing_fields_required = self._get_mandatory_fields_billing(order.partner_id.country_id.id) if not all(order.partner_id.read(billing_fields_required)[0].values()): return request.redirect('/shop/address?partner_id=%d' % order.partner_id.id) shipping_fields_required = self._get_mandatory_fields_shipping(order.partner_shipping_id.country_id.id) if not all(order.partner_shipping_id.read(shipping_fields_required)[0].values()): return request.redirect('/shop/address?partner_id=%d' % order.partner_shipping_id.id) def checkout_redirection(self, order): # must have a draft sales order with lines at this point, otherwise reset if not order or order.state != 'draft': request.session['sale_order_id'] = None request.session['sale_transaction_id'] = None return request.redirect('/shop') if order and not order.order_line: return request.redirect('/shop/cart') # if transaction pending / done: redirect to confirmation tx = request.env.context.get('website_sale_transaction') if tx and tx.state != 'draft': return request.redirect('/shop/payment/confirmation/%s' % order.id) def checkout_values(self, **kw): order = request.website.sale_get_order(force_create=1) shippings = [] if order.partner_id != request.website.user_id.sudo().partner_id: Partner = order.partner_id.with_context(show_address=1).sudo() shippings = Partner.search([ ("id", "child_of", order.partner_id.commercial_partner_id.ids), '|', ("type", "in", ["delivery", "other"]), ("id", "=", order.partner_id.commercial_partner_id.id) ], order='id desc') if shippings: if kw.get('partner_id') or 'use_billing' in kw: if 'use_billing' in kw: partner_id = order.partner_id.id else: partner_id = int(kw.get('partner_id')) if partner_id in shippings.mapped('id'): order.partner_shipping_id = partner_id values = { 'order': order, 'shippings': shippings, 'only_services': order and order.only_services or False } return values def _get_mandatory_fields_billing(self, country_id=False): req = ["name", "email", "street", "city", "country_id"] if country_id: country = request.env['res.country'].browse(country_id) if country.state_required: req += ['state_id'] if country.zip_required: req += ['zip'] return req def _get_mandatory_fields_shipping(self, country_id=False): req = ["name", "street", "city", "country_id"] if country_id: country = request.env['res.country'].browse(country_id) if country.state_required: req += ['state_id'] if country.zip_required: req += ['zip'] return req def checkout_form_validate(self, mode, all_form_values, data): # mode: tuple ('new|edit', 'billing|shipping') # all_form_values: all values before preprocess # data: values after preprocess error = dict() error_message = [] # Required fields from form required_fields = [f for f in (all_form_values.get('field_required') or '').split(',') if f] # Required fields from mandatory field function country_id = int(data.get('country_id', False)) required_fields += mode[1] == 'shipping' and self._get_mandatory_fields_shipping(country_id) or self._get_mandatory_fields_billing(country_id) # error message for empty required fields for field_name in required_fields: if not data.get(field_name): error[field_name] = 'missing' # email validation if data.get('email') and not tools.single_email_re.match(data.get('email')): error["email"] = 'error' error_message.append(_('Invalid Email! Please enter a valid email address.')) # vat validation Partner = request.env['res.partner'] if data.get("vat") and hasattr(Partner, "check_vat"): if country_id: data["vat"] = Partner.fix_eu_vat_number(country_id, data.get("vat")) partner_dummy = Partner.new(self._get_vat_validation_fields(data)) try: partner_dummy.check_vat() except ValidationError as exception: error["vat"] = 'error' error_message.append(exception.args[0]) if [err for err in error.values() if err == 'missing']: error_message.append(_('Some required fields are empty.')) return error, error_message def _get_vat_validation_fields(self, data): return { 'vat': data['vat'], 'country_id': int(data['country_id']) if data.get('country_id') else False, } def _checkout_form_save(self, mode, checkout, all_values): Partner = request.env['res.partner'] if mode[0] == 'new': partner_id = Partner.sudo().with_context(tracking_disable=True).create(checkout).id elif mode[0] == 'edit': partner_id = int(all_values.get('partner_id', 0)) if partner_id: # double check order = request.website.sale_get_order() shippings = Partner.sudo().search([("id", "child_of", order.partner_id.commercial_partner_id.ids)]) if partner_id not in shippings.mapped('id') and partner_id != order.partner_id.id: return Forbidden() Partner.browse(partner_id).sudo().write(checkout) return partner_id def values_preprocess(self, order, mode, values): # Convert the values for many2one fields to integer since they are used as IDs partner_fields = request.env['res.partner']._fields return { k: (bool(v) and int(v)) if k in partner_fields and partner_fields[k].type == 'many2one' else v for k, v in values.items() } def values_postprocess(self, order, mode, values, errors, error_msg): new_values = {} authorized_fields = request.env['ir.model']._get('res.partner')._get_form_writable_fields() for k, v in values.items(): # don't drop empty value, it could be a field to reset if k in authorized_fields and v is not None: new_values[k] = v else: # DEBUG ONLY if k not in ('field_required', 'partner_id', 'callback', 'submitted'): # classic case _logger.debug("website_sale postprocess: %s value has been dropped (empty or not writable)" % k) if request.website.specific_user_account: new_values['website_id'] = request.website.id if mode[0] == 'new': new_values['company_id'] = request.website.company_id.id new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id new_values['user_id'] = request.website.salesperson_id.id lang = request.lang.code if request.lang.code in request.website.mapped('language_ids.code') else None if lang: new_values['lang'] = lang if mode == ('edit', 'billing') and order.partner_id.type == 'contact': new_values['type'] = 'other' if mode[1] == 'shipping': new_values['parent_id'] = order.partner_id.commercial_partner_id.id new_values['type'] = 'delivery' return new_values, errors, error_msg @http.route(['/shop/address'], type='http', methods=['GET', 'POST'], auth="public", website=True, sitemap=False) def address(self, **kw): Partner = request.env['res.partner'].with_context(show_address=1).sudo() order = request.website.sale_get_order() redirection = self.checkout_redirection(order) if redirection: return redirection mode = (False, False) can_edit_vat = False values, errors = {}, {} partner_id = int(kw.get('partner_id', -1)) # IF PUBLIC ORDER if order.partner_id.id == request.website.user_id.sudo().partner_id.id: mode = ('new', 'billing') can_edit_vat = True # IF ORDER LINKED TO A PARTNER else: if partner_id > 0: if partner_id == order.partner_id.id: mode = ('edit', 'billing') can_edit_vat = order.partner_id.can_edit_vat() else: shippings = Partner.search([('id', 'child_of', order.partner_id.commercial_partner_id.ids)]) if order.partner_id.commercial_partner_id.id == partner_id: mode = ('new', 'shipping') partner_id = -1 elif partner_id in shippings.mapped('id'): mode = ('edit', 'shipping') else: return Forbidden() if mode and partner_id != -1: values = Partner.browse(partner_id) elif partner_id == -1: mode = ('new', 'shipping') else: # no mode - refresh without post? return request.redirect('/shop/checkout') # IF POSTED if 'submitted' in kw and request.httprequest.method == "POST": pre_values = self.values_preprocess(order, mode, kw) errors, error_msg = self.checkout_form_validate(mode, kw, pre_values) post, errors, error_msg = self.values_postprocess(order, mode, pre_values, errors, error_msg) if errors: errors['error_message'] = error_msg values = kw else: partner_id = self._checkout_form_save(mode, post, kw) if mode[1] == 'billing': order.partner_id = partner_id # This is the *only* thing that the front end user will see/edit anyway when choosing billing address order.partner_invoice_id = partner_id if not kw.get('use_same'): kw['callback'] = kw.get('callback') or \ (not order.only_services and (mode[0] == 'edit' and '/shop/checkout' or '/shop/address')) elif mode[1] == 'shipping': order.partner_shipping_id = partner_id # TDE FIXME: don't ever do this # -> TDE: you are the guy that did what we should never do in commit e6f038a order.message_partner_ids = [(4, partner_id), (3, request.website.partner_id.id)] if not errors: return request.redirect(kw.get('callback') or '/shop/confirm_order') render_values = { 'website_sale_order': order, 'partner_id': partner_id, 'mode': mode, 'checkout': values, 'can_edit_vat': can_edit_vat, 'error': errors, 'callback': kw.get('callback'), 'only_services': order and order.only_services, } render_values.update(self._get_country_related_render_values(kw, render_values)) return request.render("website_sale.address", render_values) def _get_country_related_render_values(self, kw, render_values): ''' This method provides fields related to the country to render the website sale form ''' values = render_values['checkout'] mode = render_values['mode'] order = render_values['website_sale_order'] def_country_id = order.partner_id.country_id # IF PUBLIC ORDER if order.partner_id.id == request.website.user_id.sudo().partner_id.id: country_code = request.session['geoip'].get('country_code') if country_code: def_country_id = request.env['res.country'].search([('code', '=', country_code)], limit=1) else: def_country_id = request.website.user_id.sudo().country_id country = 'country_id' in values and values['country_id'] != '' and request.env['res.country'].browse(int(values['country_id'])) country = country and country.exists() or def_country_id res = { 'country': country, 'country_states': country.get_website_sale_states(mode=mode[1]), 'countries': country.get_website_sale_countries(mode=mode[1]), } return res @http.route(['/shop/checkout'], type='http', auth="public", website=True, sitemap=False) def checkout(self, **post): order = request.website.sale_get_order() redirection = self.checkout_redirection(order) if redirection: return redirection if order.partner_id.id == request.website.user_id.sudo().partner_id.id: return request.redirect('/shop/address') redirection = self.checkout_check_address(order) if redirection: return redirection values = self.checkout_values(**post) if post.get('express'): return request.redirect('/shop/confirm_order') values.update({'website_sale_order': order}) # Avoid useless rendering if called in ajax if post.get('xhr'): return 'ok' return request.render("website_sale.checkout", values) @http.route(['/shop/confirm_order'], type='http', auth="public", website=True, sitemap=False) def confirm_order(self, **post): order = request.website.sale_get_order() redirection = self.checkout_redirection(order) or self.checkout_check_address(order) if redirection: return redirection order.order_line._compute_tax_id() request.session['sale_last_order_id'] = order.id request.website.sale_get_order(update_pricelist=True) extra_step = request.website.viewref('website_sale.extra_info_option') if extra_step.active: return request.redirect("/shop/extra_info") return request.redirect("/shop/payment") # ------------------------------------------------------ # Extra step # ------------------------------------------------------ @http.route(['/shop/extra_info'], type='http', auth="public", website=True, sitemap=False) def extra_info(self, **post): # Check that this option is activated extra_step = request.website.viewref('website_sale.extra_info_option') if not extra_step.active: return request.redirect("/shop/payment") # check that cart is valid order = request.website.sale_get_order() redirection = self.checkout_redirection(order) if redirection: return redirection values = { 'website_sale_order': order, 'post': post, 'escape': lambda x: x.replace("'", r"\'"), 'partner': order.partner_id.id, 'order': order, } return request.render("website_sale.extra_info", values) # ------------------------------------------------------ # Payment # ------------------------------------------------------ def _get_shop_payment_values(self, order, **kwargs): logged_in = not request.env.user._is_public() acquirers_sudo = request.env['payment.acquirer'].sudo()._get_compatible_acquirers( order.company_id.id, order.partner_id.id, currency_id=order.currency_id.id, sale_order_id=order.id, website_id=request.website.id, ) # In sudo mode to read the fields of acquirers, order and partner (if not logged in) tokens = request.env['payment.token'].search( [('acquirer_id', 'in', acquirers_sudo.ids), ('partner_id', '=', order.partner_id.id)] ) if logged_in else request.env['payment.token'] fees_by_acquirer = { acq_sudo: acq_sudo._compute_fees( order.amount_total, order.currency_id, order.partner_id.country_id ) for acq_sudo in acquirers_sudo.filtered('fees_active') } # Prevent public partner from saving payment methods but force it for logged in partners # buying subscription products show_tokenize_input = logged_in \ and not request.env['payment.acquirer'].sudo()._is_tokenization_required( sale_order_id=order.id ) return { 'website_sale_order': order, 'errors': [], 'partner': order.partner_id, 'order': order, 'payment_action_id': request.env.ref('payment.action_payment_acquirer').id, # Payment form common (checkout and manage) values 'acquirers': acquirers_sudo, 'tokens': tokens, 'fees_by_acquirer': fees_by_acquirer, 'show_tokenize_input': show_tokenize_input, 'amount': order.amount_total, 'currency': order.currency_id, 'partner_id': order.partner_id.id, 'access_token': order._portal_ensure_token(), 'transaction_route': f'/shop/payment/transaction/{order.id}', 'landing_route': '/shop/payment/validate', } @http.route('/shop/payment', type='http', auth='public', website=True, sitemap=False) def shop_payment(self, **post): """ Payment step. This page proposes several payment means based on available payment.acquirer. State at this point : - a draft sales order with lines; otherwise, clean context / session and back to the shop - no transaction in context / session, or only a draft one, if the customer did go to a payment.acquirer website but closed the tab without paying / canceling """ order = request.website.sale_get_order() redirection = self.checkout_redirection(order) or self.checkout_check_address(order) if redirection: return redirection render_values = self._get_shop_payment_values(order, **post) render_values['only_services'] = order and order.only_services or False if render_values['errors']: render_values.pop('acquirers', '') render_values.pop('tokens', '') return request.render("website_sale.payment", render_values) @http.route('/shop/payment/get_status/', type='json', auth="public", website=True) def shop_payment_get_status(self, sale_order_id, **post): order = request.env['sale.order'].sudo().browse(sale_order_id).exists() if order.id != request.session.get('sale_last_order_id'): # either something went wrong or the session is unbound # prevent recalling every 3rd of a second in the JS widget return {} return { 'recall': order.get_portal_last_transaction().state == 'pending', 'message': request.env['ir.ui.view']._render_template("website_sale.payment_confirmation_status", { 'order': order }) } @http.route('/shop/payment/validate', type='http', auth="public", website=True, sitemap=False) def shop_payment_validate(self, transaction_id=None, sale_order_id=None, **post): """ Method that should be called by the server when receiving an update for a transaction. State at this point : - UDPATE ME """ if sale_order_id is None: order = request.website.sale_get_order() else: order = request.env['sale.order'].sudo().browse(sale_order_id) assert order.id == request.session.get('sale_last_order_id') if transaction_id: tx = request.env['payment.transaction'].sudo().browse(transaction_id) assert tx in order.transaction_ids() elif order: tx = order.get_portal_last_transaction() else: tx = None if not order or (order.amount_total and not tx): return request.redirect('/shop') if order and not order.amount_total and not tx: order.with_context(send_email=True).action_confirm() return request.redirect(order.get_portal_url()) # clean context and session, then redirect to the confirmation page request.website.sale_reset() if tx and tx.state == 'draft': return request.redirect('/shop') PaymentPostProcessing.remove_transactions(tx) return request.redirect('/shop/confirmation') @http.route(['/shop/confirmation'], type='http', auth="public", website=True, sitemap=False) def shop_payment_confirmation(self, **post): """ End of checkout process controller. Confirmation is basically seing the status of a sale.order. State at this point : - should not have any context / session info: clean them - take a sale.order id, because we request a sale.order and are not session dependant anymore """ sale_order_id = request.session.get('sale_last_order_id') if sale_order_id: order = request.env['sale.order'].sudo().browse(sale_order_id) return request.render("website_sale.confirmation", { 'order': order, 'order_tracking_info': self.order_2_return_dict(order), }) else: return request.redirect('/shop') @http.route(['/shop/print'], type='http', auth="public", website=True, sitemap=False) def print_saleorder(self, **kwargs): sale_order_id = request.session.get('sale_last_order_id') if sale_order_id: pdf, _ = request.env.ref('sale.action_report_saleorder').with_user(SUPERUSER_ID)._render_qweb_pdf([sale_order_id]) pdfhttpheaders = [('Content-Type', 'application/pdf'), ('Content-Length', u'%s' % len(pdf))] return request.make_response(pdf, headers=pdfhttpheaders) else: return request.redirect('/shop') # ------------------------------------------------------ # Edit # ------------------------------------------------------ @http.route(['/shop/add_product'], type='json', auth="user", methods=['POST'], website=True) def add_product(self, name=None, category=None, **post): product = request.env['product.product'].create({ 'name': name or _("New Product"), 'public_categ_ids': category, 'website_id': request.website.id, }) return "%s?enable_editor=1" % product.product_tmpl_id.website_url @http.route(['/shop/config/product'], type='json', auth='user') def change_product_config(self, product_id, **options): if not request.env.user.has_group('website.group_website_publisher'): raise NotFound() product = request.env['product.template'].browse(product_id) if "sequence" in options: sequence = options["sequence"] if sequence == "top": product.set_sequence_top() elif sequence == "bottom": product.set_sequence_bottom() elif sequence == "up": product.set_sequence_up() elif sequence == "down": product.set_sequence_down() if {"x", "y"} <= set(options): product.write({'website_size_x': options["x"], 'website_size_y': options["y"]}) @http.route(['/shop/config/website'], type='json', auth='user') def _change_website_config(self, **options): if not request.env.user.has_group('website.group_website_publisher'): raise NotFound() current_website = request.env['website'].get_current_website() if "ppg" in options: current_website.shop_ppg = options["ppg"] or 1 if "ppr" in options: current_website.shop_ppr = options["ppr"] if "default_sort" in options: current_website.shop_default_sort = options["default_sort"] def order_lines_2_google_api(self, order_lines): """ Transforms a list of order lines into a dict for google analytics """ ret = [] for line in order_lines: product = line.product_id ret.append({ 'item_id': product.barcode or product.id, 'item_name': product.name or '-', 'item_category': product.categ_id.name or '-', 'price': line.price_unit, 'quantity': line.product_uom_qty, }) return ret def order_2_return_dict(self, order): """ Returns the tracking_cart dict of the order for Google analytics basically defined to be inherited """ return { 'transaction_id': order.id, 'affiliation': order.company_id.name, 'value': order.amount_total, 'tax': order.amount_tax, 'currency': order.currency_id.name, 'items': self.order_lines_2_google_api(order.order_line), } @http.route(['/shop/country_infos/'], type='json', auth="public", methods=['POST'], website=True) def country_infos(self, country, mode, **kw): return dict( fields=country.get_address_fields(), states=[(st.id, st.name, st.code) for st in country.get_website_sale_states(mode=mode)], phone_code=country.phone_code, zip_required=country.zip_required, state_required=country.state_required, ) # -------------------------------------------------------------------------- # Products Recently Viewed # -------------------------------------------------------------------------- @http.route('/shop/products/recently_viewed_update', type='json', auth='public', website=True) def products_recently_viewed_update(self, product_id, **kwargs): res = {} visitor_sudo = request.env['website.visitor']._get_visitor_from_request(force_create=True) if visitor_sudo: if request.httprequest.cookies.get('visitor_uuid', '') != visitor_sudo.access_token: res['visitor_uuid'] = visitor_sudo.access_token visitor_sudo._add_viewed_product(product_id) return res @http.route('/shop/products/recently_viewed_delete', type='json', auth='public', website=True) def products_recently_viewed_delete(self, product_id, **kwargs): visitor_sudo = request.env['website.visitor']._get_visitor_from_request() if visitor_sudo: request.env['website.track'].sudo().search([('visitor_id', '=', visitor_sudo.id), ('product_id', '=', product_id)]).unlink() return {} class PaymentPortal(payment_portal.PaymentPortal): @http.route( '/shop/payment/transaction/', type='json', auth='public', website=True ) def shop_payment_transaction(self, order_id, access_token, **kwargs): """ Create a draft transaction and return its processing values. :param int order_id: The sales order to pay, as a `sale.order` id :param str access_token: The access token used to authenticate the request :param dict kwargs: Locally unused data passed to `_create_transaction` :return: The mandatory values for the processing of the transaction :rtype: dict :raise: ValidationError if the invoice id or the access token is invalid """ # Check the order id and the access token try: self._document_check_access('sale.order', order_id, access_token) except MissingError as error: raise error except AccessError: raise ValidationError("The access token is invalid.") kwargs.update({ 'reference_prefix': None, # Allow the reference to be computed based on the order 'sale_order_id': order_id, # Include the SO to allow Subscriptions to tokenize the tx }) kwargs.pop('custom_create_values', None) # Don't allow passing arbitrary create values tx_sudo = self._create_transaction( custom_create_values={'sale_order_ids': [Command.set([order_id])]}, **kwargs, ) # Store the new transaction into the transaction list and if there's an old one, we remove # it until the day the ecommerce supports multiple orders at the same time. last_tx_id = request.session.get('__website_sale_last_tx_id') last_tx = request.env['payment.transaction'].browse(last_tx_id).sudo().exists() if last_tx: PaymentPostProcessing.remove_transactions(last_tx) request.session['__website_sale_last_tx_id'] = tx_sudo.id return tx_sudo._get_processing_values()