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/controllers/main.py | |
| parent | 0a15094050bfde69a06d6eff798e9a8ddf2b8c21 (diff) | |
initial commit 2
Diffstat (limited to 'addons/website_sale/controllers/main.py')
| -rw-r--r-- | addons/website_sale/controllers/main.py | 1319 |
1 files changed, 1319 insertions, 0 deletions
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py new file mode 100644 index 00000000..d8fef59c --- /dev/null +++ b/addons/website_sale/controllers/main.py @@ -0,0 +1,1319 @@ +# -*- 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/<int:page>''', + '''/shop/category/<model("product.public.category"):category>''', + '''/shop/category/<model("product.public.category"):category>/page/<int: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/<model("product.template"):product>'], 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/<model("product.template"):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/<model("product.pricelist"):pl_id>'], 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/<int:so_id>', + '/shop/payment/transaction/<int:so_id>/<string:access_token>'], 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/<int:sale_order_id>', 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/<model("res.country"):country>'], 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 |
