[IMP] pos_self_order: change authorisation token
Previously, access_token were linked to tables. These tables were linked to a floor_plan, which could be linked to several pos_configs. The only way to access the self-order was to obtain a valid table access_token. This behaviour was not correct because sometimes we would allow table selection directly in the interface or commands without a table. Now, access_token is managed by pos_config. When a user has this token, they can place commands and select the table they want if the option is enabled. closes odoo/odoo#126186 Signed-off-by: Joseph Caburnay (jcb) <jcb@odoo.com>
This commit is contained in:
@@ -9,14 +9,13 @@
|
||||
"auto_install": ["pos_restaurant"],
|
||||
"demo": [
|
||||
"demo/pos_restaurant_demo.xml",
|
||||
"demo/custom_link_data.xml",
|
||||
],
|
||||
"data": [
|
||||
"security/ir.model.access.csv",
|
||||
"views/index.xml",
|
||||
"views/qr_code.xml",
|
||||
"views/custom_link_views.xml",
|
||||
"data/restaurant_table_data.xml",
|
||||
"data/init_access.xml",
|
||||
"views/res_config_settings_views.xml",
|
||||
"views/point_of_sale_dashboard.xml",
|
||||
"data/pos_restaurant_data.xml",
|
||||
|
||||
@@ -7,7 +7,6 @@ from odoo import http
|
||||
from odoo.http import request
|
||||
|
||||
from odoo.addons.pos_self_order.controllers.utils import (
|
||||
get_pos_config_sudo,
|
||||
get_any_pos_config_sudo,
|
||||
get_table_sudo,
|
||||
)
|
||||
@@ -23,39 +22,31 @@ class PosQRMenuController(http.Controller):
|
||||
to the server, using client side routing.
|
||||
"""
|
||||
|
||||
@http.route("/menu", auth="public")
|
||||
def pos_self_order_redirect(self):
|
||||
return request.redirect(f"/menu/{get_any_pos_config_sudo().id}")
|
||||
|
||||
@http.route(
|
||||
[
|
||||
"/menu/",
|
||||
"/menu/<config_id>",
|
||||
"/menu/<config_id>/<path:subpath>"
|
||||
],
|
||||
auth="public",
|
||||
website=True,
|
||||
sitemap=True,
|
||||
auth="public", website=True, sitemap=True,
|
||||
)
|
||||
def pos_self_order_start(self, config_id: str, at=None):
|
||||
"""
|
||||
The user gets this route from the QR code that they scan at the table
|
||||
:param config_id: the name of the pos config: can be the id or the slugified name of the pos config. (e.g. "3" or "bar-3")
|
||||
:param at: the access token of the table; we call this argument "at" because
|
||||
it will be displayed in the url ( as a query param ), and "at" is more user friendly than "access_token"
|
||||
the user is allowed to order only if this "at" matches the access token of a table
|
||||
:param product_id: the id of the product that the user wants to see the details of;
|
||||
we never actually use this argument in this function ( it will be read by the client side router ),
|
||||
but we still have it here, because otherwise we get a Warning in the logs
|
||||
:return: the rendered template
|
||||
"""
|
||||
config_sudo = get_pos_config_sudo(config_id)
|
||||
table_sudo = get_table_sudo(access_token=at)
|
||||
def pos_self_order_start(self, config_id=None, access_token=None, table_identifier=None):
|
||||
if config_id.isnumeric():
|
||||
pos_config_sudo = request.env["pos.config"].sudo().search([
|
||||
("id", "=", config_id),
|
||||
('access_token', '=', access_token)], limit=1)
|
||||
|
||||
self_order_mode = 'qr_code'
|
||||
table_infos = False
|
||||
pos_config_access_token = False
|
||||
|
||||
if config_sudo.has_active_session and config_sudo.self_order_table_mode and table_sudo:
|
||||
table_infos = table_sudo._get_self_order_data()
|
||||
self_order_mode = config_sudo.self_order_pay_after
|
||||
if pos_config_sudo and pos_config_sudo.has_active_session and pos_config_sudo.self_order_table_mode:
|
||||
self_order_mode = pos_config_sudo.self_order_pay_after
|
||||
pos_config_access_token = pos_config_sudo.access_token
|
||||
table_sudo = get_table_sudo(identifier=table_identifier)
|
||||
table_infos = table_sudo._get_self_order_data() if table_sudo else False
|
||||
else:
|
||||
pos_config_sudo = get_any_pos_config_sudo()
|
||||
|
||||
return request.render(
|
||||
'pos_self_order.index',
|
||||
@@ -66,10 +57,11 @@ class PosQRMenuController(http.Controller):
|
||||
'pos_self_order_data': {
|
||||
'self_order_mode': self_order_mode,
|
||||
'table': table_infos,
|
||||
**config_sudo._get_self_order_data(),
|
||||
'access_token': pos_config_access_token,
|
||||
**pos_config_sudo._get_self_order_data(),
|
||||
},
|
||||
}
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
@http.route(
|
||||
@@ -109,7 +101,7 @@ class PosQRMenuController(http.Controller):
|
||||
:return: the bg image
|
||||
:rtype: binary
|
||||
"""
|
||||
pos_config_sudo = get_pos_config_sudo(pos_config_id)
|
||||
pos_config_sudo = request.env["pos.config"].sudo().browse(pos_config_id)
|
||||
|
||||
if not pos_config_sudo.self_order_image:
|
||||
raise werkzeug.exceptions.NotFound()
|
||||
|
||||
@@ -2,31 +2,17 @@
|
||||
|
||||
from datetime import timedelta
|
||||
import uuid
|
||||
|
||||
from odoo import http, fields, Command
|
||||
from odoo.http import request
|
||||
from odoo.addons.pos_self_order.controllers.utils import (
|
||||
get_pos_config_sudo,
|
||||
get_table_sudo,
|
||||
)
|
||||
|
||||
from werkzeug.exceptions import NotFound, BadRequest, Unauthorized
|
||||
|
||||
class PosSelfOrderController(http.Controller):
|
||||
@http.route("/pos-self-order/process-new-order", auth="public", type="json", website=True)
|
||||
def process_new_order(self, order, table_access_token):
|
||||
pos_config_id = order.get('pos_config_id')
|
||||
def process_new_order(self, order, access_token, table_identifier):
|
||||
lines = order.get('lines')
|
||||
|
||||
pos_config_sudo = get_pos_config_sudo(pos_config_id)
|
||||
pos_config_sudo, table_sudo = self._verify_authorization(access_token, table_identifier)
|
||||
pos_session_sudo = pos_config_sudo.current_session_id
|
||||
table_sudo = get_table_sudo(table_access_token)
|
||||
|
||||
if not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
|
||||
raise Unauthorized
|
||||
if not table_sudo or not pos_session_sudo:
|
||||
raise Unauthorized
|
||||
|
||||
sequence_number = self._get_sequence_number(table_sudo.id, pos_session_sudo.id)
|
||||
unique_id = self._generate_unique_id(pos_session_sudo.id, table_sudo.id, sequence_number)
|
||||
|
||||
@@ -41,18 +27,18 @@ class PosSelfOrderController(http.Controller):
|
||||
'sequence_number': sequence_number,
|
||||
'access_token': uuid.uuid4().hex,
|
||||
'pos_session_id': pos_session_sudo.id,
|
||||
'table_id': table_sudo.id,
|
||||
"partner_id": False,
|
||||
"creation_date": str(fields.Datetime.now()),
|
||||
"fiscal_position_id": pos_config_sudo.default_fiscal_position_id,
|
||||
"statement_ids": [],
|
||||
"lines": [],
|
||||
'table_id': table_sudo.id if table_sudo else False,
|
||||
'partner_id': False,
|
||||
'creation_date': str(fields.Datetime.now()),
|
||||
'fiscal_position_id': pos_config_sudo.default_fiscal_position_id,
|
||||
'statement_ids': [],
|
||||
'lines': [],
|
||||
'amount_tax': 0,
|
||||
'amount_total': 0,
|
||||
'amount_paid': 0,
|
||||
'amount_return': 0,
|
||||
},
|
||||
"to_invoice": False,
|
||||
'to_invoice': False,
|
||||
'session_id': pos_session_sudo.id,
|
||||
}
|
||||
|
||||
@@ -76,11 +62,11 @@ class PosSelfOrderController(http.Controller):
|
||||
return order_sudo._export_for_self_order()
|
||||
|
||||
@http.route('/pos-self-order/get-orders-taxes', auth='public', type='json', website=True)
|
||||
def get_order_taxes(self, order, pos_config_id):
|
||||
pos_config_sudo = get_pos_config_sudo(pos_config_id)
|
||||
def get_order_taxes(self, order, access_token):
|
||||
pos_config_sudo = request.env['pos.config'].sudo().search([('access_token', '=', access_token)], limit=1)
|
||||
|
||||
if not pos_config_sudo or not pos_config_sudo.self_order_table_mode:
|
||||
raise Unauthorized
|
||||
if not pos_config_sudo or not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
|
||||
raise Unauthorized("Invalid access token")
|
||||
|
||||
lines = self._process_lines(order.get('lines'), pos_config_sudo, 0)
|
||||
amount_total, amount_untaxed = self._get_order_prices(lines)
|
||||
@@ -96,21 +82,21 @@ class PosSelfOrderController(http.Controller):
|
||||
}
|
||||
|
||||
@http.route('/pos-self-order/update-existing-order', auth="public", type="json", website=True)
|
||||
def update_existing_order(self, order):
|
||||
order_pos_reference = order.get('pos_reference')
|
||||
def update_existing_order(self, order, access_token, table_identifier):
|
||||
order_id = order.get('id')
|
||||
order_access_token = order.get('access_token')
|
||||
pos_config_id = order.get('pos_config_id')
|
||||
pos_config_sudo = get_pos_config_sudo(pos_config_id)
|
||||
pos_config_sudo, table_sudo = self._verify_authorization(access_token, table_identifier)
|
||||
|
||||
order_sudo = request.env['pos.order'].sudo().search([
|
||||
('pos_reference', '=', order_pos_reference),
|
||||
('id', '=', order_id),
|
||||
('access_token', '=', order_access_token),
|
||||
('table_id', '=', table_sudo.id)
|
||||
])
|
||||
|
||||
if not order_sudo:
|
||||
raise Unauthorized("Order not found in the server !")
|
||||
elif order_sudo.state != 'draft':
|
||||
raise BadRequest("Order is not in draft state")
|
||||
raise Unauthorized("Order is not in draft state")
|
||||
|
||||
lines = self._process_lines(order.get('lines'), pos_config_sudo, order_sudo.id)
|
||||
for line in lines:
|
||||
@@ -135,14 +121,14 @@ class PosSelfOrderController(http.Controller):
|
||||
return order_sudo._export_for_self_order()
|
||||
|
||||
@http.route('/pos-self-order/get-orders', auth='public', type='json', website=True)
|
||||
def get_orders_by_access_token(self, access_tokens):
|
||||
def get_orders_by_access_token(self, order_access_tokens):
|
||||
orders_sudo = request.env["pos.order"].sudo().search([
|
||||
("access_token", "in", access_tokens),
|
||||
("access_token", "in", order_access_tokens),
|
||||
("date_order", ">=", fields.Datetime.now() - timedelta(days=7)),
|
||||
])
|
||||
|
||||
if not orders_sudo:
|
||||
raise NotFound()
|
||||
raise NotFound("Orders not found")
|
||||
|
||||
orders = []
|
||||
for order in orders_sudo:
|
||||
@@ -150,6 +136,20 @@ class PosSelfOrderController(http.Controller):
|
||||
|
||||
return orders
|
||||
|
||||
@http.route('/pos-self-order/get-tables', auth='public', type='json', website=True)
|
||||
def get_tables(self, access_token):
|
||||
pos_config_sudo = request.env['pos.config'].sudo().search([('access_token', '=', access_token)], limit=1)
|
||||
|
||||
if not pos_config_sudo or not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
|
||||
raise Unauthorized("Invalid access token")
|
||||
|
||||
tables = pos_config_sudo.floor_ids.table_ids.filtered(lambda t: t.active).read(['id', 'name', 'identifier', 'floor_id'])
|
||||
|
||||
for table in tables:
|
||||
table['floor_name'] = table.get('floor_id')[1]
|
||||
|
||||
return tables
|
||||
|
||||
def _process_lines(self, lines, pos_config_sudo, pos_order_id):
|
||||
newLines = []
|
||||
pricelist = request.env['product.pricelist'].sudo().browse(pos_config_sudo.pricelist_id.id)
|
||||
@@ -204,10 +204,22 @@ class PosSelfOrderController(http.Controller):
|
||||
|
||||
return f"Self-Order {first_part}-{second_part}-{third_part}"
|
||||
|
||||
def _get_sequence_number(self, table_id: int, session_id: int) -> int:
|
||||
def _get_sequence_number(self, table_id, session_id):
|
||||
order_sudo = request.env["pos.order"].sudo().search([(
|
||||
'pos_reference',
|
||||
'like',
|
||||
f"Self-Order {session_id:0>5}-{table_id:0>3}")], order='id desc', limit=1)
|
||||
|
||||
return (order_sudo.sequence_number + 1) or 1
|
||||
|
||||
def _verify_authorization(self, access_token, table_identifier):
|
||||
table_sudo = request.env["restaurant.table"].sudo().search([('identifier', '=', table_identifier)], limit=1)
|
||||
pos_config_sudo = request.env['pos.config'].sudo().search([('access_token', '=', access_token)], limit=1)
|
||||
|
||||
if not pos_config_sudo or not pos_config_sudo.self_order_table_mode or not pos_config_sudo.has_active_session:
|
||||
raise Unauthorized("Invalid access token")
|
||||
|
||||
if not table_sudo:
|
||||
raise Unauthorized("Table not found")
|
||||
|
||||
return pos_config_sudo, table_sudo
|
||||
|
||||
@@ -5,29 +5,7 @@ import werkzeug
|
||||
from typing import Optional
|
||||
|
||||
from odoo.http import request
|
||||
from odoo.addons.http_routing.models.ir_http import unslug
|
||||
from odoo.addons.pos_self_order.models.pos_config import PosConfig
|
||||
from odoo.addons.pos_restaurant.models.pos_restaurant import RestaurantTable
|
||||
|
||||
|
||||
def get_pos_config_sudo(pos_config_name: str) -> PosConfig:
|
||||
"""
|
||||
Returns the PosConfig if pos_config_id exist and the pos is configured to allow the menu to be viewed online.
|
||||
If not, it raises a NotFound
|
||||
:param pos_config_name: The name of the pos config. Can be the id or the slug. ex: 3 or Bar-3
|
||||
"""
|
||||
return (
|
||||
request.env["pos.config"]
|
||||
.sudo()
|
||||
.search(
|
||||
[
|
||||
("id", "=", unslug(str(pos_config_name))[1]),
|
||||
("self_order_view_mode", "=", True),
|
||||
],
|
||||
limit=1,
|
||||
)
|
||||
) or _raise(werkzeug.exceptions.NotFound())
|
||||
|
||||
|
||||
def get_any_pos_config_sudo() -> PosConfig:
|
||||
"""
|
||||
@@ -39,11 +17,11 @@ def get_any_pos_config_sudo() -> PosConfig:
|
||||
) or _raise(werkzeug.exceptions.NotFound())
|
||||
|
||||
|
||||
def get_table_sudo(access_token: Optional[str]) -> Optional[RestaurantTable]:
|
||||
return access_token and (
|
||||
def get_table_sudo(identifier):
|
||||
return identifier and (
|
||||
request.env["restaurant.table"]
|
||||
.sudo()
|
||||
.search([("access_token", "=", access_token), ("active", "=", True)], limit=1)
|
||||
.search([("identifier", "=", identifier), ("active", "=", True)], limit=1)
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<function model="restaurant.table" name="_update_identifier" />
|
||||
<function model="pos.config" name="_init_access_token" />
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,6 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<function model="restaurant.table" name="_update_access_token" />
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -1,10 +0,0 @@
|
||||
<?xml version="1.0"?>
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="default_custom_link" model="pos_self_order.custom_link">
|
||||
<field name="name">View Menu</field>
|
||||
<field name="pos_config_ids" eval="[(4, ref('pos_restaurant.pos_config_restaurant'))]"/>
|
||||
<field name="url" model="pos.config" eval="'/menu/' + str(obj().env.ref('pos_restaurant.pos_config_restaurant').id) + '/products'" />
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -2,6 +2,7 @@
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from typing import Optional, List, Dict, Callable
|
||||
import uuid
|
||||
from werkzeug.urls import url_quote
|
||||
import base64
|
||||
|
||||
@@ -10,7 +11,6 @@ from odoo import api, fields, models, modules
|
||||
from odoo.tools import file_open, split_every
|
||||
|
||||
from odoo.addons.pos_self_order.models.product_product import ProductProduct
|
||||
from odoo.addons.pos_self_order.models.pos_order import PosOrderLine
|
||||
|
||||
|
||||
class PosConfig(models.Model):
|
||||
@@ -52,6 +52,27 @@ class PosConfig(models.Model):
|
||||
help="Name of the image to display on the self order screen",
|
||||
default=_self_order_default_image_name,
|
||||
)
|
||||
access_token = fields.Char(
|
||||
"Security Token",
|
||||
copy=False,
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self._get_access_token(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_access_token():
|
||||
return uuid.uuid4().hex[:16]
|
||||
|
||||
def _update_access_token(self):
|
||||
self.access_token = self._get_access_token()
|
||||
self.floor_ids.table_ids._update_identifier()
|
||||
|
||||
@api.model
|
||||
def _init_access_token(self):
|
||||
pos_config_ids = self.env["pos.config"].search([])
|
||||
for pos_config_id in pos_config_ids:
|
||||
pos_config_id.access_token = self._get_access_token()
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
@@ -68,13 +89,6 @@ class PosConfig(models.Model):
|
||||
pos_config_id.self_order_view_mode = True
|
||||
pos_config_id.self_order_table_mode = True
|
||||
|
||||
self.env['pos_self_order.custom_link'].create({
|
||||
'url': '/menu/%s/products' % pos_config_id.id,
|
||||
'name': 'View Menu',
|
||||
'pos_config_ids': pos_config_id,
|
||||
'style': 'primary',
|
||||
})
|
||||
|
||||
return pos_config_ids
|
||||
|
||||
@api.depends("module_pos_restaurant")
|
||||
@@ -90,16 +104,19 @@ class PosConfig(models.Model):
|
||||
def _get_self_order_route(self, table_id: Optional[int] = None) -> str:
|
||||
self.ensure_one()
|
||||
base_route = f"/menu/{self.id}"
|
||||
table_route = ""
|
||||
|
||||
if not self.self_order_table_mode:
|
||||
return base_route
|
||||
access_token = (
|
||||
self.env["restaurant.table"]
|
||||
.search(
|
||||
[("active", "=", True), *(table_id and [("id", "=", table_id)] or [])], limit=1
|
||||
)
|
||||
.access_token
|
||||
|
||||
table = self.env["restaurant.table"].search(
|
||||
[("active", "=", True), ("id", "=", table_id)], limit=1
|
||||
)
|
||||
return f"{base_route}?at={access_token}"
|
||||
|
||||
if table:
|
||||
table_route = f"&table_identifier={table.identifier}"
|
||||
|
||||
return f"{base_route}?access_token={self.access_token}{table_route}"
|
||||
|
||||
def _get_self_order_url(self, table_id: Optional[int] = None) -> str:
|
||||
self.ensure_one()
|
||||
|
||||
@@ -23,45 +23,35 @@ class RestaurantFloor(models.Model):
|
||||
class RestaurantTable(models.Model):
|
||||
_inherit = "restaurant.table"
|
||||
|
||||
access_token = fields.Char(
|
||||
identifier = fields.Char(
|
||||
"Security Token",
|
||||
copy=False,
|
||||
required=True,
|
||||
readonly=True,
|
||||
default=lambda self: self._get_access_token(),
|
||||
default=lambda self: self._get_identifier(),
|
||||
)
|
||||
|
||||
@staticmethod
|
||||
def _get_access_token():
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
def _get_self_order_data(self) -> Dict:
|
||||
self.ensure_one()
|
||||
return self.read(["name", "access_token"])[0]
|
||||
return self.read(["name", "identifier"])[0]
|
||||
|
||||
def _get_data_for_qr_codes_page(self, url: Callable[[Optional[int]], str]) -> List[Dict]:
|
||||
return [
|
||||
{
|
||||
"access_token": table.access_token,
|
||||
"id": table.id,
|
||||
"name": table.name,
|
||||
"url": url(table.id),
|
||||
'identifier': table.identifier,
|
||||
'id': table.id,
|
||||
'name': table.name,
|
||||
'url': url(table.id),
|
||||
}
|
||||
for table in self
|
||||
]
|
||||
|
||||
@staticmethod
|
||||
def _get_identifier():
|
||||
return uuid.uuid4().hex[:8]
|
||||
|
||||
@api.model
|
||||
def _update_access_token(self):
|
||||
"""
|
||||
We define a new access token field in this file.
|
||||
There might already be databases that have restaurant.table records.
|
||||
They will now also get an access token each; the problem is that
|
||||
because of the way `default` values work, all those tables that
|
||||
exist in the db will get the same access token.
|
||||
This method will be ran at the moment the pos_self_order module
|
||||
is installed and will thus make sure that every record has a
|
||||
different access token.
|
||||
"""
|
||||
def _update_identifier(self):
|
||||
tables = self.env["restaurant.table"].search([])
|
||||
for table in tables:
|
||||
table.access_token = self._get_access_token()
|
||||
table.identifier = self._get_identifier()
|
||||
|
||||
@@ -0,0 +1,50 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { useSelfOrder } from "@pos_self_order/self_order_service";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
import { groupBy } from "@web/core/utils/arrays";
|
||||
|
||||
export class PopupTable extends Component {
|
||||
static template = "pos_self_order.PopupTable";
|
||||
static props = { selectTable: Function };
|
||||
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.router = useService("router");
|
||||
this.rpc = useService("rpc");
|
||||
this.tables = [];
|
||||
this.state = useState({
|
||||
selectedTable: null,
|
||||
});
|
||||
|
||||
onWillStart(async () => {
|
||||
await this.getTable();
|
||||
});
|
||||
}
|
||||
|
||||
async getTable() {
|
||||
this.tables = await this.rpc("/pos-self-order/get-tables", {
|
||||
access_token: this.selfOrder.access_token,
|
||||
});
|
||||
this.state.selectedTable = this.tables[0]?.id;
|
||||
}
|
||||
|
||||
get availableFloor() {
|
||||
const groupedFloors = groupBy(this.tables, (t) => t.floor_id[0]);
|
||||
return Object.entries(groupedFloors).map(([floorId, tables]) => ({
|
||||
id: floorId,
|
||||
name: tables[0].floor_id[1],
|
||||
tables,
|
||||
}));
|
||||
}
|
||||
|
||||
setTable() {
|
||||
const table = this.tables.find((t) => t.id === parseInt(this.state.selectedTable));
|
||||
this.props.selectTable(table);
|
||||
}
|
||||
|
||||
close() {
|
||||
this.props.selectTable(null);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
.o_self-popup-table {
|
||||
border-radius: 35px 35px 0 0;
|
||||
animation: popupAnimation 0.2s ease-in-out forwards;
|
||||
}
|
||||
|
||||
@keyframes popupAnimation {
|
||||
0% {
|
||||
bottom: -40vh;
|
||||
opacity: 1;
|
||||
}
|
||||
100% {
|
||||
bottom: 0;
|
||||
opacity: 1;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<templates id="template" xml:space="preserve">
|
||||
<t t-name="pos_self_order.PopupTable" owl="1">
|
||||
<div class="position-absolute bg-dark bg-opacity-25 w-100 h-100 fixed-top" />
|
||||
<div class="o_self-popup-table shadow-lg position-absolute fixed-bottom bg-white w-100 p-4 flex-column d-flex justify-content-between">
|
||||
<div class="mb-5 d-flex justify-content-between align-items-start">
|
||||
<div>
|
||||
<h3>Confirm your table</h3>
|
||||
<span>to place the order</span>
|
||||
</div>
|
||||
<button class="btn rounded" t-on-click="close">
|
||||
<i class="fa fa-times"/>
|
||||
</button>
|
||||
</div>
|
||||
<select class="form-select form-select-lg mb-5" t-model="state.selectedTable">
|
||||
<t t-foreach="availableFloor" t-as="floor" t-key="floor.id">
|
||||
<option value="floor" disabled="true">
|
||||
<t t-esc="floor.name" />
|
||||
</option>
|
||||
<option t-foreach="floor.tables" t-as="table" t-key="table.id" t-att-value="table.id">
|
||||
<t t-esc="table.name" />
|
||||
</option>
|
||||
</t>
|
||||
</select>
|
||||
<a
|
||||
type="button"
|
||||
t-on-click="() => this.setTable()"
|
||||
class="btn btn-primary py-3 my-2">
|
||||
Confirm
|
||||
</a>
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -22,9 +22,6 @@ export class ProductCard extends Component {
|
||||
}, 0);
|
||||
}
|
||||
|
||||
// FIXME: we need to verify the product name for future attribute variants
|
||||
// in case of variants, we need to show the main product screen with "add" btn
|
||||
// if the user select the same variant as an existing orderline, we merge it.
|
||||
clickOnProduct() {
|
||||
const product = this.props.product;
|
||||
if (!this.canOpenProductMainView(product)) {
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
<div class="d-flex flex-row">
|
||||
<span
|
||||
class="me-1 text-primary fw-bolder"
|
||||
t-if="quantityInCart and selfOrder.table"
|
||||
t-if="quantityInCart and selfOrder.ordering"
|
||||
t-esc="`${quantityInCart}x`"
|
||||
/>
|
||||
<span class="card-text small" t-esc="getTotalPriceString()" />
|
||||
@@ -29,7 +29,7 @@
|
||||
loading="lazy"
|
||||
onerror="this.remove()"
|
||||
/>
|
||||
<span t-if="quantityInCart and selfOrder.table" class="position-absolute top-0 start-0 h-100 border-start border-5 border-primary" />
|
||||
<span t-if="quantityInCart and selfOrder.ordering" class="position-absolute top-0 start-0 h-100 border-start border-5 border-primary" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
@@ -15,7 +15,13 @@
|
||||
<t t-esc="link.name"/>
|
||||
</a>
|
||||
</t>
|
||||
<t t-if="selfOrder.table and selfOrder.self_order_mode === 'each'">
|
||||
<a
|
||||
type="button"
|
||||
t-on-click="() => this.router.navigate('productList')"
|
||||
class="btn btn-primary py-3 my-2">
|
||||
View Menu
|
||||
</a>
|
||||
<t t-if="selfOrder.ordering and selfOrder.self_order_mode === 'each'">
|
||||
<hr class="bg-500"/>
|
||||
<a
|
||||
type="button"
|
||||
|
||||
@@ -1,20 +1,26 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { Component, onWillStart } from "@odoo/owl";
|
||||
import { Component, onWillStart, useState } from "@odoo/owl";
|
||||
import { NavBar } from "@pos_self_order/components/navbar/navbar";
|
||||
import { ProductCard } from "@pos_self_order/components/product_card/product_card";
|
||||
import { Lines } from "@pos_self_order/components/lines/lines";
|
||||
import { useSelfOrder } from "@pos_self_order/self_order_service";
|
||||
import { PriceDetails } from "@pos_self_order/components/price_details/price_details";
|
||||
import { PopupTable } from "@pos_self_order/components/popup_table/popup_table";
|
||||
import { _t } from "@web/core/l10n/translation";
|
||||
import { useService } from "@web/core/utils/hooks";
|
||||
|
||||
export class OrderCart extends Component {
|
||||
static components = { NavBar, ProductCard, Lines, PriceDetails };
|
||||
static components = { NavBar, ProductCard, Lines, PriceDetails, PopupTable };
|
||||
static props = [];
|
||||
static template = "pos_self_order.OrderCart";
|
||||
setup() {
|
||||
this.selfOrder = useSelfOrder();
|
||||
this.sendInProgress = false;
|
||||
this.router = useService("router");
|
||||
this.state = useState({
|
||||
selectTable: false,
|
||||
});
|
||||
|
||||
onWillStart(() => {
|
||||
this.selfOrder.getPricesFromServer();
|
||||
@@ -25,15 +31,30 @@ export class OrderCart extends Component {
|
||||
return this.selfOrder.self_order_mode === "each" ? "Pay" : "Order";
|
||||
}
|
||||
|
||||
async selectTable(table) {
|
||||
if (table) {
|
||||
this.selfOrder.table = table;
|
||||
this.router.addTableIdentifier(table);
|
||||
await this.processOrder();
|
||||
}
|
||||
this.state.selectTable = false;
|
||||
}
|
||||
|
||||
async processOrder() {
|
||||
if (this.sendInProgress) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!this.selfOrder.table) {
|
||||
this.state.selectTable = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (this.selfOrder.self_order_mode === "meal") {
|
||||
this.sendInProgress = true;
|
||||
try {
|
||||
await this.selfOrder.sendDraftOrderToServer();
|
||||
this.router.navigate("default");
|
||||
} finally {
|
||||
this.sendInProgress = false;
|
||||
}
|
||||
|
||||
@@ -17,6 +17,7 @@
|
||||
already sent
|
||||
</t>
|
||||
</div>
|
||||
<PopupTable t-if="this.state.selectTable" selectTable.bind="selectTable" />
|
||||
</div>
|
||||
</t>
|
||||
</templates>
|
||||
|
||||
@@ -32,7 +32,7 @@ export class OrdersHistory extends Component {
|
||||
}
|
||||
|
||||
editOrder(order) {
|
||||
if (this.selfOrder.self_order_mode === "meal" && order.state === "draft") {
|
||||
if (order.state === "draft") {
|
||||
this.selfOrder.editedOrder = order;
|
||||
this.router.navigate("productList");
|
||||
} else {
|
||||
|
||||
@@ -70,7 +70,7 @@
|
||||
but if there are no products (for example if you search for something that does not exist), the button
|
||||
would be at the top of the page )
|
||||
-->
|
||||
<div t-if="selfOrder.table and selfOrder.currentOrder.totalQuantity" class="d-flex flex-column align-items-center w-100 p-3 fixed-bottom">
|
||||
<div t-if="selfOrder.ordering and selfOrder.currentOrder.totalQuantity" class="d-flex flex-column align-items-center w-100 p-3 fixed-bottom">
|
||||
<t t-set="order" t-value="selfOrder.currentOrder"/>
|
||||
<button t-on-click="() => this.router.navigate('cart')" class="o_self_order_main_button btn btn-primary w-100 px-2 py-3 mt-2 rounded">
|
||||
<div class="d-flex align-items-center justify-content-between mx-2">
|
||||
|
||||
@@ -84,13 +84,13 @@
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<div t-if="selfOrder.table">
|
||||
<div t-if="selfOrder.ordering">
|
||||
<label class="form-label fw-bold" for="note">Add a note:</label>
|
||||
<textarea class="form-control rounded bg-white" id="note" type="textarea" rows="1" placeholder="No onions please" t-model="state.customer_note"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div t-if="selfOrder.table" class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg" style="z-index:1">
|
||||
<div t-if="selfOrder.ordering" class="d-flex flex-column align-items-center w-100 p-3 bg-white shadow-lg" style="z-index:1">
|
||||
<div class="o_self_order_incr_button btn-group text-center border-light mb-1" role="group" aria-label="Quantity select">
|
||||
<button type="button"
|
||||
t-on-click = "() => this.changeQuantity(false)"
|
||||
|
||||
@@ -22,6 +22,12 @@ export class SelfOrderRouter extends Reactive {
|
||||
});
|
||||
}
|
||||
|
||||
addTableIdentifier(table) {
|
||||
const url = new URL(browser.location.href);
|
||||
url.searchParams.append("table_identifier", table.identifier);
|
||||
history.replaceState({}, "", url);
|
||||
}
|
||||
|
||||
back() {
|
||||
if (!this.historyPage.length) {
|
||||
// We use the browser history, so if the user arrives on a page with a back button from a link,
|
||||
@@ -58,12 +64,21 @@ export class SelfOrderRouter extends Reactive {
|
||||
Object.assign(this.registeredRoutes, routes);
|
||||
}
|
||||
|
||||
// If the url isn't a valid URL, we assume it's a relative path
|
||||
customLink(link) {
|
||||
const url = new URL(browser.location.href);
|
||||
url.pathname = link.url;
|
||||
let url = "";
|
||||
|
||||
history.pushState({}, "", url);
|
||||
this.path = window.location.pathname;
|
||||
try {
|
||||
url = new URL(link.url);
|
||||
window.open(url);
|
||||
} catch {
|
||||
url = new URL(browser.location.href);
|
||||
url.pathname = link.url;
|
||||
|
||||
history.pushState({}, "", url);
|
||||
this.path = window.location.pathname;
|
||||
this.historyPage = this.path;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -8,6 +8,7 @@ import { _t } from "@web/core/l10n/translation";
|
||||
import { effect } from "@web/core/utils/reactive";
|
||||
import { Order } from "./models/order";
|
||||
import { Product } from "./models/product";
|
||||
import { Line } from "./models/line";
|
||||
import { ConnectionLostError, RPCError } from "@web/core/network/rpc_service";
|
||||
import { batched } from "@point_of_sale/utils";
|
||||
|
||||
@@ -27,6 +28,7 @@ export class SelfOrder {
|
||||
this.orders = [];
|
||||
this.editedOrder = null;
|
||||
this.productByIds = {};
|
||||
this.ordering = false;
|
||||
this.priceLoading = false;
|
||||
this.currentProduct = 0;
|
||||
this.lastEditedProductId = null;
|
||||
@@ -51,6 +53,8 @@ export class SelfOrder {
|
||||
),
|
||||
{ type: "warning" }
|
||||
);
|
||||
} else {
|
||||
this.ordering = true;
|
||||
}
|
||||
|
||||
if (this.self_order_mode !== "qr_code") {
|
||||
@@ -100,9 +104,7 @@ export class SelfOrder {
|
||||
return this.editedOrder;
|
||||
}
|
||||
|
||||
const existingOrder = this.orders.find(
|
||||
(o) => (this.self_order_mode === "each" && !o.access_token) || o.state === "draft"
|
||||
);
|
||||
const existingOrder = this.orders.find((o) => o.state === "draft");
|
||||
|
||||
if (!existingOrder) {
|
||||
const newOrder = new Order({
|
||||
@@ -124,20 +126,18 @@ export class SelfOrder {
|
||||
|
||||
async sendDraftOrderToServer() {
|
||||
try {
|
||||
let order = {};
|
||||
const rpcUrl = this.currentOrder.isAlreadySent
|
||||
? "/pos-self-order/update-existing-order"
|
||||
: "/pos-self-order/process-new-order";
|
||||
|
||||
if (this.currentOrder.isAlreadySent) {
|
||||
order = await this.rpc(`/pos-self-order/update-existing-order`, {
|
||||
order: this.currentOrder,
|
||||
});
|
||||
} else {
|
||||
order = await this.rpc(`/pos-self-order/process-new-order`, {
|
||||
order: this.currentOrder,
|
||||
table_access_token: this?.table?.access_token,
|
||||
});
|
||||
}
|
||||
const order = await this.rpc(rpcUrl, {
|
||||
order: this.currentOrder,
|
||||
access_token: this.access_token,
|
||||
table_identifier: this.table ? this.table.identifier : null,
|
||||
});
|
||||
|
||||
this.editedOrder = Object.assign(this.editedOrder, order);
|
||||
this.editedOrder.access_token = order.access_token;
|
||||
this.updateOrdersFromServer([order], [this.access_token]);
|
||||
this.editedOrder.computelastChangesSent();
|
||||
|
||||
if (this.self_order_mode === "each") {
|
||||
@@ -145,10 +145,11 @@ export class SelfOrder {
|
||||
}
|
||||
|
||||
this.notification.add(_t("Your order has been placed!"), { type: "success" });
|
||||
|
||||
return order;
|
||||
} catch (error) {
|
||||
this.handleErrorNotification(error, [this.editedOrder.access_token]);
|
||||
} finally {
|
||||
this.router.navigate("default");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -161,35 +162,10 @@ export class SelfOrder {
|
||||
|
||||
try {
|
||||
const orders = await this.rpc(`/pos-self-order/get-orders/`, {
|
||||
access_tokens: accessTokens,
|
||||
order_access_tokens: accessTokens,
|
||||
});
|
||||
|
||||
const ordersToRecreate = {};
|
||||
for (const order of this.orders.filter((o) => o.access_token)) {
|
||||
ordersToRecreate[order.access_token] = {
|
||||
lastChangesSent: order.lastChangesSent,
|
||||
lines: order.lines.filter((l) => !l.id),
|
||||
};
|
||||
}
|
||||
|
||||
for (const index in this.orders) {
|
||||
if (this.orders[index].access_token) {
|
||||
this.orders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
this.orders.push(
|
||||
...orders.map((o) => {
|
||||
const data = ordersToRecreate[o.access_token] || {};
|
||||
const newOrder = new Order({
|
||||
...o,
|
||||
lastChangesSent: data.lastChangesSent ?? {},
|
||||
});
|
||||
newOrder.lines.push(...(data.lines ?? []));
|
||||
return newOrder;
|
||||
})
|
||||
);
|
||||
|
||||
this.updateOrdersFromServer(orders, accessTokens);
|
||||
this.editedOrder = null;
|
||||
} catch (error) {
|
||||
this.handleErrorNotification(
|
||||
@@ -199,6 +175,33 @@ export class SelfOrder {
|
||||
}
|
||||
}
|
||||
|
||||
updateOrdersFromServer(orders, localAccessToken) {
|
||||
//FIXME, if the user refresh the page with not sent, we will lost this data.
|
||||
const accessTokensFromServer = orders.map((order) => order.access_token);
|
||||
|
||||
for (const idx in this.orders) {
|
||||
const order = this.orders[idx];
|
||||
|
||||
if (order.access_token) {
|
||||
const orderFromServer = orders.find((o) => o.access_token === order.access_token);
|
||||
|
||||
if (orderFromServer) {
|
||||
this.orders[idx] = Object.assign(this.orders[idx], orderFromServer);
|
||||
this.orders[idx].lines = orderFromServer.lines.map((l) => new Line(l));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (const index in this.orders) {
|
||||
if (
|
||||
!accessTokensFromServer.includes(this.orders[index].access_token) &&
|
||||
localAccessToken.includes(this.orders[index].access_token)
|
||||
) {
|
||||
this.orders.splice(index, 1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async getPricesFromServer() {
|
||||
this.priceLoading = true;
|
||||
|
||||
@@ -209,7 +212,7 @@ export class SelfOrder {
|
||||
|
||||
const taxes = await this.rpc(`/pos-self-order/get-orders-taxes/`, {
|
||||
order: this.currentOrder,
|
||||
pos_config_id: this.pos_config_id,
|
||||
access_token: this.access_token,
|
||||
});
|
||||
|
||||
for (const line of this.currentOrder.lines) {
|
||||
@@ -229,12 +232,15 @@ export class SelfOrder {
|
||||
|
||||
handleErrorNotification(error, accessToken = []) {
|
||||
let message = _t("An error has occurred");
|
||||
let cleanOrders = false;
|
||||
|
||||
if (error instanceof RPCError) {
|
||||
if (error.data.name === "werkzeug.exceptions.Unauthorized") {
|
||||
message = _t("You're not authorized to perform this action");
|
||||
cleanOrders = true;
|
||||
} else if (error.data.name === "werkzeug.exceptions.NotFound") {
|
||||
message = _t("Orders wasn't found on the server");
|
||||
cleanOrders = true;
|
||||
}
|
||||
} else if (error instanceof ConnectionLostError) {
|
||||
message = _t("Connection lost, please try again later");
|
||||
@@ -244,7 +250,7 @@ export class SelfOrder {
|
||||
type: "danger",
|
||||
});
|
||||
|
||||
if (accessToken) {
|
||||
if (accessToken && cleanOrders) {
|
||||
this.editedOrder = null;
|
||||
|
||||
for (const index in this.orders) {
|
||||
|
||||
@@ -26,7 +26,13 @@ registry.category("web_tour.tours").add("self_order_after_meal_cart_tour", {
|
||||
PosSelf.check.isOrderline("Office Chair Black", "138.58", "kidding"),
|
||||
|
||||
// Send the order to the server
|
||||
// Here it's the first time we send an order to the server, so we check the table.
|
||||
// if the table is not selected, we check that the table selection popup is displayed.
|
||||
// Then we select a table
|
||||
PosSelf.action.clickPrimaryBtn("Order"),
|
||||
PosSelf.check.tablePopupIsShown(),
|
||||
PosSelf.action.selectTable({ id: "1", name: "1" }),
|
||||
PosSelf.action.clickPrimaryBtn("Confirm"),
|
||||
PosSelf.check.isNotification("Your order has been placed!"),
|
||||
|
||||
// Once an order has been sent to the server, the user can no
|
||||
|
||||
@@ -2,6 +2,13 @@
|
||||
|
||||
export const PosSelf = {
|
||||
check: {
|
||||
tablePopupIsShown: () => {
|
||||
return {
|
||||
content: `Check if the select table popup is shown`,
|
||||
trigger: `body:has(.o_self-popup-table)`,
|
||||
run: () => {},
|
||||
};
|
||||
},
|
||||
isNotification: (text) => {
|
||||
return {
|
||||
content: `Check if there is a notification with ${text}`,
|
||||
@@ -92,6 +99,13 @@ export const PosSelf = {
|
||||
trigger: `.btn:contains('${buttonName}')`,
|
||||
};
|
||||
},
|
||||
selectTable(table) {
|
||||
return {
|
||||
content: `Select ${table.name} with value ${table.id}`,
|
||||
trigger: `.o_self-popup-table select:has(option[value='${table.id}'])`,
|
||||
run: `text ${table}`,
|
||||
};
|
||||
},
|
||||
addProduct: (name, quantity = 1, description, attributes) => {
|
||||
return [
|
||||
{
|
||||
@@ -139,7 +153,7 @@ const attributeHelper = (attributes = { radio: {}, select: {}, color: {} }) => {
|
||||
|
||||
if (attributes.select.value) {
|
||||
attributesSteps.push({
|
||||
content: `Select radio ${attributes.select.name} with value ${attributes.select.value}`,
|
||||
content: `Select ${attributes.select.name} with value ${attributes.select.value}`,
|
||||
trigger: `.o_self_order_main_options div:contains('${attributes.select.name}') ~ select:has(option[value='${attributes.select.value}'])`,
|
||||
run: `text ${attributes.select.value}`,
|
||||
});
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import test_controllers
|
||||
from . import test_frontend
|
||||
|
||||
@@ -1,21 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import odoo
|
||||
from odoo.tests import HttpCase
|
||||
|
||||
@odoo.tests.tagged("post_install", "-at_install")
|
||||
class TestSelfOrderControllers(HttpCase):
|
||||
def test_menu_redirect(self):
|
||||
response = self.url_open("/menu")
|
||||
self.assertEqual(response.status_code, 200)
|
||||
self.assertEqual(response.history[0].status_code, 303)
|
||||
chosen_pos_config_id = int(response.url.split("/")[-1])
|
||||
available_pos_config_ids = (
|
||||
self.env["pos.config"].search([("self_order_view_mode", "=", True)]).ids
|
||||
)
|
||||
self.assertTrue(chosen_pos_config_id in available_pos_config_ids)
|
||||
|
||||
self.env["pos.config"].search([]).write({"self_order_view_mode": False})
|
||||
response = self.url_open("/menu")
|
||||
self.assertEqual(response.status_code, 404)
|
||||
@@ -16,6 +16,7 @@ class TestFrontendMobile(odoo.tests.HttpCase):
|
||||
"name": "BarTest",
|
||||
"module_pos_restaurant": True,
|
||||
"self_order_view_mode": True,
|
||||
"floor_ids": self.env["restaurant.floor"].search([]),
|
||||
"self_order_table_mode": False,
|
||||
}
|
||||
)
|
||||
|
||||
@@ -71,9 +71,6 @@
|
||||
<t t-call="pos_self_order.first_letter_of_odoo_logo_svg"/>
|
||||
</div>
|
||||
</div>
|
||||
<div class="text-center">
|
||||
(<t t-esc="table['access_token']"/>)
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
Reference in New Issue
Block a user