From bb9011d54aa693d7c27fa95c692f7102b7cbfd52 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:00:33 -0300 Subject: [PATCH 1/7] [MIG] iot_print_node: Migration to 20.0 --- iot_print_node/__manifest__.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/iot_print_node/__manifest__.py b/iot_print_node/__manifest__.py index 627aec04..3aed0f4c 100644 --- a/iot_print_node/__manifest__.py +++ b/iot_print_node/__manifest__.py @@ -19,7 +19,7 @@ ############################################################################## { "name": "IOT Print Node Printing", - "version": "19.0.0.1.0", + "version": "20.0.1.0.0", "category": "Generic Modules/Base", "author": "ADHOC SA", "license": "AGPL-3", @@ -29,5 +29,5 @@ "views/iot_views.xml", "wizard/add_iot_printnode_views.xml", ], - "installable": False, + "installable": True, } From f454e6f6f34087ec4506639b177c436ebddd91e7 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:01:10 -0300 Subject: [PATCH 2/7] [MIG] iot_print_node: upgrade_code 19.4-00-ir-access --- iot_print_node/__manifest__.py | 2 +- iot_print_node/security/ir.access.csv | 3 +++ iot_print_node/security/ir.model.access.csv | 3 --- 3 files changed, 4 insertions(+), 4 deletions(-) create mode 100644 iot_print_node/security/ir.access.csv delete mode 100644 iot_print_node/security/ir.model.access.csv diff --git a/iot_print_node/__manifest__.py b/iot_print_node/__manifest__.py index 3aed0f4c..c6720a1f 100644 --- a/iot_print_node/__manifest__.py +++ b/iot_print_node/__manifest__.py @@ -25,7 +25,7 @@ "license": "AGPL-3", "depends": ["iot"], "data": [ - "security/ir.model.access.csv", + "security/ir.access.csv", "views/iot_views.xml", "wizard/add_iot_printnode_views.xml", ], diff --git a/iot_print_node/security/ir.access.csv b/iot_print_node/security/ir.access.csv new file mode 100644 index 00000000..4dee50fb --- /dev/null +++ b/iot_print_node/security/ir.access.csv @@ -0,0 +1,3 @@ +id,name,model_id,group_id/id,operation,domain +access_add_iot_printnode_admin,add.iot.printnode.admin,add.iot.printnode,iot.group_iot_admin,crud, +access_add_iot_printnode_user,add.iot.printnode.user,add.iot.printnode,base.group_user,cru, diff --git a/iot_print_node/security/ir.model.access.csv b/iot_print_node/security/ir.model.access.csv deleted file mode 100644 index 3919d708..00000000 --- a/iot_print_node/security/ir.model.access.csv +++ /dev/null @@ -1,3 +0,0 @@ -id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_add_iot_printnode_admin,add.iot.printnode.admin,model_add_iot_printnode,iot.group_iot_admin,1,1,1,1 -access_add_iot_printnode_user,add.iot.printnode.user,model_add_iot_printnode,base.group_user,1,1,1,0 From 13a3a66bbf3944d4fe1dd8cf56741346fb6ffdf7 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:07:25 -0300 Subject: [PATCH 3/7] [MIG] iot_print_node: print through PrintNode as a 20.0 printer type Odoo 20.0 removed ir.actions.report.render_document and the IoT menu the module hooked into: printing now goes through the new printer module (printer.printer + get_print_jobs + printer.type.handlers in the client). As decided by the PO in task 76786 (option A), PrintNode becomes a printer type, following the pattern of Odoo's obox module: - printnode.account replaces the IoT box with an API key; it syncs its printers as printer.printer records of type printnode. - get_print_jobs adds a printnode job (pdf for qweb-pdf/pdf/aeroo, raw for qweb-text) and a client handler sends it with printnode_print. - The module depends on printer instead of iot (no Enterprise needed). - Account and wizard management is restricted to Settings admins, under Settings > Technical > Reporting > PrintNode. --- iot_print_node/__manifest__.py | 11 +- iot_print_node/models/__init__.py | 4 +- iot_print_node/models/iot_box.py | 106 ----------------- iot_print_node/models/iot_device.py | 11 -- iot_print_node/models/ir_actions_report.py | 51 +++++---- iot_print_node/models/printer.py | 37 ++++++ iot_print_node/models/printnode_account.py | 106 +++++++++++++++++ iot_print_node/security/ir.access.csv | 5 +- .../static/src/printnode_printer_handler.js | 12 ++ iot_print_node/tests/__init__.py | 1 + iot_print_node/tests/test_printnode.py | 107 ++++++++++++++++++ iot_print_node/views/iot_views.xml | 12 -- .../views/printnode_account_views.xml | 98 ++++++++++++++++ iot_print_node/wizard/__init__.py | 2 +- iot_print_node/wizard/add_iot_printnode.py | 40 ------- .../wizard/add_iot_printnode_views.xml | 38 ------- .../wizard/add_printnode_account.py | 35 ++++++ .../wizard/add_printnode_account_views.xml | 35 ++++++ 18 files changed, 471 insertions(+), 240 deletions(-) delete mode 100644 iot_print_node/models/iot_box.py delete mode 100644 iot_print_node/models/iot_device.py create mode 100644 iot_print_node/models/printer.py create mode 100644 iot_print_node/models/printnode_account.py create mode 100644 iot_print_node/static/src/printnode_printer_handler.js create mode 100644 iot_print_node/tests/__init__.py create mode 100644 iot_print_node/tests/test_printnode.py delete mode 100644 iot_print_node/views/iot_views.xml create mode 100644 iot_print_node/views/printnode_account_views.xml delete mode 100644 iot_print_node/wizard/add_iot_printnode.py delete mode 100644 iot_print_node/wizard/add_iot_printnode_views.xml create mode 100644 iot_print_node/wizard/add_printnode_account.py create mode 100644 iot_print_node/wizard/add_printnode_account_views.xml diff --git a/iot_print_node/__manifest__.py b/iot_print_node/__manifest__.py index c6720a1f..0f9ba3dd 100644 --- a/iot_print_node/__manifest__.py +++ b/iot_print_node/__manifest__.py @@ -23,11 +23,16 @@ "category": "Generic Modules/Base", "author": "ADHOC SA", "license": "AGPL-3", - "depends": ["iot"], + "depends": ["printer"], "data": [ "security/ir.access.csv", - "views/iot_views.xml", - "wizard/add_iot_printnode_views.xml", + "views/printnode_account_views.xml", + "wizard/add_printnode_account_views.xml", ], + "assets": { + "web.assets_backend": [ + "iot_print_node/static/src/**/*", + ], + }, "installable": True, } diff --git a/iot_print_node/models/__init__.py b/iot_print_node/models/__init__.py index cc0c7a1e..3b0c7fd4 100644 --- a/iot_print_node/models/__init__.py +++ b/iot_print_node/models/__init__.py @@ -1,3 +1,3 @@ -from . import iot_box -from . import iot_device +from . import printnode_account +from . import printer from . import ir_actions_report diff --git a/iot_print_node/models/iot_box.py b/iot_print_node/models/iot_box.py deleted file mode 100644 index ac7ee355..00000000 --- a/iot_print_node/models/iot_box.py +++ /dev/null @@ -1,106 +0,0 @@ -import base64 -import logging -import time -import uuid - -import requests -from odoo import Command, _, api, fields, models -from odoo.exceptions import UserError - -_logger = logging.getLogger(__name__) - -PRINTNODE_URL = "https://api.printnode.com" -TIMEOUT = 20 - - -class IotBox(models.Model): - _inherit = "iot.box" - - print_node_api_key = fields.Char("Print Node API Key", readonly=True) - - def open_homepage(self): - self.ensure_one() - if self.print_node_api_key: - return { - "type": "ir.actions.act_url", - "url": "https://api.printnode.com/app/print", - "target": "new", - } - return super().open_homepage() - - @api.model - def _get_response(self, service, data=None): - request_url = f"{PRINTNODE_URL}/{service}" - headers = { - "authorization": "Basic " + base64.b64encode(self.print_node_api_key.encode("UTF-8")).decode("UTF-8"), - } - try: - if data: - headers["Content-Type"] = "application/json" - response = requests.post(request_url, json=data, headers=headers, timeout=TIMEOUT) - response.raise_for_status() - else: - response = requests.get(request_url, headers=headers, timeout=TIMEOUT) - response.raise_for_status() - except requests.RequestException: - raise UserError(_("Could not connect to print node. Check your configuration")) - return response.json() - - def action_print_update_devices(self): - _logger.info("Updating Print Node Printers") - devices = self._get_response("printers") - device_commands = [] - existing_devices = self.device_ids.mapped("identifier") - - for pn_printer in devices: - vals = { - "name": pn_printer["description"], - "identifier": str(pn_printer["id"]), - "type": "printer", - "connection": "printnode_printer", - "connected_status": "connected" if pn_printer["state"] == "online" else "disconnected", - } - if str(pn_printer["id"]) in existing_devices: - del existing_devices[existing_devices.index(str(pn_printer["id"]))] - - if device_id := self.device_ids.filtered(lambda d: d.identifier == str(pn_printer["id"])): - device_commands.append(Command.update(device_id.id, vals)) - else: - device_commands.append(Command.create(vals)) - - for to_deleted_devices in existing_devices: - device_commands.append( - Command.delete(self.device_ids.filtered(lambda d: d.identifier == to_deleted_devices).id) - ) - self.sudo().device_ids = device_commands - - def _print_node_submit_job(self, printerid, jobtype, content): - """Submit a job to printerid with content of dataUrl. - - Args: - printerid: string, the printer id to submit the job to. - jobtype: string, must match the dictionary keys in content and - content_type. - jobsrc: string, points to source for job. Could be a pathname or - id string. - Returns: - boolean: True = submitted, False = errors. - """ - if jobtype in ["qweb-pdf", "pdf", "aeroo"]: - jobtype = "pdf" - elif jobtype in ["qweb-text"]: - jobtype = "txt" - else: - raise UserError(_("Jobtype %s not implemented for Print Node") % (jobtype)) - - # Generate a unique title using timestamp and microseconds - title = f"odoo_print_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}" - - data = { - "printerId": printerid, - "title": title, - "contentType": "pdf_base64" if jobtype == "pdf" else "raw_base64", - "content": content.decode("utf-8"), - "source": "created by odoo db: %s" % self.env.cr.dbname, - } - return self._get_response("printjobs", data) diff --git a/iot_print_node/models/iot_device.py b/iot_print_node/models/iot_device.py deleted file mode 100644 index 7aa83c89..00000000 --- a/iot_print_node/models/iot_device.py +++ /dev/null @@ -1,11 +0,0 @@ -from odoo import fields, models - - -class IotDevice(models.Model): - _inherit = "iot.device" - - # print_node_api_key = fields.Char('Print Node API Key', readonly=True) - connection = fields.Selection( - selection_add=[("printnode_printer", "Print Node Printer")], - ondelete={"printnode_printer": "set null"}, - ) diff --git a/iot_print_node/models/ir_actions_report.py b/iot_print_node/models/ir_actions_report.py index e9911525..893d88c3 100644 --- a/iot_print_node/models/ir_actions_report.py +++ b/iot_print_node/models/ir_actions_report.py @@ -1,30 +1,31 @@ -from odoo import _, models +import base64 + +from odoo import api, models + +PRINTNODE_JOB_TYPES = { + "qweb-pdf": "pdf", + "pdf": "pdf", + "aeroo": "pdf", + "qweb-text": "txt", +} class IrActionsReport(models.Model): _inherit = "ir.actions.report" - def render_document(self, device_id_list, res_ids, data=None): - result = super().render_document(device_id_list, res_ids, data) - filtered_result = [] - for item in result: - iot_box = self.env["iot.box"].browse(item["iotBoxId"]) - if iot_box.print_node_api_key: - job = iot_box._print_node_submit_job( - printerid=item["deviceIdentifier"], - jobtype=self.report_type, - content=item["document"], - ) - if job: - device = iot_box.device_ids.filtered(lambda d: d.identifier == item["deviceIdentifier"]) - printer_name = device.name if device else _("Printer") - self.env.user._bus_send( - "simple_notification", - { - "type": "success", - "message": _("Print job successfully sent to %s via PrintNode", printer_name), - }, - ) - else: - filtered_result.append(item) - return filtered_result + @api.model + def get_print_jobs(self, report_name, docids, data) -> list: + jobs = super().get_print_jobs(report_name, docids, data) + report = self._get_report(report_name) + if not report.printer_ids.filtered(lambda p: p.type == "printnode"): + return jobs + + printnode_type = PRINTNODE_JOB_TYPES.get(report.report_type) + job = {"type": "printnode", "report_type": report.report_type, "printnode_type": printnode_type} + if printnode_type: + report_data = self._render(report_name, docids, data)[0] + if not report_data: + return jobs + job["report"] = base64.b64encode(report_data).decode() + jobs.append(job) + return jobs diff --git a/iot_print_node/models/printer.py b/iot_print_node/models/printer.py new file mode 100644 index 00000000..e0d173a6 --- /dev/null +++ b/iot_print_node/models/printer.py @@ -0,0 +1,37 @@ +from odoo import fields, models +from odoo.exceptions import UserError + + +class Printer(models.Model): + _inherit = "printer.printer" + + type = fields.Selection(selection_add=[("printnode", "PrintNode")], ondelete={"printnode": "cascade"}) + printnode_account_id = fields.Many2one( + "printnode.account", + string="PrintNode Account", + ondelete="cascade", + index="btree_not_null", + ) + printnode_identifier = fields.Char("PrintNode Identifier", readonly=True) + printnode_status = fields.Selection( + [ + ("disconnected", "Disconnected"), + ("connected", "Connected"), + ], + default="disconnected", + readonly=True, + ) + + def printnode_print(self, job): + """Send a job built by ``ir.actions.report.get_print_jobs`` to PrintNode.""" + self.ensure_one() + if self.type != "printnode" or not self.printnode_account_id: + raise UserError(self.env._("Must be a PrintNode printer")) + jobtype = job.get("printnode_type") + if jobtype not in ("pdf", "txt"): + raise UserError(self.env._("Jobtype %s not implemented for Print Node", job.get("report_type"))) + return self.printnode_account_id.sudo()._print_node_submit_job( + printerid=self.printnode_identifier, + jobtype=jobtype, + content=job["report"], + ) diff --git a/iot_print_node/models/printnode_account.py b/iot_print_node/models/printnode_account.py new file mode 100644 index 00000000..180fa937 --- /dev/null +++ b/iot_print_node/models/printnode_account.py @@ -0,0 +1,106 @@ +import base64 +import logging +import time +import uuid + +import requests +from odoo import Command, api, fields, models +from odoo.exceptions import UserError + +_logger = logging.getLogger(__name__) + +PRINTNODE_URL = "https://api.printnode.com" +TIMEOUT = 20 + + +class PrintnodeAccount(models.Model): + _name = "printnode.account" + _description = "PrintNode Account" + + name = fields.Char(required=True) + print_node_api_key = fields.Char("Print Node API Key", required=True, groups="base.group_system") + printer_ids = fields.One2many("printer.printer", "printnode_account_id", string="Printers") + printer_count = fields.Integer(compute="_compute_printer_count") + + @api.depends("printer_ids") + def _compute_printer_count(self): + for account in self: + account.printer_count = len(account.printer_ids) + + def action_open_printnode(self): + self.ensure_one() + return { + "type": "ir.actions.act_url", + "url": "https://api.printnode.com/app/print", + "target": "new", + } + + def _get_response(self, service, data=None): + self.ensure_one() + request_url = f"{PRINTNODE_URL}/{service}" + api_key = self.sudo().print_node_api_key + headers = { + "authorization": "Basic " + base64.b64encode(api_key.encode("UTF-8")).decode("UTF-8"), + } + try: + if data: + headers["Content-Type"] = "application/json" + response = requests.post(request_url, json=data, headers=headers, timeout=TIMEOUT) + response.raise_for_status() + else: + response = requests.get(request_url, headers=headers, timeout=TIMEOUT) + response.raise_for_status() + except requests.RequestException: + raise UserError(self.env._("Could not connect to print node. Check your configuration")) + return response.json() + + def action_print_update_devices(self): + self.ensure_one() + _logger.info("Updating Print Node Printers") + pn_printers = self._get_response("printers") + printer_commands = [] + existing_identifiers = self.printer_ids.mapped("printnode_identifier") + + for pn_printer in pn_printers: + identifier = str(pn_printer["id"]) + vals = { + "name": pn_printer["description"], + "type": "printnode", + "printnode_identifier": identifier, + "printnode_status": "connected" if pn_printer["state"] == "online" else "disconnected", + } + if identifier in existing_identifiers: + existing_identifiers.remove(identifier) + + printer = self.printer_ids.filtered(lambda p, identifier=identifier: p.printnode_identifier == identifier) + if printer: + printer_commands.append(Command.update(printer.id, vals)) + else: + printer_commands.append(Command.create(vals)) + + for identifier in existing_identifiers: + printer = self.printer_ids.filtered(lambda p, identifier=identifier: p.printnode_identifier == identifier) + printer_commands.append(Command.delete(printer.id)) + self.sudo().printer_ids = printer_commands + + def _print_node_submit_job(self, printerid, jobtype, content): + """Submit a job to printerid with content of dataUrl. + + Args: + printerid: string, the printer id to submit the job to. + jobtype: string, "pdf" or "txt". + content: string, the document encoded in base64. + Returns: + dict: the PrintNode response. + """ + # Generate a unique title using timestamp and microseconds + title = f"odoo_print_{int(time.time() * 1000)}_{uuid.uuid4().hex[:8]}" + + data = { + "printerId": printerid, + "title": title, + "contentType": "pdf_base64" if jobtype == "pdf" else "raw_base64", + "content": content, + "source": f"created by odoo db: {self.env.cr.dbname}", + } + return self._get_response("printjobs", data) diff --git a/iot_print_node/security/ir.access.csv b/iot_print_node/security/ir.access.csv index 4dee50fb..dd4316eb 100644 --- a/iot_print_node/security/ir.access.csv +++ b/iot_print_node/security/ir.access.csv @@ -1,3 +1,4 @@ id,name,model_id,group_id/id,operation,domain -access_add_iot_printnode_admin,add.iot.printnode.admin,add.iot.printnode,iot.group_iot_admin,crud, -access_add_iot_printnode_user,add.iot.printnode.user,add.iot.printnode,base.group_user,cru, +access_add_printnode_account_admin,add.printnode.account.admin,add.printnode.account,base.group_system,crud, +access_printnode_account_admin,printnode.account.admin,printnode.account,base.group_system,crud, +access_printnode_account_user,printnode.account.user,printnode.account,base.group_user,r, diff --git a/iot_print_node/static/src/printnode_printer_handler.js b/iot_print_node/static/src/printnode_printer_handler.js new file mode 100644 index 00000000..2e3306e3 --- /dev/null +++ b/iot_print_node/static/src/printnode_printer_handler.js @@ -0,0 +1,12 @@ +import { _t } from "@web/core/l10n/translation"; +import { registry } from "@web/core/registry"; + +registry.category("printer.type.handlers").add("printnode", async (printer, _duplex, jobs, services) => { + const { orm, notification } = services; + for (const job of jobs.filter((job) => job.type === "printnode")) { + await orm.call("printer.printer", "printnode_print", [printer.id, job]); + notification.add(_t("Print job successfully sent to %s via PrintNode", printer.name), { + type: "success", + }); + } +}); diff --git a/iot_print_node/tests/__init__.py b/iot_print_node/tests/__init__.py new file mode 100644 index 00000000..eeba3717 --- /dev/null +++ b/iot_print_node/tests/__init__.py @@ -0,0 +1 @@ +from . import test_printnode diff --git a/iot_print_node/tests/test_printnode.py b/iot_print_node/tests/test_printnode.py new file mode 100644 index 00000000..0b97aba9 --- /dev/null +++ b/iot_print_node/tests/test_printnode.py @@ -0,0 +1,107 @@ +import base64 +from unittest.mock import MagicMock, patch + +from odoo.exceptions import UserError +from odoo.tests import TransactionCase, tagged + +REQUESTS = "odoo.addons.iot_print_node.models.printnode_account.requests" + + +def _response(payload): + response = MagicMock() + response.json.return_value = payload + return response + + +@tagged("post_install", "-at_install") +class TestPrintnode(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.account = cls.env["printnode.account"].create({"name": "Office", "print_node_api_key": "secret"}) + cls.report = cls.env.ref("base.ir_module_reference_print") + cls.doc = cls.env.ref("base.module_base") + + def _sync(self, pn_printers): + with patch(REQUESTS) as requests_mock: + requests_mock.get.return_value = _response(pn_printers) + self.account.action_print_update_devices() + return requests_mock + + def test_update_devices_creates_updates_and_deletes_printers(self): + requests_mock = self._sync( + [ + {"id": 1, "description": "Zebra", "state": "online"}, + {"id": 2, "description": "HP", "state": "offline"}, + ] + ) + url = requests_mock.get.call_args.args[0] + self.assertEqual(url, "https://api.printnode.com/printers") + auth = requests_mock.get.call_args.kwargs["headers"]["authorization"] + self.assertEqual(auth, "Basic " + base64.b64encode(b"secret").decode()) + + printers = self.account.printer_ids + self.assertEqual(sorted(printers.mapped("printnode_identifier")), ["1", "2"]) + self.assertEqual(set(printers.mapped("type")), {"printnode"}) + zebra = printers.filtered(lambda p: p.printnode_identifier == "1") + self.assertEqual(zebra.name, "Zebra") + self.assertEqual(zebra.printnode_status, "connected") + hp = printers.filtered(lambda p: p.printnode_identifier == "2") + self.assertEqual(hp.printnode_status, "disconnected") + + self._sync([{"id": 1, "description": "Zebra ZD421", "state": "offline"}]) + self.assertEqual(self.account.printer_ids, zebra) + self.assertEqual(zebra.name, "Zebra ZD421") + self.assertEqual(zebra.printnode_status, "disconnected") + self.assertFalse(hp.exists()) + + def test_connection_error_raises_user_error(self): + import requests + + with patch(REQUESTS) as requests_mock: + requests_mock.RequestException = requests.RequestException + requests_mock.get.side_effect = requests.ConnectionError() + with self.assertRaises(UserError): + self.account.action_print_update_devices() + + def test_get_print_jobs_and_print(self): + self._sync([{"id": 7, "description": "Zebra", "state": "online"}]) + printer = self.account.printer_ids + self.report.printer_ids = printer + + with patch.object(type(self.report), "_render", return_value=(b"%PDF-1.4 test", "pdf")): + jobs = self.report.get_print_jobs(self.report.report_name, self.doc.ids, {}) + printnode_jobs = [job for job in jobs if job["type"] == "printnode"] + self.assertEqual(len(printnode_jobs), 1) + job = printnode_jobs[0] + self.assertEqual(job["printnode_type"], "pdf") + self.assertEqual(base64.b64decode(job["report"]), b"%PDF-1.4 test") + + with patch(REQUESTS) as requests_mock: + requests_mock.post.return_value = _response(123) + printer.printnode_print(job) + url = requests_mock.post.call_args.args[0] + self.assertEqual(url, "https://api.printnode.com/printjobs") + payload = requests_mock.post.call_args.kwargs["json"] + self.assertEqual(payload["printerId"], "7") + self.assertEqual(payload["contentType"], "pdf_base64") + self.assertEqual(payload["content"], job["report"]) + + def test_no_job_without_printnode_printer(self): + jobs = self.report.get_print_jobs(self.report.report_name, self.doc.ids, {}) + self.assertFalse([job for job in jobs if job["type"] == "printnode"]) + + def test_unsupported_report_type_raises(self): + self._sync([{"id": 7, "description": "Zebra", "state": "online"}]) + printer = self.account.printer_ids + with self.assertRaises(UserError): + printer.printnode_print({"type": "printnode", "report_type": "qweb-html", "printnode_type": None}) + + def test_wizard_creates_account_and_syncs(self): + wizard = self.env["add.printnode.account"].create({"name": "Warehouse", "print_node_api_key": "key2"}) + with patch(REQUESTS) as requests_mock: + requests_mock.get.return_value = _response([{"id": 9, "description": "Brother", "state": "online"}]) + action = wizard.action_add_printnode_account() + account = self.env["printnode.account"].browse(action["res_id"]) + self.assertEqual(account.name, "Warehouse") + self.assertEqual(account.printer_ids.printnode_identifier, "9") diff --git a/iot_print_node/views/iot_views.xml b/iot_print_node/views/iot_views.xml deleted file mode 100644 index b1ff7f54..00000000 --- a/iot_print_node/views/iot_views.xml +++ /dev/null @@ -1,12 +0,0 @@ - - - iot.box.view.form - iot.box - - - - - - diff --git a/iot_print_node/views/printnode_account_views.xml b/iot_print_node/views/printnode_account_views.xml new file mode 100644 index 00000000..7524a222 --- /dev/null +++ b/iot_print_node/views/printnode_account_views.xml @@ -0,0 +1,98 @@ + + + + + printnode.account.view.form + printnode.account + +
+
+
+ +

