[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:
David Monnom (moda)
2023-07-03 21:56:04 +02:00
parent 61ef69a8cf
commit 0ae2b9a3f2
28 changed files with 353 additions and 234 deletions
+1 -2
View File
@@ -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",
+20 -28
View File
@@ -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()
+49 -37
View File
@@ -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
+3 -25
View File
@@ -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>
+32 -15
View File
@@ -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()
+13 -23
View File
@@ -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
View File
@@ -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,
}
)
-3
View File
@@ -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>