diff --git a/.copier-answers.yml b/.copier-answers.yml index b57fe4d..e6fc0de 100644 --- a/.copier-answers.yml +++ b/.copier-answers.yml @@ -1,5 +1,5 @@ # Do NOT update manually; changes here will be overwritten by Copier -_commit: v1.27 +_commit: v1.29 _src_path: https://github.com/OCA/oca-addons-repo-template.git ci: GitHub convert_readme_fragments_to_markdown: false diff --git a/.github/workflows/pre-commit.yml b/.github/workflows/pre-commit.yml index 38b0ba1..afd7524 100644 --- a/.github/workflows/pre-commit.yml +++ b/.github/workflows/pre-commit.yml @@ -13,13 +13,13 @@ jobs: pre-commit: runs-on: ubuntu-22.04 steps: - - uses: actions/checkout@v3 - - uses: actions/setup-python@v2 + - uses: actions/checkout@v4 + - uses: actions/setup-python@v5 with: python-version: "3.11" - name: Get python version run: echo "PY=$(python -VV | sha256sum | cut -d' ' -f1)" >> $GITHUB_ENV - - uses: actions/cache@v1 + - uses: actions/cache@v4 with: path: ~/.cache/pre-commit key: pre-commit|${{ env.PY }}|${{ hashFiles('.pre-commit-config.yaml') }} diff --git a/.github/workflows/test.yml b/.github/workflows/test.yml index 7d6c04f..a1fa8ac 100644 --- a/.github/workflows/test.yml +++ b/.github/workflows/test.yml @@ -14,7 +14,7 @@ jobs: runs-on: ubuntu-latest name: Detect unreleased dependencies steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 - run: | for reqfile in requirements.txt test-requirements.txt ; do if [ -f ${reqfile} ] ; then @@ -50,7 +50,7 @@ jobs: ports: - 5432:5432 steps: - - uses: actions/checkout@v3 + - uses: actions/checkout@v4 with: persist-credentials: false - name: Install addons and dependencies diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml index b84e492..19edb62 100644 --- a/.pre-commit-config.yaml +++ b/.pre-commit-config.yaml @@ -150,7 +150,7 @@ repos: - --header - "# generated from manifests external_dependencies" - repo: https://github.com/PyCQA/flake8 - rev: 3.9.2 + rev: 5.0.0 hooks: - id: flake8 name: flake8 diff --git a/queue_management/views/queue_location.xml b/queue_management/views/queue_location.xml index 5fe70d4..095c92a 100644 --- a/queue_management/views/queue_location.xml +++ b/queue_management/views/queue_location.xml @@ -40,12 +40,9 @@ name='token_location_cancelled_ids' context="{'location_id': id}" /> - - -
diff --git a/queue_management/views/queue_token.xml b/queue_management/views/queue_token.xml index 8127a69..eb0df01 100644 --- a/queue_management/views/queue_token.xml +++ b/queue_management/views/queue_token.xml @@ -39,7 +39,6 @@ -
diff --git a/queue_management_display/README.rst b/queue_management_display/README.rst new file mode 100644 index 0000000..94bb92a --- /dev/null +++ b/queue_management_display/README.rst @@ -0,0 +1,9 @@ +.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg + :target: https://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 + +======================== +Queue Management Display +======================== + +Management of queue displays diff --git a/queue_management_display/__init__.py b/queue_management_display/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/queue_management_display/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/queue_management_display/__manifest__.py b/queue_management_display/__manifest__.py new file mode 100644 index 0000000..2da68f2 --- /dev/null +++ b/queue_management_display/__manifest__.py @@ -0,0 +1,27 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +{ + "name": "Queue Management Display", + "version": "16.0.1.0.0", + "license": "AGPL-3", + "website": "https://github.com/tegin/kiwi", + "author": "CreuBlanca", + "depends": ["queue_management", "bus", "base_sparse_field"], + "data": [ + "views/queue_token_location.xml", + "views/queue_location.xml", + "security/ir.model.access.csv", + "views/queue_display.xml", + "views/queue_management.xml", + ], + # "qweb": ["static/src/xml/queue_management.xml"], + "demo": ["demo/data.xml"], + "assets": { + "web.assets_backend": [ + "/queue_management_display/static/src/**/*.esm.js", + "/queue_management_display/static/src/**/*.xml", + "/queue_management_display/static/src/**/*.scss", + ] + }, +} diff --git a/queue_management_display/demo/data.xml b/queue_management_display/demo/data.xml new file mode 100644 index 0000000..bcdc154 --- /dev/null +++ b/queue_management_display/demo/data.xml @@ -0,0 +1,14 @@ + + + + + Display DEMO + Display CreuBlanca + + + diff --git a/queue_management_display/models/__init__.py b/queue_management_display/models/__init__.py new file mode 100644 index 0000000..6d46e31 --- /dev/null +++ b/queue_management_display/models/__init__.py @@ -0,0 +1,6 @@ +from . import queue_display +from . import queue_location +from . import queue_token_location +from . import queue_location_group +from . import queue_token_location_action +from . import ir_websocket diff --git a/queue_management_display/models/ir_websocket.py b/queue_management_display/models/ir_websocket.py new file mode 100644 index 0000000..7acd959 --- /dev/null +++ b/queue_management_display/models/ir_websocket.py @@ -0,0 +1,36 @@ +import re + +from odoo import models +from odoo.exceptions import AccessDenied + + +class IrWebsocket(models.AbstractModel): + _inherit = "ir.websocket" + + def _build_bus_channel_list(self, channels): + if self.env.uid: + # Do not alter original list. + channels = list(channels) + for channel in channels: + if isinstance(channel, str): + match = re.match(r"queue.display:(\d+)", channel) + if match: + res_id = int(match[1]) + + # Verify access to the edition channel. + if not self.env.user._is_internal(): + raise AccessDenied() + + document = self.env["queue.display"].browse([res_id]) + if not document.exists(): + continue + + document.check_access_rights("read") + document.check_access_rule("read") + document.check_access_rights("write") + document.check_access_rule("write") + + channels.append( + (self.env.registry.db_name, document._name, document.id) + ) + return super()._build_bus_channel_list(channels) diff --git a/queue_management_display/models/queue_display.py b/queue_management_display/models/queue_display.py new file mode 100644 index 0000000..6f608f8 --- /dev/null +++ b/queue_management_display/models/queue_display.py @@ -0,0 +1,138 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from datetime import timedelta + +from lxml import etree + +from odoo import api, fields, models + + +class QueueDisplay(models.Model): + + _name = "queue.display" + _description = "Queue Display" # TODO + + name = fields.Char() + description = fields.Text() + location_ids = fields.Many2many("queue.location") + show_items = fields.Integer(default=10) + max_time = fields.Float(default=24) # Time maximum + shiny_time = fields.Float(default=0.05) # By default, 3 minutes + items = fields.Serialized(compute="_compute_items") + kind = fields.Selection( + [("notification", "Notification screen"), ("control", "Control Screen")], + default="notification", + required=True, + ) + qweb = fields.Text(default=lambda r: r._default_qweb()) + parsed_qweb = fields.Html(compute="_compute_parsed_qweb") + css = fields.Text() + audio_file = fields.Binary() + audio_filename = fields.Char() + + def get_data(self): + self.ensure_one() + return { + "id": self.id, + "name": self.name, + "description": self.description, + "parsed_qweb": self.parsed_qweb, + "items": self.items, + "css": self.css, + "audio_file": self.audio_file, + "shiny_time": self.shiny_time, + "max_time": self.max_time, + "show_items": self.show_items, + } + + @api.depends("qweb") + def _compute_parsed_qweb(self): + qweb = self.env["ir.qweb"] + for record in self: + record.parsed_qweb = qweb._render( + etree.fromstring(record.qweb), {"data": self} + ) + + def _default_qweb(self): + return """ +
+ +
+

