# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import json import logging from datetime import datetime from werkzeug.exceptions import Forbidden, NotFound from odoo import fields, http, SUPERUSER_ID, tools, _ 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.portal import PaymentProcessing from odoo.addons.website.controllers.main import QueryURL from odoo.addons.website.models.ir_http import sitemap_qs2dom from odoo.exceptions import ValidationError from odoo.addons.portal.controllers.portal import _build_url_w_params from odoo.addons.website.controllers.main import Website from odoo.addons.website_form.controllers.main import WebsiteForm from odoo.osv import expression _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.website_ribbon_id, } 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', 'no_auto_thread': False, '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(Website): @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) 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 'website_sequence ASC' 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([('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='', ppg=False, **post): add_qty = int(post.get('add_qty', 1)) 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} domain = self._get_search_domain(search, category, attrib_values) keep = QueryURL('/shop', category=category and int(category), search=search, attrib=attrib_list, 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) url = "/shop" if search: post["search"] = search if attrib_list: post['attrib'] = attrib_list Product = request.env['product.template'].with_context(bin_size=True) search_product = Product.search(domain, order=self._get_search_order(post)) 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) product_count = len(search_product) 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)]) 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': 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 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): if not product.can_access_from_current_website(): raise NotFound() 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') 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) 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): request.session['website_sale_current_pl'] = pl_id.id request.website.sale_get_order(force_pricelist=pl_id.id) return request.redirect(request.httprequest.referrer 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.loads(kw.get('product_custom_attribute_values')) no_variant_attribute_values = None if kw.get('no_variant_attribute_values'): no_variant_attribute_values = json.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): """This route is called when changing quantity from the cart or adding a product from the wishlist.""" order = request.website.sale_get_order(force_create=1) if order.state != 'draft': request.website.sale_reset() return {} value = order._cart_update(product_id=product_id, line_id=line_id, add_qty=add_qty, set_qty=set_qty) 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_billing_fields(self): # deprecated for _get_mandatory_fields_billing which handle zip/state required return ["name", "email", "street", "city", "country_id"] def _get_mandatory_shipping_fields(self): # deprecated for _get_mandatory_fields_shipping which handle zip/state required return ["name", "street", "city", "country_id"] def _get_mandatory_fields_billing(self, country_id=False): req = self._get_mandatory_billing_fields() 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 = self._get_mandatory_shipping_fields() 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) new_values['team_id'] = request.website.salesteam_id and request.website.salesteam_id.id new_values['user_id'] = request.website.salesperson_id and request.website.salesperson_id.id 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 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: 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 order.with_context(not_self_saleperson=True).onchange_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 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.onchange_partner_shipping_id() 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 # if form posted if 'post_values' in post: values = {} for field_name, field_value in post.items(): if field_name in request.env['sale.order']._fields and field_name.startswith('x_'): values[field_name] = field_value if values: order.write(values) return request.redirect("/shop/payment") 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): values = dict( website_sale_order=order, errors=[], partner=order.partner_id.id, order=order, payment_action_id=request.env.ref('payment.action_payment_acquirer').id, return_url= '/shop/payment/validate', bootstrap_formatting= True ) domain = expression.AND([ ['&', ('state', 'in', ['enabled', 'test']), ('company_id', '=', order.company_id.id)], ['|', ('website_id', '=', False), ('website_id', '=', request.website.id)], ['|', ('country_ids', '=', False), ('country_ids', 'in', [order.partner_id.country_id.id])] ]) acquirers = request.env['payment.acquirer'].search(domain) values['access_token'] = order.access_token values['acquirers'] = [acq for acq in acquirers if (acq.payment_flow == 'form' and acq.view_template_id) or (acq.payment_flow == 's2s' and acq.registration_view_template_id)] values['tokens'] = request.env['payment.token'].search([ ('acquirer_id', 'in', acquirers.ids), ('partner_id', 'child_of', order.partner_id.commercial_partner_id.id)]) if order: values['acq_extra_fees'] = acquirers.get_acquirer_extra_fees(order.amount_total, order.currency_id, order.partner_id.country_id.id) return values @http.route(['/shop/payment'], type='http', auth="public", website=True, sitemap=False) def 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/transaction/', '/shop/payment/transaction/', '/shop/payment/transaction//'], type='json', auth="public", website=True) def payment_transaction(self, acquirer_id, save_token=False, so_id=None, access_token=None, token=None, **kwargs): """ Json method that creates a payment.transaction, used to create a transaction when the user clicks on 'pay now' button. After having created the transaction, the event continues and the user is redirected to the acquirer website. :param int acquirer_id: id of a payment.acquirer record. If not set the user is redirected to the checkout page """ # Ensure a payment acquirer is selected if not acquirer_id: return False try: acquirer_id = int(acquirer_id) except: return False # Retrieve the sale order if so_id: env = request.env['sale.order'] domain = [('id', '=', so_id)] if access_token: env = env.sudo() domain.append(('access_token', '=', access_token)) order = env.search(domain, limit=1) else: order = request.website.sale_get_order() # Ensure there is something to proceed if not order or (order and not order.order_line): return False assert order.partner_id.id != request.website.partner_id.id # Create transaction vals = {'acquirer_id': acquirer_id, 'return_url': '/shop/payment/validate'} if save_token: vals['type'] = 'form_save' if token: vals['payment_token_id'] = int(token) transaction = order._create_payment_transaction(vals) # 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: PaymentProcessing.remove_payment_transaction(last_tx) PaymentProcessing.add_payment_transaction(transaction) request.session['__website_sale_last_tx_id'] = transaction.id return transaction.render_sale_button(order) @http.route('/shop/payment/token', type='http', auth='public', website=True, sitemap=False) def payment_token(self, pm_id=None, **kwargs): """ Method that handles payment using saved tokens :param int pm_id: id of the payment.token that we want to use to pay. """ order = request.website.sale_get_order() # do not crash if the user has already paid and try to pay again if not order: return request.redirect('/shop/?error=no_order') assert order.partner_id.id != request.website.partner_id.id try: pm_id = int(pm_id) except ValueError: return request.redirect('/shop/?error=invalid_token_id') # We retrieve the token the user want to use to pay if not request.env['payment.token'].sudo().search_count([('id', '=', pm_id)]): return request.redirect('/shop/?error=token_not_found') # Create transaction vals = {'payment_token_id': pm_id, 'return_url': '/shop/payment/validate'} tx = order._create_payment_transaction(vals) PaymentProcessing.add_payment_transaction(tx) return request.redirect('/payment/process') @http.route('/shop/payment/get_status/', type='json', auth="public", website=True) def 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 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') PaymentProcessing.remove_payment_transaction(tx) return request.redirect('/shop/confirmation') @http.route(['/shop/terms'], type='http', auth="public", website=True, sitemap=True) def terms(self, **kw): return request.render("website_sale.terms") @http.route(['/shop/confirmation'], type='http', auth="public", website=True, sitemap=False) def 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}) 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').sudo()._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') @http.route(['/shop/tracking_last_order'], type='json', auth="public") def tracking_cart(self, **post): """ return data about order in JSON needed for google analytics""" ret = {} sale_order_id = request.session.get('sale_last_order_id') if sale_order_id: order = request.env['sale.order'].sudo().browse(sale_order_id) ret = self.order_2_return_dict(order) return ret # ------------------------------------------------------ # 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/change_sequence'], type='json', auth='user') def change_sequence(self, id, sequence): product_tmpl = request.env['product.template'].browse(id) if sequence == "top": product_tmpl.set_sequence_top() elif sequence == "bottom": product_tmpl.set_sequence_bottom() elif sequence == "up": product_tmpl.set_sequence_up() elif sequence == "down": product_tmpl.set_sequence_down() @http.route(['/shop/change_size'], type='json', auth='user') def change_size(self, id, x, y): product = request.env['product.template'].browse(id) return product.write({'website_size_x': x, 'website_size_y': y}) @http.route(['/shop/change_ppg'], type='json', auth='user') def change_ppg(self, ppg): request.env['website'].get_current_website().shop_ppg = ppg @http.route(['/shop/change_ppr'], type='json', auth='user') def change_ppr(self, ppr): request.env['website'].get_current_website().shop_ppr = ppr 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({ 'id': line.order_id.id, 'sku': product.barcode or product.id, 'name': product.name or '-', '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, 'revenue': order.amount_total, 'tax': order.amount_tax, 'currency': order.currency_id.name }, 'lines': 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 Search Bar # -------------------------------------------------------------------------- @http.route('/shop/products/autocomplete', type='json', auth='public', website=True) def products_autocomplete(self, term, options={}, **kwargs): """ Returns list of products according to the term and product options Params: term (str): search term written by the user options (dict) - 'limit' (int), default to 5: number of products to consider - 'display_description' (bool), default to True - 'display_price' (bool), default to True - 'order' (str) - 'max_nb_chars' (int): max number of characters for the description if returned Returns: dict (or False if no result) - 'products' (list): products (only their needed field values) note: the prices will be strings properly formatted and already containing the currency - 'products_count' (int): the number of products in the database that matched the search query """ ProductTemplate = request.env['product.template'] display_description = options.get('display_description', True) display_price = options.get('display_price', True) order = self._get_search_order(options) max_nb_chars = options.get('max_nb_chars', 999) category = options.get('category') attrib_values = options.get('attrib_values') domain = self._get_search_domain(term, category, attrib_values, display_description) products = ProductTemplate.search( domain, limit=min(20, options.get('limit', 5)), order=order ) fields = ['id', 'name', 'website_url'] if display_description: fields.append('description_sale') res = { 'products': products.read(fields), 'products_count': ProductTemplate.search_count(domain), } if display_description: for res_product in res['products']: desc = res_product['description_sale'] if desc and len(desc) > max_nb_chars: res_product['description_sale'] = "%s..." % desc[:(max_nb_chars - 3)] if display_price: FieldMonetary = request.env['ir.qweb.field.monetary'] monetary_options = { 'display_currency': request.website.get_current_pricelist().currency_id, } for res_product, product in zip(res['products'], products): combination_info = product._get_combination_info(only_template=True) res_product.update(combination_info) res_product['list_price'] = FieldMonetary.value_to_html(res_product['list_price'], monetary_options) res_product['price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options) return res # -------------------------------------------------------------------------- # Products Recently Viewed # -------------------------------------------------------------------------- @http.route('/shop/products/recently_viewed', type='json', auth='public', website=True) def products_recently_viewed(self, **kwargs): return self._get_products_recently_viewed() def _get_products_recently_viewed(self): """ Returns list of recently viewed products according to current user """ max_number_of_product_for_carousel = 12 visitor = request.env['website.visitor']._get_visitor_from_request() if visitor: excluded_products = request.website.sale_get_order().mapped('order_line.product_id.id') products = request.env['website.track'].sudo().read_group( [('visitor_id', '=', visitor.id), ('product_id', '!=', False), ('product_id.website_published', '=', True), ('product_id', 'not in', excluded_products)], ['product_id', 'visit_datetime:max'], ['product_id'], limit=max_number_of_product_for_carousel, orderby='visit_datetime DESC') products_ids = [product['product_id'][0] for product in products] if products_ids: viewed_products = request.env['product.product'].with_context(display_default_code=False).browse(products_ids) FieldMonetary = request.env['ir.qweb.field.monetary'] monetary_options = { 'display_currency': request.website.get_current_pricelist().currency_id, } rating = request.website.viewref('website_sale.product_comment').active res = {'products': []} for product in viewed_products: combination_info = product._get_combination_info_variant() res_product = product.read(['id', 'name', 'website_url'])[0] res_product.update(combination_info) res_product['price'] = FieldMonetary.value_to_html(res_product['price'], monetary_options) if rating: res_product['rating'] = request.env["ir.ui.view"]._render_template('portal_rating.rating_widget_stars_static', values={ 'rating_avg': product.rating_avg, 'rating_count': product.rating_count, }) res['products'].append(res_product) return res return {} @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 self._get_products_recently_viewed() # -------------------------------------------------------------------------- # Website Snippet Filters # -------------------------------------------------------------------------- @http.route('/website_sale/snippet/options_filters', type='json', auth='user', website=True) def get_dynamic_snippet_filters(self): domain = expression.AND([ request.website.website_domain(), ['|', ('filter_id.model_id', '=', 'product.product'), ('action_server_id.model_id.model', '=', 'product.product')] ]) filters = request.env['website.snippet.filter'].sudo().search_read( domain, ['id'] ) return filters