+ +

+ + + + + + + + + + + +
+
+
+
+ + + printnode.account.view.list + printnode.account + + + + + + + + + + PrintNode Accounts + printnode.account + list,form + + + + printer.view.form.inherit.printnode + printer.printer + + + + + + + + + type == 'printnode' + type not in ('iot', 'printnode') + + + + + + printer.view.list.inherit.printnode + printer.printer + + + + type not in ('iot', 'printnode') + + + + + + + + + + +
diff --git a/iot_print_node/wizard/__init__.py b/iot_print_node/wizard/__init__.py index 7a275a7d..28922983 100644 --- a/iot_print_node/wizard/__init__.py +++ b/iot_print_node/wizard/__init__.py @@ -1 +1 @@ -from . import add_iot_printnode +from . import add_printnode_account diff --git a/iot_print_node/wizard/add_iot_printnode.py b/iot_print_node/wizard/add_iot_printnode.py deleted file mode 100644 index b7a49b6d..00000000 --- a/iot_print_node/wizard/add_iot_printnode.py +++ /dev/null @@ -1,40 +0,0 @@ -############################################################################## -# For copyright and license notices, see __manifest__.py file in module root -# directory -############################################################################## -from odoo import fields, models - - -class AddIotPrintnode(models.TransientModel): - _name = "add.iot.printnode" - _description = "Add IoT PrintNode" - - name = fields.Char( - required=True, - ) - print_node_api_key = fields.Char( - required=True, - ) - - def action_add_iot_printnode_box(self): - self.ensure_one() - iot_box = ( - self.env["iot.box"] - .sudo() - .create( - { - "name": self.name, - "print_node_api_key": self.print_node_api_key, - "token": self.print_node_api_key, - } - ) - ) - iot_box.action_print_update_devices() - - return { - "type": "ir.actions.act_window", - "res_model": "iot.box", - "res_id": iot_box.id, - "view_mode": "form", - "target": "current", - } diff --git a/iot_print_node/wizard/add_iot_printnode_views.xml b/iot_print_node/wizard/add_iot_printnode_views.xml deleted file mode 100644 index 3db9bf40..00000000 --- a/iot_print_node/wizard/add_iot_printnode_views.xml +++ /dev/null @@ -1,38 +0,0 @@ - - - - - - add.iot.printnode.form - add.iot.printnode - -
- - - - -
-
-
-
-
- - - - Add IoT PrintNode - add.iot.printnode - form - new - - - - - -
diff --git a/iot_print_node/wizard/add_printnode_account.py b/iot_print_node/wizard/add_printnode_account.py new file mode 100644 index 00000000..7e17d82b --- /dev/null +++ b/iot_print_node/wizard/add_printnode_account.py @@ -0,0 +1,35 @@ +############################################################################## +# For copyright and license notices, see __manifest__.py file in module root +# directory +############################################################################## +from odoo import fields, models + + +class AddPrintnodeAccount(models.TransientModel): + _name = "add.printnode.account" + _description = "Add PrintNode Account" + + name = fields.Char( + required=True, + ) + print_node_api_key = fields.Char( + required=True, + ) + + def action_add_printnode_account(self): + self.ensure_one() + account = self.env["printnode.account"].create( + { + "name": self.name, + "print_node_api_key": self.print_node_api_key, + } + ) + account.action_print_update_devices() + + return { + "type": "ir.actions.act_window", + "res_model": "printnode.account", + "res_id": account.id, + "view_mode": "form", + "target": "current", + } diff --git a/iot_print_node/wizard/add_printnode_account_views.xml b/iot_print_node/wizard/add_printnode_account_views.xml new file mode 100644 index 00000000..4a5e89eb --- /dev/null +++ b/iot_print_node/wizard/add_printnode_account_views.xml @@ -0,0 +1,35 @@ + + + + + add.printnode.account.form + add.printnode.account + +
+ + + + +
+
+
+
+
+ + + Add PrintNode + add.printnode.account + form + new + + + + +
From 6493828ac6dce67ba4947bd8c9eaebd27aaef950 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:07:45 -0300 Subject: [PATCH 4/7] [MIG] iot_print_node: sync the README with the PrintNode printer type --- iot_print_node/README.rst | 19 ++++++++++--------- 1 file changed, 10 insertions(+), 9 deletions(-) diff --git a/iot_print_node/README.rst b/iot_print_node/README.rst index a2795150..480a16ad 100644 --- a/iot_print_node/README.rst +++ b/iot_print_node/README.rst @@ -14,7 +14,7 @@ IoT Print Node Printing ========================= -This module integrates PrintNode cloud printing service with Odoo's IoT functionality, allowing you to print documents directly to PrintNode printers without requiring a physical IoT Box. +This module integrates PrintNode cloud printing service with Odoo's printers, adding PrintNode as a printer type, so you can print documents directly to PrintNode printers without requiring an IoT Box. Installation ============ @@ -29,30 +29,31 @@ Configuration To configure this module: -1. Go to **IoT > Add PrintNode** -2. Enter a name for your PrintNode IoT Box +1. Activate the developer mode and go to **Settings > Technical > Reporting > PrintNode > Add PrintNode** +2. Enter a name for your PrintNode account 3. Enter your PrintNode API Key -4. Click **Add** to create the IoT Box and automatically import your printers -5. Your PrintNode printers will be automatically synced with Odoo +4. Click **Add** to create the PrintNode account and automatically import your printers +5. Use **Update printnode printers** on the account to sync them again with Odoo +6. Link each printer to the reports it has to print, from **PrintNode > Printers** or from the report Usage ===== Once configured: -* Your PrintNode printers will appear in the IoT devices list -* You can print reports directly to PrintNode printers from any Odoo document +* Your PrintNode printers will appear in the printers list, with the PrintNode type +* You can print reports directly to PrintNode printers from any Odoo document, choosing the printer when printing * The system will automatically send print jobs to PrintNode cloud service * You'll receive a notification when a print job is successfully sent Features ======== -* **Cloud-based printing**: Print without a physical IoT Box +* **Cloud-based printing**: Print without an IoT Box * **Automatic printer sync**: Printers are automatically imported from your PrintNode account * **Easy setup**: Simple wizard to add PrintNode credentials * **Real-time notifications**: Get notified when print jobs are sent -* **Multiple printer support**: Manage multiple PrintNode printers from a single Odoo instance +* **Multiple printer support**: Manage multiple PrintNode accounts and printers from a single Odoo instance Bug Tracker =========== From c2acce51da65283960a2cc222c2b1f2e127dedb5 Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:08:56 -0300 Subject: [PATCH 5/7] [I18N] iot_print_node: export and translate for 20.0 --- iot_print_node/i18n/es.po | 288 ++++++++++++++++++++++++- iot_print_node/i18n/iot_print_node.pot | 234 ++++++++++++++++---- 2 files changed, 472 insertions(+), 50 deletions(-) diff --git a/iot_print_node/i18n/es.po b/iot_print_node/i18n/es.po index 0132152c..b5e9884d 100644 --- a/iot_print_node/i18n/es.po +++ b/iot_print_node/i18n/es.po @@ -3,13 +3,295 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 15.0\n" +"Project-Id-Version: Odoo Server 20.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2022-04-11 18:15+0000\n" -"PO-Revision-Date: 2022-04-11 18:15+0000\n" +"POT-Creation-Date: 2026-11-13 15:08+0000\n" +"PO-Revision-Date: 2026-11-13 15:08+0000\n" "Language-Team: Spanish (https://www.transifex.com/adhoc/teams/133229/es/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" "Content-Transfer-Encoding: \n" "Language: es\n" "Plural-Forms: nplurals=2; plural=(n != 1);\n" + +#. module: iot_print_node +#: model:ir.module.module,description:base.module_iot_print_node +msgid "" +".. |company| replace:: ADHOC SA\n" +"\n" +".. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png\n" +" :alt: ADHOC SA\n" +" :target: https://www.adhoc.com.ar\n" +"\n" +".. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png\n" +"\n" +".. image:: https://img.shields.io/badge/license-AGPL--3-blue.png\n" +" :target: https://www.gnu.org/licenses/agpl\n" +" :alt: License: AGPL-3\n" +"\n" +"=========================\n" +"IoT Print Node Printing\n" +"=========================\n" +"\n" +"This module integrates PrintNode cloud printing service with Odoo's printers, adding PrintNode as a printer type, so you can print documents directly to PrintNode printers without requiring an IoT Box.\n" +"\n" +"Installation\n" +"============\n" +"\n" +"Before installing this module, you need to:\n" +"\n" +"1. Have a PrintNode account (https://www.printnode.com/)\n" +"2. Get your PrintNode API Key from your PrintNode account settings\n" +"\n" +"Configuration\n" +"=============\n" +"\n" +"To configure this module:\n" +"\n" +"1. Activate the developer mode and go to **Settings > Technical > Reporting > PrintNode > Add PrintNode**\n" +"2. Enter a name for your PrintNode account\n" +"3. Enter your PrintNode API Key\n" +"4. Click **Add** to create the PrintNode account and automatically import your printers\n" +"5. Use **Update printnode printers** on the account to sync them again with Odoo\n" +"6. Link each printer to the reports it has to print, from **PrintNode > Printers** or from the report\n" +"\n" +"Usage\n" +"=====\n" +"\n" +"Once configured:\n" +"\n" +"* Your PrintNode printers will appear in the printers list, with the PrintNode type\n" +"* You can print reports directly to PrintNode printers from any Odoo document, choosing the printer when printing\n" +"* The system will automatically send print jobs to PrintNode cloud service\n" +"* You'll receive a notification when a print job is successfully sent\n" +"\n" +"Features\n" +"========\n" +"\n" +"* **Cloud-based printing**: Print without an IoT Box\n" +"* **Automatic printer sync**: Printers are automatically imported from your PrintNode account\n" +"* **Easy setup**: Simple wizard to add PrintNode credentials\n" +"* **Real-time notifications**: Get notified when print jobs are sent\n" +"* **Multiple printer support**: Manage multiple PrintNode accounts and printers from a single Odoo instance\n" +"\n" +"Bug Tracker\n" +"===========\n" +"\n" +"Bugs are tracked on `GitHub Issues\n" +"`_. In case of trouble, please\n" +"check there if your issue has already been reported. If you spotted it first,\n" +"help us smashing it by providing a detailed and welcomed feedback.\n" +"\n" +"Credits\n" +"=======\n" +"\n" +"Images\n" +"------\n" +"\n" +"* |company| |icon|\n" +"\n" +"Contributors\n" +"------------\n" +"\n" +"Maintainer\n" +"----------\n" +"\n" +"|company_logo|\n" +"\n" +"This module is maintained by the |company|.\n" +"\n" +"To contribute to this module, please visit https://www.adhoc.com.ar.\n" +msgstr "" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form +msgid "Add" +msgstr "Agregar" + +#. module: iot_print_node +#: model:ir.actions.act_window,name:iot_print_node.action_add_printnode_account +#: model:ir.ui.menu,name:iot_print_node.menu_add_printnode_account +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form +msgid "Add PrintNode" +msgstr "Agregar PrintNode" + +#. module: iot_print_node +#: model:ir.model,name:iot_print_node.model_add_printnode_account +msgid "Add PrintNode Account" +msgstr "Agregar cuenta de PrintNode" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form +msgid "Cancel" +msgstr "Cancelar" + +#. module: iot_print_node +#: model:ir.model.fields.selection,name:iot_print_node.selection__printer_printer__printnode_status__connected +msgid "Connected" +msgstr "Conectada" + +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printnode_account.py:0 +msgid "Could not connect to print node. Check your configuration" +msgstr "No se pudo conectar con PrintNode. Revise su configuración" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__create_uid +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__create_date +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: iot_print_node +#: model:ir.model.fields.selection,name:iot_print_node.selection__printer_printer__printnode_status__disconnected +msgid "Disconnected" +msgstr "Desconectada" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__display_name +#: model:ir.model.fields,field_description:iot_print_node.field_ir_actions_report__display_name +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__display_name +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__display_name +msgid "Display Name" +msgstr "Mostrar nombre" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form +msgid "Enter your PrintNode API Key" +msgstr "Ingrese su clave de API de PrintNode" + +#. module: iot_print_node +#: model:ir.model,name:iot_print_node.model_printer_printer +msgid "External Printer" +msgstr "Impresora externa" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__id +#: model:ir.model.fields,field_description:iot_print_node.field_ir_actions_report__id +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__id +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__id +msgid "ID" +msgstr "ID" + +#. module: iot_print_node +#: model:ir.module.module,shortdesc:base.module_iot_print_node +msgid "IOT Print Node Printing" +msgstr "Impresión con PrintNode" + +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "Jobtype %s not implemented for Print Node" +msgstr "El tipo de trabajo %s no está implementado para PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__write_uid +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__write_uid +msgid "Last Updated by" +msgstr "Última actualización por" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__write_date +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__write_date +msgid "Last Updated on" +msgstr "Última actualización el" + +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "Must be a PrintNode printer" +msgstr "Debe ser una impresora de PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__name +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__name +msgid "Name" +msgstr "Nombre" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +msgid "Open PrintNode" +msgstr "Abrir PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__print_node_api_key +msgid "Print Node API Key" +msgstr "Clave de API de PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__print_node_api_key +msgid "Print Node Api Key" +msgstr "Clave de API de PrintNode" + +#. module: iot_print_node +#. odoo-javascript +#: code:addons/iot_print_node/static/src/printnode_printer_handler.js:0 +msgid "Print job successfully sent to %s via PrintNode" +msgstr "Trabajo de impresión enviado correctamente a %s mediante PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields.selection,name:iot_print_node.selection__printer_printer__type__printnode +#: model:ir.ui.menu,name:iot_print_node.menu_printnode +msgid "PrintNode" +msgstr "PrintNode" + +#. module: iot_print_node +#: model:ir.model,name:iot_print_node.model_printnode_account +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__printnode_account_id +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +msgid "PrintNode Account" +msgstr "Cuenta de PrintNode" + +#. module: iot_print_node +#: model:ir.actions.act_window,name:iot_print_node.printnode_account_action +#: model:ir.ui.menu,name:iot_print_node.menu_printnode_account +msgid "PrintNode Accounts" +msgstr "Cuentas de PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__printnode_identifier +msgid "PrintNode Identifier" +msgstr "Identificador de PrintNode" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__printer_count +msgid "Printer Count" +msgstr "Cantidad de impresoras" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__printer_ids +#: model:ir.ui.menu,name:iot_print_node.menu_printnode_printers +msgid "Printers" +msgstr "Impresoras" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__printnode_status +msgid "Printnode Status" +msgstr "Estado en PrintNode" + +#. module: iot_print_node +#: model:ir.model,name:iot_print_node.model_ir_actions_report +msgid "Report Action" +msgstr "Acción de reporte" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__type +msgid "Type" +msgstr "Tipo" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +msgid "Update printnode printers" +msgstr "Actualizar impresoras de PrintNode" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form +msgid "e.g. Office Printer" +msgstr "p. ej. Impresora de la oficina" diff --git a/iot_print_node/i18n/iot_print_node.pot b/iot_print_node/i18n/iot_print_node.pot index e32254c6..d21cfef2 100644 --- a/iot_print_node/i18n/iot_print_node.pot +++ b/iot_print_node/i18n/iot_print_node.pot @@ -4,10 +4,10 @@ # msgid "" msgstr "" -"Project-Id-Version: Odoo Server 19.0+e\n" +"Project-Id-Version: Odoo Server 20.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-03-09 10:50+0000\n" -"PO-Revision-Date: 2026-03-09 10:50+0000\n" +"POT-Creation-Date: 2026-11-13 15:08+0000\n" +"PO-Revision-Date: 2026-11-13 15:08+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -16,125 +16,264 @@ msgstr "" "Plural-Forms: \n" #. module: iot_print_node -#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_iot_printnode_form -msgid "Add" +#: model:ir.module.module,description:base.module_iot_print_node +msgid "" +".. |company| replace:: ADHOC SA\n" +"\n" +".. |company_logo| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-logo.png\n" +" :alt: ADHOC SA\n" +" :target: https://www.adhoc.com.ar\n" +"\n" +".. |icon| image:: https://raw.githubusercontent.com/ingadhoc/maintainer-tools/master/resources/adhoc-icon.png\n" +"\n" +".. image:: https://img.shields.io/badge/license-AGPL--3-blue.png\n" +" :target: https://www.gnu.org/licenses/agpl\n" +" :alt: License: AGPL-3\n" +"\n" +"=========================\n" +"IoT Print Node Printing\n" +"=========================\n" +"\n" +"This module integrates PrintNode cloud printing service with Odoo's printers, adding PrintNode as a printer type, so you can print documents directly to PrintNode printers without requiring an IoT Box.\n" +"\n" +"Installation\n" +"============\n" +"\n" +"Before installing this module, you need to:\n" +"\n" +"1. Have a PrintNode account (https://www.printnode.com/)\n" +"2. Get your PrintNode API Key from your PrintNode account settings\n" +"\n" +"Configuration\n" +"=============\n" +"\n" +"To configure this module:\n" +"\n" +"1. Activate the developer mode and go to **Settings > Technical > Reporting > PrintNode > Add PrintNode**\n" +"2. Enter a name for your PrintNode account\n" +"3. Enter your PrintNode API Key\n" +"4. Click **Add** to create the PrintNode account and automatically import your printers\n" +"5. Use **Update printnode printers** on the account to sync them again with Odoo\n" +"6. Link each printer to the reports it has to print, from **PrintNode > Printers** or from the report\n" +"\n" +"Usage\n" +"=====\n" +"\n" +"Once configured:\n" +"\n" +"* Your PrintNode printers will appear in the printers list, with the PrintNode type\n" +"* You can print reports directly to PrintNode printers from any Odoo document, choosing the printer when printing\n" +"* The system will automatically send print jobs to PrintNode cloud service\n" +"* You'll receive a notification when a print job is successfully sent\n" +"\n" +"Features\n" +"========\n" +"\n" +"* **Cloud-based printing**: Print without an IoT Box\n" +"* **Automatic printer sync**: Printers are automatically imported from your PrintNode account\n" +"* **Easy setup**: Simple wizard to add PrintNode credentials\n" +"* **Real-time notifications**: Get notified when print jobs are sent\n" +"* **Multiple printer support**: Manage multiple PrintNode accounts and printers from a single Odoo instance\n" +"\n" +"Bug Tracker\n" +"===========\n" +"\n" +"Bugs are tracked on `GitHub Issues\n" +"`_. In case of trouble, please\n" +"check there if your issue has already been reported. If you spotted it first,\n" +"help us smashing it by providing a detailed and welcomed feedback.\n" +"\n" +"Credits\n" +"=======\n" +"\n" +"Images\n" +"------\n" +"\n" +"* |company| |icon|\n" +"\n" +"Contributors\n" +"------------\n" +"\n" +"Maintainer\n" +"----------\n" +"\n" +"|company_logo|\n" +"\n" +"This module is maintained by the |company|.\n" +"\n" +"To contribute to this module, please visit https://www.adhoc.com.ar.\n" msgstr "" #. module: iot_print_node -#: model:ir.actions.act_window,name:iot_print_node.action_add_iot_printnode -#: model:ir.model,name:iot_print_node.model_add_iot_printnode -#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_iot_printnode_form -msgid "Add IoT PrintNode" +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form +msgid "Add" msgstr "" #. module: iot_print_node -#: model:ir.ui.menu,name:iot_print_node.menu_add_iot_printnode +#: model:ir.actions.act_window,name:iot_print_node.action_add_printnode_account +#: model:ir.ui.menu,name:iot_print_node.menu_add_printnode_account +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form msgid "Add PrintNode" msgstr "" #. module: iot_print_node -#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_iot_printnode_form +#: model:ir.model,name:iot_print_node.model_add_printnode_account +msgid "Add PrintNode Account" +msgstr "" + +#. module: iot_print_node +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form msgid "Cancel" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_iot_device__connection -msgid "Connection" +#: model:ir.model.fields.selection,name:iot_print_node.selection__printer_printer__printnode_status__connected +msgid "Connected" msgstr "" #. module: iot_print_node #. odoo-python -#: code:addons/iot_print_node/models/iot_box.py:0 +#: code:addons/iot_print_node/models/printnode_account.py:0 msgid "Could not connect to print node. Check your configuration" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__create_uid +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__create_uid +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__create_uid msgid "Created by" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__create_date +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__create_date +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__create_date msgid "Created on" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__display_name -#: model:ir.model.fields,field_description:iot_print_node.field_iot_box__display_name -#: model:ir.model.fields,field_description:iot_print_node.field_iot_device__display_name +#: model:ir.model.fields.selection,name:iot_print_node.selection__printer_printer__printnode_status__disconnected +msgid "Disconnected" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__display_name #: model:ir.model.fields,field_description:iot_print_node.field_ir_actions_report__display_name +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__display_name +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__display_name msgid "Display Name" msgstr "" #. module: iot_print_node -#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_iot_printnode_form +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form msgid "Enter your PrintNode API Key" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__id -#: model:ir.model.fields,field_description:iot_print_node.field_iot_box__id -#: model:ir.model.fields,field_description:iot_print_node.field_iot_device__id -#: model:ir.model.fields,field_description:iot_print_node.field_ir_actions_report__id -msgid "ID" +#: model:ir.model,name:iot_print_node.model_printer_printer +msgid "External Printer" msgstr "" #. module: iot_print_node -#: model:ir.model,name:iot_print_node.model_iot_device -msgid "IOT Device" +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__id +#: model:ir.model.fields,field_description:iot_print_node.field_ir_actions_report__id +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__id +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__id +msgid "ID" msgstr "" #. module: iot_print_node -#: model:ir.model,name:iot_print_node.model_iot_box -msgid "IoT Box" +#: model:ir.module.module,shortdesc:base.module_iot_print_node +msgid "IOT Print Node Printing" msgstr "" #. module: iot_print_node #. odoo-python -#: code:addons/iot_print_node/models/iot_box.py:0 +#: code:addons/iot_print_node/models/printer.py:0 msgid "Jobtype %s not implemented for Print Node" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__write_uid +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__write_uid +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__write_uid msgid "Last Updated by" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__write_date +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__write_date +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__write_date msgid "Last Updated on" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__name +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "Must be a PrintNode printer" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__name +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__name msgid "Name" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_iot_box__print_node_api_key +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +msgid "Open PrintNode" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__print_node_api_key msgid "Print Node API Key" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,field_description:iot_print_node.field_add_iot_printnode__print_node_api_key +#: model:ir.model.fields,field_description:iot_print_node.field_add_printnode_account__print_node_api_key msgid "Print Node Api Key" msgstr "" #. module: iot_print_node -#: model:ir.model.fields.selection,name:iot_print_node.selection__iot_device__connection__printnode_printer -msgid "Print Node Printer" +#. odoo-javascript +#: code:addons/iot_print_node/static/src/printnode_printer_handler.js:0 +msgid "Print job successfully sent to %s via PrintNode" msgstr "" #. module: iot_print_node -#. odoo-python -#: code:addons/iot_print_node/models/ir_actions_report.py:0 -msgid "Print job successfully sent to %s via PrintNode" +#: model:ir.model.fields.selection,name:iot_print_node.selection__printer_printer__type__printnode +#: model:ir.ui.menu,name:iot_print_node.menu_printnode +msgid "PrintNode" msgstr "" #. module: iot_print_node -#. odoo-python -#: code:addons/iot_print_node/models/ir_actions_report.py:0 -msgid "Printer" +#: model:ir.model,name:iot_print_node.model_printnode_account +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__printnode_account_id +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +msgid "PrintNode Account" +msgstr "" + +#. module: iot_print_node +#: model:ir.actions.act_window,name:iot_print_node.printnode_account_action +#: model:ir.ui.menu,name:iot_print_node.menu_printnode_account +msgid "PrintNode Accounts" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__printnode_identifier +msgid "PrintNode Identifier" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__printer_count +msgid "Printer Count" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printnode_account__printer_ids +#: model:ir.ui.menu,name:iot_print_node.menu_printnode_printers +msgid "Printers" +msgstr "" + +#. module: iot_print_node +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__printnode_status +msgid "Printnode Status" msgstr "" #. module: iot_print_node @@ -143,16 +282,17 @@ msgid "Report Action" msgstr "" #. module: iot_print_node -#: model:ir.model.fields,help:iot_print_node.field_iot_device__connection -msgid "Type of connection." +#: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__type +msgid "Type" msgstr "" #. module: iot_print_node -#: model_terms:ir.ui.view,arch_db:iot_print_node.iot_box_view_form +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form msgid "Update printnode printers" msgstr "" #. module: iot_print_node -#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_iot_printnode_form +#: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form +#: model_terms:ir.ui.view,arch_db:iot_print_node.view_add_printnode_account_form msgid "e.g. Office Printer" msgstr "" From 478c73bf5503983b50ece69768365abea25f20ac Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:22:16 -0300 Subject: [PATCH 6/7] [FIX] iot_print_node: render the PrintNode job when printing, as the user get_print_jobs also runs from _read_format, under the sudo of /web/action/load and with active_ids from the client, and from report_action with docids as a recordset. Rendering there sent the report rendered as superuser back to the browser and failed with a recordset. Now get_print_jobs only adds the report and its ids, and printnode_print renders it as the user right before sending it, as render_document did in 19.0. Also: - printnode_print only prints reports linked to the printer. - Only administrators can set the PrintNode account or identifier of a printer: printer.printer is writable by every internal user. - The views add their condition to ip_address instead of replacing the ones of iot and obox. - Add the Reset Linked Printers menu that the IoT app gave in 19.0. --- iot_print_node/models/ir_actions_report.py | 35 ++++---- iot_print_node/models/printer.py | 45 ++++++++-- iot_print_node/tests/test_printnode.py | 84 +++++++++++++++---- .../views/printnode_account_views.xml | 13 ++- 4 files changed, 134 insertions(+), 43 deletions(-) diff --git a/iot_print_node/models/ir_actions_report.py b/iot_print_node/models/ir_actions_report.py index 893d88c3..e59faa3c 100644 --- a/iot_print_node/models/ir_actions_report.py +++ b/iot_print_node/models/ir_actions_report.py @@ -1,31 +1,32 @@ -import base64 - from odoo import api, models -PRINTNODE_JOB_TYPES = { - "qweb-pdf": "pdf", - "pdf": "pdf", - "aeroo": "pdf", - "qweb-text": "txt", -} - class IrActionsReport(models.Model): _inherit = "ir.actions.report" @api.model def get_print_jobs(self, report_name, docids, data) -> list: + """Add a PrintNode job without rendering the report. + + The report is rendered when printing, as the user, in + ``printer.printer.printnode_print``: this method also runs from + ``_read_format`` under the sudo of ``/web/action/load``. + """ jobs = super().get_print_jobs(report_name, docids, data) report = self._get_report(report_name) if not report.printer_ids.filtered(lambda p: p.type == "printnode"): return jobs - printnode_type = PRINTNODE_JOB_TYPES.get(report.report_type) - job = {"type": "printnode", "report_type": report.report_type, "printnode_type": printnode_type} - if printnode_type: - report_data = self._render(report_name, docids, data)[0] - if not report_data: - return jobs - job["report"] = base64.b64encode(report_data).decode() - jobs.append(job) + if isinstance(docids, models.BaseModel): + docids = docids.ids + elif isinstance(docids, int): + docids = [docids] + jobs.append( + { + "type": "printnode", + "report_id": report.id, + "docids": list(docids or []), + "data": data, + } + ) return jobs diff --git a/iot_print_node/models/printer.py b/iot_print_node/models/printer.py index e0d173a6..cecb5ee3 100644 --- a/iot_print_node/models/printer.py +++ b/iot_print_node/models/printer.py @@ -1,5 +1,15 @@ -from odoo import fields, models -from odoo.exceptions import UserError +import base64 + +from odoo import api, fields, models +from odoo.exceptions import AccessError, UserError + +PRINTNODE_JOB_TYPES = { + "qweb-pdf": "pdf", + "pdf": "pdf", + "aeroo": "pdf", + "qweb-text": "txt", +} +PRINTNODE_PROTECTED_FIELDS = {"printnode_account_id", "printnode_identifier"} class Printer(models.Model): @@ -22,16 +32,37 @@ class Printer(models.Model): readonly=True, ) + def _check_printnode_fields_access(self, vals_list): + # printer.printer is writable by every internal user; which PrintNode printer it points to is not + if self.env.su or self.env.is_system(): + return + if any(PRINTNODE_PROTECTED_FIELDS & vals.keys() for vals in vals_list): + raise AccessError(self.env._("Only administrators can link a printer to a PrintNode account")) + + @api.model_create_multi + def create(self, vals_list): + self._check_printnode_fields_access(vals_list) + return super().create(vals_list) + + def write(self, vals): + self._check_printnode_fields_access([vals]) + return super().write(vals) + def printnode_print(self, job): - """Send a job built by ``ir.actions.report.get_print_jobs`` to PrintNode.""" + """Render the report of a job built by ``ir.actions.report.get_print_jobs``, + as the current user, and send it to PrintNode.""" self.ensure_one() if self.type != "printnode" or not self.printnode_account_id: raise UserError(self.env._("Must be a PrintNode printer")) - jobtype = job.get("printnode_type") - if jobtype not in ("pdf", "txt"): - raise UserError(self.env._("Jobtype %s not implemented for Print Node", job.get("report_type"))) + report = self.env["ir.actions.report"].browse(job["report_id"]).exists() + if not report or report.id not in self.sudo().report_ids.ids: + raise UserError(self.env._("The report is not linked to the printer %s", self.name)) + jobtype = PRINTNODE_JOB_TYPES.get(report.report_type) + if not jobtype: + raise UserError(self.env._("Jobtype %s not implemented for Print Node", report.report_type)) + content = report._render(report.report_name, job.get("docids") or [], data=job.get("data"))[0] return self.printnode_account_id.sudo()._print_node_submit_job( printerid=self.printnode_identifier, jobtype=jobtype, - content=job["report"], + content=base64.b64encode(content).decode(), ) diff --git a/iot_print_node/tests/test_printnode.py b/iot_print_node/tests/test_printnode.py index 0b97aba9..e58e2719 100644 --- a/iot_print_node/tests/test_printnode.py +++ b/iot_print_node/tests/test_printnode.py @@ -1,8 +1,8 @@ import base64 from unittest.mock import MagicMock, patch -from odoo.exceptions import UserError -from odoo.tests import TransactionCase, tagged +from odoo.exceptions import AccessError, UserError +from odoo.tests import TransactionCase, new_test_user, tagged REQUESTS = "odoo.addons.iot_print_node.models.printnode_account.requests" @@ -64,38 +64,90 @@ def test_connection_error_raises_user_error(self): with self.assertRaises(UserError): self.account.action_print_update_devices() - def test_get_print_jobs_and_print(self): + def _printnode_printer(self): self._sync([{"id": 7, "description": "Zebra", "state": "online"}]) printer = self.account.printer_ids self.report.printer_ids = printer + return printer - with patch.object(type(self.report), "_render", return_value=(b"%PDF-1.4 test", "pdf")): + def _printnode_jobs(self, jobs): + return [job for job in jobs if job["type"] == "printnode"] + + def test_get_print_jobs_does_not_render(self): + self._printnode_printer() + with patch.object(type(self.report), "_render") as render: jobs = self.report.get_print_jobs(self.report.report_name, self.doc.ids, {}) - printnode_jobs = [job for job in jobs if job["type"] == "printnode"] - self.assertEqual(len(printnode_jobs), 1) - job = printnode_jobs[0] - self.assertEqual(job["printnode_type"], "pdf") - self.assertEqual(base64.b64decode(job["report"]), b"%PDF-1.4 test") + self.assertFalse(render.called) + self.assertEqual( + self._printnode_jobs(jobs), + [{"type": "printnode", "report_id": self.report.id, "docids": self.doc.ids, "data": {}}], + ) - with patch(REQUESTS) as requests_mock: + def test_report_action_with_recordset(self): + self._printnode_printer() + action = self.report.report_action(self.doc) + jobs = self._printnode_jobs(action["context"]["jobs"]) + self.assertEqual(len(jobs), 1) + self.assertEqual(jobs[0]["docids"], self.doc.ids) + + def test_print_renders_as_user_and_sends_to_printnode(self): + printer = self._printnode_printer() + user = new_test_user(self.env, login="printnode_user", groups="base.group_user") + job = self._printnode_jobs(self.report.get_print_jobs(self.report.report_name, self.doc.ids, {}))[0] + render_envs = [] + + def fake_render(report, report_ref, res_ids, data=None): + render_envs.append((report.env.uid, report.env.su, res_ids)) + return b"%PDF-1.4 test", "pdf" + + with ( + patch.object(type(self.report), "_render", autospec=True, side_effect=fake_render), + patch(REQUESTS) as requests_mock, + ): requests_mock.post.return_value = _response(123) - printer.printnode_print(job) + printer.with_user(user).printnode_print(job) + + self.assertEqual(render_envs, [(user.id, False, self.doc.ids)]) url = requests_mock.post.call_args.args[0] self.assertEqual(url, "https://api.printnode.com/printjobs") payload = requests_mock.post.call_args.kwargs["json"] self.assertEqual(payload["printerId"], "7") self.assertEqual(payload["contentType"], "pdf_base64") - self.assertEqual(payload["content"], job["report"]) + self.assertEqual(base64.b64decode(payload["content"]), b"%PDF-1.4 test") def test_no_job_without_printnode_printer(self): jobs = self.report.get_print_jobs(self.report.report_name, self.doc.ids, {}) - self.assertFalse([job for job in jobs if job["type"] == "printnode"]) + self.assertFalse(self._printnode_jobs(jobs)) + + def test_print_report_not_linked_raises(self): + printer = self._printnode_printer() + self.report.printer_ids = False + with self.assertRaises(UserError): + printer.printnode_print({"type": "printnode", "report_id": self.report.id, "docids": self.doc.ids}) def test_unsupported_report_type_raises(self): - self._sync([{"id": 7, "description": "Zebra", "state": "online"}]) - printer = self.account.printer_ids + printer = self._printnode_printer() + self.report.report_type = "qweb-html" with self.assertRaises(UserError): - printer.printnode_print({"type": "printnode", "report_type": "qweb-html", "printnode_type": None}) + printer.printnode_print({"type": "printnode", "report_id": self.report.id, "docids": self.doc.ids}) + + def test_internal_user_cannot_point_printer_to_printnode(self): + printer = self._printnode_printer() + user = new_test_user(self.env, login="printnode_user", groups="base.group_user") + Printer = self.env["printer.printer"].with_user(user) + with self.assertRaises(AccessError): + Printer.create( + { + "name": "Mine", + "type": "printnode", + "printnode_account_id": self.account.id, + "printnode_identifier": "999999", + } + ) + with self.assertRaises(AccessError): + printer.with_user(user).write({"printnode_identifier": "999999"}) + printer.with_user(user).write({"name": "Zebra front"}) + self.assertEqual(printer.name, "Zebra front") def test_wizard_creates_account_and_syncs(self): wizard = self.env["add.printnode.account"].create({"name": "Warehouse", "print_node_api_key": "key2"}) diff --git a/iot_print_node/views/printnode_account_views.xml b/iot_print_node/views/printnode_account_views.xml index 7524a222..ac41a7fd 100644 --- a/iot_print_node/views/printnode_account_views.xml +++ b/iot_print_node/views/printnode_account_views.xml @@ -58,8 +58,8 @@ - type == 'printnode' - type not in ('iot', 'printnode') + + @@ -70,7 +70,7 @@ - type not in ('iot', 'printnode') + @@ -95,4 +95,11 @@ parent="menu_printnode" sequence="20"/> + + From 50c879af92cd14c4ee172d21b30564fa22ef0f0e Mon Sep 17 00:00:00 2001 From: les-adhoc Date: Fri, 13 Nov 2026 12:23:07 -0300 Subject: [PATCH 7/7] [I18N] iot_print_node: translate the PrintNode access and report messages --- iot_print_node/i18n/es.po | 23 +++++++++++++++++++++-- iot_print_node/i18n/iot_print_node.pot | 21 +++++++++++++++++++-- 2 files changed, 40 insertions(+), 4 deletions(-) diff --git a/iot_print_node/i18n/es.po b/iot_print_node/i18n/es.po index b5e9884d..36dccba1 100644 --- a/iot_print_node/i18n/es.po +++ b/iot_print_node/i18n/es.po @@ -5,8 +5,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 20.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-13 15:08+0000\n" -"PO-Revision-Date: 2026-11-13 15:08+0000\n" +"POT-Creation-Date: 2026-11-13 15:22+0000\n" +"PO-Revision-Date: 2026-11-13 15:22+0000\n" "Language-Team: Spanish (https://www.transifex.com/adhoc/teams/133229/es/)\n" "MIME-Version: 1.0\n" "Content-Type: text/plain; charset=UTF-8\n" @@ -214,6 +214,14 @@ msgstr "Debe ser una impresora de PrintNode" msgid "Name" msgstr "Nombre" +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "Only administrators can link a printer to a PrintNode account" +msgstr "" +"Solo los administradores pueden vincular una impresora a una cuenta de " +"PrintNode" + #. module: iot_print_node #: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form msgid "Open PrintNode" @@ -280,6 +288,17 @@ msgstr "Estado en PrintNode" msgid "Report Action" msgstr "Acción de reporte" +#. module: iot_print_node +#: model:ir.ui.menu,name:iot_print_node.menu_printnode_reset_linked_printers +msgid "Reset Linked Printers" +msgstr "Restablecer impresoras vinculadas" + +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "The report is not linked to the printer %s" +msgstr "El reporte no está vinculado a la impresora %s" + #. module: iot_print_node #: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__type msgid "Type" diff --git a/iot_print_node/i18n/iot_print_node.pot b/iot_print_node/i18n/iot_print_node.pot index d21cfef2..9d58b1a5 100644 --- a/iot_print_node/i18n/iot_print_node.pot +++ b/iot_print_node/i18n/iot_print_node.pot @@ -6,8 +6,8 @@ msgid "" msgstr "" "Project-Id-Version: Odoo Server 20.0+e\n" "Report-Msgid-Bugs-To: \n" -"POT-Creation-Date: 2026-11-13 15:08+0000\n" -"PO-Revision-Date: 2026-11-13 15:08+0000\n" +"POT-Creation-Date: 2026-11-13 15:22+0000\n" +"PO-Revision-Date: 2026-11-13 15:22+0000\n" "Last-Translator: \n" "Language-Team: \n" "MIME-Version: 1.0\n" @@ -215,6 +215,12 @@ msgstr "" msgid "Name" msgstr "" +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "Only administrators can link a printer to a PrintNode account" +msgstr "" + #. module: iot_print_node #: model_terms:ir.ui.view,arch_db:iot_print_node.printnode_account_view_form msgid "Open PrintNode" @@ -281,6 +287,17 @@ msgstr "" msgid "Report Action" msgstr "" +#. module: iot_print_node +#: model:ir.ui.menu,name:iot_print_node.menu_printnode_reset_linked_printers +msgid "Reset Linked Printers" +msgstr "" + +#. module: iot_print_node +#. odoo-python +#: code:addons/iot_print_node/models/printer.py:0 +msgid "The report is not linked to the printer %s" +msgstr "" + #. module: iot_print_node #: model:ir.model.fields,field_description:iot_print_node.field_printer_printer__type msgid "Type"