+

+
+
+
+
+
+
+
+ + Token + Location + + +
+
+
+
+ +
+
+ + """ + + def open_display(self): + """ + Open XML id depending on wich type os self.kind you have choosed. + """ + self.ensure_one() + action = self.env["ir.actions.act_window"]._for_xml_id( + "queue_management_display.queue_display_fullscreen_%s_act_window" + % self.kind + ) + action["res_id"] = self.id + return action + + @api.depends() + def _compute_items(self): + for record in self: + record.items = {"tokens": record._get_display_tokens()} + + def _get_display_tokens(self): + actions = self.env["queue.token.location.action"].search( + [ + ("location_id", "in", self.location_ids.ids), + ( + "date", + ">", + fields.Datetime.now() + timedelta(hours=-self.max_time), + ), + ("action", "=", "call"), + ], + order="date desc", + ) + token_locations = self.env["queue.token.location"] + final_actions = self.env["queue.token.location.action"] + for action in actions: + if action.token_location_id not in token_locations: + token_locations |= action.token_location_id + final_actions |= action + if len(token_locations) >= self.show_items: + break + return [ + { + "id": action.token_location_id.id, + "token": action.token_id.name, + "location": action.location_id.display_description + or action.location_id.name, + "last_call": fields.Datetime.to_string(action.date), + "last_call_int": action.date.timestamp(), + } + for action in final_actions + ] diff --git a/queue_management_display/models/queue_location.py b/queue_management_display/models/queue_location.py new file mode 100644 index 0000000..fe391c5 --- /dev/null +++ b/queue_management_display/models/queue_location.py @@ -0,0 +1,12 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class QueueLocation(models.Model): + + _inherit = "queue.location" + + display_ids = fields.Many2many("queue.display") + display_description = fields.Char() diff --git a/queue_management_display/models/queue_location_group.py b/queue_management_display/models/queue_location_group.py new file mode 100644 index 0000000..19e8219 --- /dev/null +++ b/queue_management_display/models/queue_location_group.py @@ -0,0 +1,9 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class QueueLocationGroup(models.Model): + + _inherit = "queue.location.group" diff --git a/queue_management_display/models/queue_token_location.py b/queue_management_display/models/queue_token_location.py new file mode 100644 index 0000000..eb4ab52 --- /dev/null +++ b/queue_management_display/models/queue_token_location.py @@ -0,0 +1,110 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, fields, models +from odoo.exceptions import ValidationError + + +class QueueTokenLocation(models.Model): + + _inherit = "queue.token.location" + last_call = fields.Datetime(readonly=True) + expected_location_id = fields.Many2one("queue.location") + + def action_call(self): + self.ensure_one() + if self.expected_location_id and not self.env.context.get( + "ignore_expected_location" + ): + action = self.env["ir.actions.act_window"]._for_xml_id( + "queue_management_display.queue_token_location_force_call_act_window" + ) + action["res_id"] = self.id + action["context"] = self.env.context + return action + location = self.env["queue.location"].browse( + self.env.context.get("location_id") + ) + self._action_call(location) + + def _action_call(self, location): + if not location: + raise ValidationError(_("Location is required")) + if self.token_id.state != "in-progress": + raise ValidationError(_("You cannot call a non draft item")) + if self.group_id and location not in self.group_id.location_ids: + raise ValidationError(_("Location is not in the assigned group")) + if self.location_id and self.location_id != location: + raise ValidationError( + _( + "You cannot call a token from a different location \ + than the assigned location" + ) + ) + previous_call_token = self.search( + [("expected_location_id", "=", location.id), ("state", "=", "draft")] + ) + if previous_call_token: + previous_call_token.write({"expected_location_id": False}) + if not location.multiple_token_management: + any_assing_token = self.search( + [ + ("state", "=", "in-progress"), + ("location_id", "=", location.id), + ("id", "!=", self.id), + ] + ) + if any_assing_token: + raise ValidationError( + _( + "There is a token assigned in this location. Please, close it first." + ) + ) + + # We cannot call an already assigned token + for record in self: + if self.search( + [ + ("token_id", "=", record.token_id.id), + ("state", "=", "in-progress"), + ("id", "!=", self.id), + ], + limit=1, + ): + raise ValidationError( + _( + "Token %s already has an assigned location.\ + You cannot call it, close it first" + ) + % record.token_id.name + ) + self.write(self._call_action_vals(location)) + action = self._add_action_log("call", location) + self.env["bus.bus"]._sendmany( + self._get_channel_call_notifications(location, action) + ) + + def _get_channel_call_notifications(self, location, action): + notifications = [] + for display in location.display_ids: + if display.kind == "notification": + notifications.append( + ( + display, + "%s:%s" % (display._name, display.id), + { + "id": self.id, + "token": self.token_id.name, + "last_call": fields.Datetime.to_string(action.date), + "last_call_int": action.date.timestamp(), + "location": location.display_description or location.name, + }, + ) + ) + return notifications + + def _call_action_vals(self, location): + return { + "expected_location_id": location.id, + "last_call": fields.Datetime.now(), + } diff --git a/queue_management_display/models/queue_token_location_action.py b/queue_management_display/models/queue_token_location_action.py new file mode 100644 index 0000000..30fb08e --- /dev/null +++ b/queue_management_display/models/queue_token_location_action.py @@ -0,0 +1,11 @@ +# Copyright 2022 CreuBlanca +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import fields, models + + +class QueueTokenLocationAction(models.Model): + + _inherit = "queue.token.location.action" + + action = fields.Selection(selection_add=[("call", "Call")]) diff --git a/queue_management_display/security/ir.model.access.csv b/queue_management_display/security/ir.model.access.csv new file mode 100644 index 0000000..df0580c --- /dev/null +++ b/queue_management_display/security/ir.model.access.csv @@ -0,0 +1,4 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +queue_display_planner,queue.display Admin,model_queue_display,queue_management.group_queue_planner,1,0,0,0 +queue_display_processor,queue.display Admin,model_queue_display,queue_management.group_queue_processor,1,0,0,0 +queue_display_admin,queue.display Admin,model_queue_display,queue_management.group_queue_admin,1,1,1,1 diff --git a/queue_management_display/static/description/8mmo6OUh_400x400.jpg b/queue_management_display/static/description/8mmo6OUh_400x400.jpg new file mode 100644 index 0000000..172c017 Binary files /dev/null and b/queue_management_display/static/description/8mmo6OUh_400x400.jpg differ diff --git a/queue_management_display/static/description/icon.png b/queue_management_display/static/description/icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/queue_management_display/static/description/icon.png differ diff --git a/queue_management_display/static/src/components/display_dashboard.esm.js b/queue_management_display/static/src/components/display_dashboard.esm.js new file mode 100644 index 0000000..b92bc0b --- /dev/null +++ b/queue_management_display/static/src/components/display_dashboard.esm.js @@ -0,0 +1,97 @@ +/** @odoo-module **/ + +import {qweb} from "web.core"; +import {registry} from "@web/core/registry"; +import {useService} from "@web/core/utils/hooks"; + +const {Component, useState, onWillStart, useRef, onMounted} = owl; + +export class QueueDisplayDashboard extends Component { + setup() { + this.orm = useService("orm"); + this.bus_service = useService("bus_service"); + this.state = useState({ + title: "", + items: {}, + data: {}, + }); + this.res_id = this.props.action.context.active_id; + this.audio = useRef("audio"); + this.channel = "queue.display:" + this.res_id; + this.env.services.bus_service.addChannel(this.channel); + this.env.services.bus_service.addEventListener( + "notification", + this._onNotificationsReceived.bind(this) + ); + this.env.services.bus_service.start(); + this.container = useRef("container"); + + onWillStart(async () => { + const data = await this.orm.call("queue.display", "get_data", [ + [parseInt(this.res_id, 10)], + ]); + this.data = data; + const tokens = {}; + for (let i = 0; i < data.items.tokens.length; i++) { + const token = data.items.tokens[i]; + tokens[token.id] = token; + } + this.state.items = tokens; + }); + onMounted(() => { + this.container.el.innerHTML = this.data.parsed_qweb; + this._parseItems(); + setInterval(this._parseItems.bind(this), 15000); + }); + } + _onNotificationsReceived({detail: notifications}) { + for (const notification of notifications) { + if (notification.type === this.channel) { + this._onNotificationReceived(notification.payload); + } + } + } + _onNotificationReceived(payload) { + this.state.items[payload.id] = payload; + this._parseItems(); + if (this.audio.el) { + this.audio.el.play().catch((error) => { + console.error("Audio play error:", error); + }); + } + } + _parseItems() { + const body = $(this.container.el).find( + ".o_queue_management_display_body_content_body" + ); + body.empty(); + const shiny_max_time = Date.now() / 1000 - this.data.shiny_time * 3600; + const min_time = Date.now() / 1000 - this.data.max_time * 3600; + let shown = 0; + const items = {}; + for (const item of Object.values(this.state.items) + .filter((a) => a.last_call_int > min_time) + .sort(function (a, b) { + return a.last_call_int > b.last_call_int ? -1 : 1; + })) { + if (shown < this.data.show_items) { + items[item.id] = item; + shown += 1; + const $item = $( + qweb.render("queue_management_display.QueueDisplayItem", { + item, + shiny: item.last_call_int > shiny_max_time, + }) + ); + body.append($item); + } + } + this.state.items = items; + } +} + +QueueDisplayDashboard.template = "queue_management_display.QueueDisplayDashboard"; + +registry + .category("actions") + .add("queue_management_display.queue_display_dashboard", QueueDisplayDashboard); diff --git a/queue_management_display/static/src/components/display_dashboard.xml b/queue_management_display/static/src/components/display_dashboard.xml new file mode 100644 index 0000000..9e697b0 --- /dev/null +++ b/queue_management_display/static/src/components/display_dashboard.xml @@ -0,0 +1,26 @@ + + +