diff --git a/setup/shopfloor_base/odoo/addons/shopfloor_base b/setup/shopfloor_base/odoo/addons/shopfloor_base new file mode 120000 index 0000000000..0b4c5711d2 --- /dev/null +++ b/setup/shopfloor_base/odoo/addons/shopfloor_base @@ -0,0 +1 @@ +../../../../shopfloor_base \ No newline at end of file diff --git a/setup/shopfloor_base/setup.py b/setup/shopfloor_base/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/shopfloor_base/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shopfloor_base/README.rst b/shopfloor_base/README.rst new file mode 100644 index 0000000000..e3841bd858 --- /dev/null +++ b/shopfloor_base/README.rst @@ -0,0 +1,185 @@ +============== +Shopfloor Base +============== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-LGPL--3-blue.png + :target: http://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fwms-lightgray.png?logo=github + :target: https://github.com/OCA/wms/tree/14.0/shopfloor_base + :alt: OCA/wms +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/wms-14-0/wms-14-0-shopfloor_base + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runbot-Try%20me-875A7B.png + :target: https://runbot.odoo-community.org/runbot/285/14.0 + :alt: Try me on Runbot + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Shopfloor is a barcode scanner application. + +This module provides REST APIs to support scenario. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by ``shopfloor_mobile_base``. + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +Shopfloor config menu +~~~~~~~~~~~~~~~~~~~~~ + +In the main menu (or home screen) click on "Shopfloor". + + +Profiles +~~~~~~~~ + +In Shopfloor / Profiles. + +The profiles are used to restrict which menus are shown on the frontend +application. When a user logs in the scanner application, they have to +select their profile, so the correct menus are shown. + +Menus +~~~~~ + +In Shopfloor / Menus. + +The menus are displayed on the frontend application. +The configuration may come from the menu itself +and/or from the scenario linked to it. + +Their profile will restrict the visibility to the profile chosen on the device. +If a menu has no profile, it is shown in every profile. + +Some scenario may have additional options. + + +Scenario +~~~~~~~~ + +In Shopfloor / Scenario. + +A Scenario represents a flow (or more basically "something to do" with the app. +Each scenario must have a name and a unique key. +The key must match a registered shopfloor service component. + +Usage +===== + +An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DAC + +Curl example:: + + curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" + +Known issues / Roadmap +====================== + +* improve documentation +* change shopfloor.scenario.key to selection? See comment in model + +Changelog +========= + +13.0.1.0.0 +~~~~~~~~~~ + +First official version. + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Camptocamp +* BCIM +* Akretion + +Contributors +~~~~~~~~~~~~ + +* Guewen Baconnier +* Simone Orsi +* Sébastien Alix +* Alexandre Fayolle +* Benoit Guillot +* Thierry Ducrest +* Michael Tietz (MT Software) + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux + +Other credits +~~~~~~~~~~~~~ + +**Financial support** + +* Cosanum +* Camptocamp R&D +* Akretion R&D +* ACSONE R&D + +**Icons** + +* Tablet app icon by Gregor Cresnar from the Noun Project + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use. + +.. |maintainer-guewen| image:: https://github.com/guewen.png?size=40px + :target: https://github.com/guewen + :alt: guewen +.. |maintainer-simahawk| image:: https://github.com/simahawk.png?size=40px + :target: https://github.com/simahawk + :alt: simahawk +.. |maintainer-sebalix| image:: https://github.com/sebalix.png?size=40px + :target: https://github.com/sebalix + :alt: sebalix + +Current `maintainers `__: + +|maintainer-guewen| |maintainer-simahawk| |maintainer-sebalix| + +This module is part of the `OCA/wms `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/shopfloor_base/__init__.py b/shopfloor_base/__init__.py new file mode 100644 index 0000000000..6a34e5681f --- /dev/null +++ b/shopfloor_base/__init__.py @@ -0,0 +1,4 @@ +from . import controllers +from . import models +from . import actions +from . import services diff --git a/shopfloor_base/__manifest__.py b/shopfloor_base/__manifest__.py new file mode 100644 index 0000000000..3846aaade1 --- /dev/null +++ b/shopfloor_base/__manifest__.py @@ -0,0 +1,41 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# Copyright 2020 BCIM +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +{ + "name": "Shopfloor Base", + "summary": "Core module for creating mobile apps", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Inventory", + "website": "https://github.com/OCA/wms", + "author": "Camptocamp, BCIM, Akretion, Odoo Community Association (OCA)", + "maintainers": ["guewen", "simahawk", "sebalix"], + "license": "LGPL-3", + "application": True, + "depends": [ + "jsonifier", + "base_rest", + "component", + "base_sparse_field", + "endpoint_route_handler", + ], + "data": [ + "data/module_category_data.xml", + "data/server_action.xml", + "security/groups.xml", + "security/ir.model.access.csv", + "views/shopfloor_app.xml", + "views/shopfloor_menu.xml", + "views/shopfloor_scenario_views.xml", + "views/shopfloor_profile_views.xml", + "views/menus.xml", + ], + "demo": [ + "demo/res_users_demo.xml", + "demo/shopfloor_scenario_demo.xml", + "demo/shopfloor_menu_demo.xml", + "demo/shopfloor_profile_demo.xml", + ], +} diff --git a/shopfloor_base/actions/__init__.py b/shopfloor_base/actions/__init__.py new file mode 100644 index 0000000000..3a3a98907f --- /dev/null +++ b/shopfloor_base/actions/__init__.py @@ -0,0 +1,36 @@ +""" +Support actions available from any Service Components. + +To use an Action Component, a Service component + +Difference with Service components: + +* Public methods of a Service Components are exposed in the REST API, + Action Components are never exposed + +An Action component can be get from Service or Action Components using +``self._actions_for(usage)``. + +The goal of the Action Components is to share common actions +and processes between Services, avoid having too much logic in +Services. + +""" +from . import base_action +from . import data +from . import data_detail +from . import schema +from . import schema_detail +from . import message +from . import search +from . import savepoint +from . import lock + +# TODO: kept in shopfloor -> review if these must stay there +# from . import change_package_lot +# from . import schema_detail +# from . import completion_info +# from . import location_content_transfer_sorter +# from . import move_line_search +# from . import stock +# from . import inventory diff --git a/shopfloor_base/actions/base_action.py b/shopfloor_base/actions/base_action.py new file mode 100644 index 0000000000..2e44fdf1d6 --- /dev/null +++ b/shopfloor_base/actions/base_action.py @@ -0,0 +1,51 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.component.core import AbstractComponent, WorkContext + + +def get_actions_collection(env): + return _PseudoCollection("shopfloor.action", env) + + +def get_actions_for( + component_instance, usage, propagate_kwargs=None, collection=None, **kw +): + """Return an Action Component for a usage. + + Action Components are the components supporting the business logic of + the processes, so we can limit the code in services and other components + to the minimum and share methods. + """ + propagate_kwargs = component_instance.work._propagate_kwargs[:] + ( + propagate_kwargs or [] + ) + # propagate custom arguments (such as menu ID/profile ID) + kwargs = { + attr_name: getattr(component_instance.work, attr_name) + for attr_name in propagate_kwargs + if attr_name not in ("collection", "components_registry") + and hasattr(component_instance.work, attr_name) + } + kwargs.update(kw) + actions_collection = collection or get_actions_collection(component_instance.env) + work = WorkContext(collection=actions_collection, **kwargs) + return work.component(usage=usage) + + +class ShopFloorProcessAction(AbstractComponent): + """Base Component for actions""" + + _name = "shopfloor.process.action" + _collection = "shopfloor.action" + _usage = "actions" + + def _actions_for(self, usage): + return self.component(usage=usage) + + @property + def msg_store(self): + return self._actions_for("message") diff --git a/shopfloor_base/actions/data.py b/shopfloor_base/actions/data.py new file mode 100644 index 0000000000..1576fa26c1 --- /dev/null +++ b/shopfloor_base/actions/data.py @@ -0,0 +1,41 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.component.core import Component + +from ..utils import ensure_model + + +class DataAction(Component): + """Provide methods to share data structures + + The methods should be used in Service Components, so we try to + have similar data structures across scenarios. + """ + + _name = "shopfloor.data.action" + _inherit = "shopfloor.process.action" + _usage = "data" + + def _jsonify(self, recordset, parser, multi=False, **kw): + # TODO: drop this ctx flag for v15 as `jsonifier` makes it default + res = recordset.with_context(jsonifier__date_user_tz=False).jsonify(parser) + if not multi: + return res[0] if res else None + return res + + def _simple_record_parser(self): + return ["id", "name"] + + def _select_value_to_label(self, rec, fname): + return rec._fields[fname].convert_to_export(rec[fname], rec) + + @ensure_model("res.partner") + def partner(self, record, **kw): + return self._jsonify(record, self._partner_parser, **kw) + + def partners(self, record, **kw): + return self.partner(record, multi=True) + + @property + def _partner_parser(self): + return ["id", "display_name:name"] diff --git a/shopfloor_base/actions/data_detail.py b/shopfloor_base/actions/data_detail.py new file mode 100644 index 0000000000..a02fbd10b7 --- /dev/null +++ b/shopfloor_base/actions/data_detail.py @@ -0,0 +1,12 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.addons.component.core import Component + + +class DataDetailAction(Component): + """Provide extra data on top of data action.""" + + _name = "shopfloor.data.detail.action" + _inherit = "shopfloor.data.action" + _usage = "data_detail" diff --git a/shopfloor_base/actions/lock.py b/shopfloor_base/actions/lock.py new file mode 100644 index 0000000000..6c5148ada7 --- /dev/null +++ b/shopfloor_base/actions/lock.py @@ -0,0 +1,32 @@ +# Copyright 2022 Michael Tietz (MT Software) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import hashlib +import struct + +from odoo.addons.component.core import Component + + +class LockAction(Component): + """Provide methods to create database locks""" + + _name = "shopfloor.lock.action" + _inherit = "shopfloor.process.action" + _usage = "lock" + + def advisory(self, name): + """ + Create a blocking advisory lock + The lock is released at the commit or rollback of the transaction. + """ + hasher = hashlib.sha1(str(name).encode()) + # pg_lock accepts an int8 so we build an hash composed with + # contextual information and we throw away some bits + int_lock = struct.unpack("q", hasher.digest()[:8]) + + self.env.cr.execute("SELECT pg_advisory_xact_lock(%s);", (int_lock,)) + self.env.cr.fetchone()[0] + + def for_update(self, records, log_exceptions=False): + """Lock a table FOR UPDATE""" + sql = "SELECT id FROM %s WHERE ID IN %%s FOR UPDATE" % records._table + self.env.cr.execute(sql, (tuple(records.ids),), log_exceptions=False) diff --git a/shopfloor_base/actions/message.py b/shopfloor_base/actions/message.py new file mode 100644 index 0000000000..4c8e111772 --- /dev/null +++ b/shopfloor_base/actions/message.py @@ -0,0 +1,48 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import _ + +from odoo.addons.component.core import Component + + +class MessageAction(Component): + """Provide message templates + + The methods should be used in Service Components, in order to share as much + as possible the messages for similar events. + + Before adding a message, please look if no message already exists, + and consider making an existing message more generic. + """ + + _name = "shopfloor.message.action" + _inherit = "shopfloor.process.action" + _usage = "message" + + def generic_record_not_found(self): + return { + "message_type": "error", + "body": _("Record not found."), + } + + # TODO: we should probably have `shopfloor.message` records + # and here we can simply lookup for a message using its identifier + # Eg: + # + # def _get_message(self, key): + # domain = [ + # ("type", "=", "action"), + # ("key", "=", key), + # ] + # return self.env["shopfloor.message"].search(domain) + # + # def message(self, key, **kw): + # msg = self._get_message(key) + # return { + # "type": msg.type, + # "body": msg.body % kw + # } + # + # then all depending modules can simply create records they need + # instea of overriding and polluting the component. + # Additional goodie: users can edit messages via UI. diff --git a/shopfloor_base/actions/savepoint.py b/shopfloor_base/actions/savepoint.py new file mode 100644 index 0000000000..848f6dd85f --- /dev/null +++ b/shopfloor_base/actions/savepoint.py @@ -0,0 +1,44 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import uuid + +from psycopg2 import sql + +from odoo.addons.component.core import Component + + +class SavepointBuilder(Component): + """Return a new Savepoint instance""" + + _name = "shopfloor.savepoint.action" + _inherit = "shopfloor.process.action" + _usage = "savepoint" + + def new(self): + return Savepoint(self.env.cr) + + +class Savepoint(object): + """Wrapper for SQL Savepoint + + Close to "cr.savepoint()" context manager but this class gives more control + over when the release/rollback are called. + """ + + def __init__(self, cr): + self._cr = cr + self.name = uuid.uuid1().hex + self._cr.flush() + self._execute("SAVEPOINT {}") + + def rollback(self): + self._cr.clear() + self._execute("ROLLBACK TO SAVEPOINT {}") + + def release(self): + self._cr.flush() + self._execute("RELEASE SAVEPOINT {}") + + def _execute(self, query): + # pylint: disable=sql-injection + self._cr.execute(sql.SQL(query).format(sql.Identifier(self.name))) diff --git a/shopfloor_base/actions/schema.py b/shopfloor_base/actions/schema.py new file mode 100644 index 0000000000..02c8e76996 --- /dev/null +++ b/shopfloor_base/actions/schema.py @@ -0,0 +1,58 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.component.core import Component + + +class SchemaAction(Component): + """Provide methods to share schema structures + + The methods should be used in Service Components, so we try to + have similar schema structures across scenario. + """ + + _inherit = "shopfloor.process.action" + _name = "shopfloor.schema.action" + _usage = "schema" + + def _schema_list_of(self, schema, **kw): + schema = { + "type": "list", + "nullable": True, + "required": True, + "schema": {"type": "dict", "schema": schema}, + } + schema.update(kw) + return schema + + def _simple_record(self, **kw): + schema = { + "id": {"required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } + schema.update(kw) + return schema + + def _schema_dict_of(self, schema, **kw): + schema = { + "type": "dict", + "nullable": True, + "required": True, + "schema": schema, + } + schema.update(kw) + return schema + + def _schema_search_results_of(self, schema, **kw): + return { + "size": {"required": True, "type": "integer"}, + "records": { + "type": "list", + "required": True, + "schema": {"type": "dict", "schema": schema}, + }, + } + + def menu_item_counters(self, **kw): + return {} diff --git a/shopfloor_base/actions/schema_detail.py b/shopfloor_base/actions/schema_detail.py new file mode 100644 index 0000000000..905ba6cc1e --- /dev/null +++ b/shopfloor_base/actions/schema_detail.py @@ -0,0 +1,11 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.component.core import Component + + +class SchemaDetailAction(Component): + """Provide advanced details.""" + + _inherit = "shopfloor.schema.action" + _name = "shopfloor.schema.detail.action" + _usage = "schema_detail" diff --git a/shopfloor_base/actions/search.py b/shopfloor_base/actions/search.py new file mode 100644 index 0000000000..c87b5fbdeb --- /dev/null +++ b/shopfloor_base/actions/search.py @@ -0,0 +1,15 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.component.core import Component + + +class SearchAction(Component): + """Provide methods to search records from scanner + + The methods should be used in Service Components, so a search will always + have the same result in all scenarios. + """ + + _name = "shopfloor.search.action" + _inherit = "shopfloor.process.action" + _usage = "search" diff --git a/shopfloor_base/apispec/__init__.py b/shopfloor_base/apispec/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/shopfloor_base/apispec/service_apispec.py b/shopfloor_base/apispec/service_apispec.py new file mode 100644 index 0000000000..de21fabb9e --- /dev/null +++ b/shopfloor_base/apispec/service_apispec.py @@ -0,0 +1,46 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.http import request + +from odoo.addons.base_rest.apispec.base_rest_service_apispec import ( + BaseRestServiceAPISpec, +) +from odoo.addons.base_rest.apispec.rest_method_security_plugin import ( + RestMethodSecurityPlugin, +) + + +class ShopfloorRestServiceAPISpec(BaseRestServiceAPISpec): + """ + Describe APIspec for Shopfloor services. + """ + + def _get_servers(self): + try: + # Get always current base URL (supports localhost:8069 too!) + base_url = request.httprequest.host_url + except (RuntimeError, UnboundLocalError): + # Gracefully fallback to settings (mostly for tests) + # or in any other cases you call this method w/out a proper request. + env = self._service.env + base_url = env["ir.config_parameter"].sudo().get_param("web.base.url") + return [ + { + "url": "%s/%s/%s" + % ( + base_url.rstrip("/"), + self._service.collection.api_route.strip("/"), + self._service._usage, + ) + } + ] + + def _get_plugins(self): + plugins = super()._get_plugins() + for plugin in plugins: + if isinstance(plugin, RestMethodSecurityPlugin): + # Add `user_endpoint` to auth types + plugin._supported_user_auths = ("user", "user_endpoint") + break + return plugins diff --git a/shopfloor_base/controllers/__init__.py b/shopfloor_base/controllers/__init__.py new file mode 100644 index 0000000000..eb748308e2 --- /dev/null +++ b/shopfloor_base/controllers/__init__.py @@ -0,0 +1 @@ +from . import api_docs diff --git a/shopfloor_base/controllers/api_docs.py b/shopfloor_base/controllers/api_docs.py new file mode 100644 index 0000000000..595516b37d --- /dev/null +++ b/shopfloor_base/controllers/api_docs.py @@ -0,0 +1,69 @@ +# Copyright 2018 ACSONE SA/NV +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). + +from odoo.http import request, route + +from odoo.addons.base_rest.controllers.api_docs import ( + ApiDocsController as BaseController, +) + + +class ApiDocsController(BaseController): + @route( + [ + "/shopfloor/api-docs/", + "/shopfloor/api-docs//index.html", + ], + methods=["GET"], + type="http", + auth="public", + ) + def shopfloor_index(self, collection, **params): + primary_name = params.get("urls.primaryName") + swagger_settings = { + "urls": self._get_shopfloor_api_urls(collection), + "urls.primaryName": primary_name, + } + values = {"swagger_settings": swagger_settings} + return request.render("base_rest.openapi", values) + + @route( + [ + "/shopfloor/api-docs/" + "/.json", + ], + auth="public", + ) + def shopfloor_api(self, collection, service_name): + services = collection._get_services() + try: + service = [x for x in services if x._usage == service_name][0] + except IndexError: + return request.not_found() + collection._prepare_non_decorated_endpoints(service) + service.work.collection = collection + openapi_doc = service.to_openapi(default_auth=collection.auth_type) + return self.make_json_response(openapi_doc) + + def _get_api_urls(self): + api_urls = super()._get_api_urls() + # Inject shopfloor docs into global docs from base_rest + return api_urls + self._get_shopfloor_api_urls() + + def _get_shopfloor_api_urls(self, app=None): + """Retrieve shopfloor related URLs for all apps or only given one.""" + api_urls = [] + base_url = request.httprequest.host_url.rstrip("/") + env = request.env + apps = env["shopfloor.app"].sudo().search([]) if not app else [app] + for app in apps: + base_docs_url = f"{base_url}/{app.api_docs_url.strip('/')}" + for service in app._get_services(): + api_urls.append( + { + "name": f"[Shopfloor app] {app.name}: {service._usage}", + "url": f"{base_docs_url}/{service._usage}.json", + } + ) + api_urls = sorted(api_urls, key=lambda k: k["name"]) + return api_urls diff --git a/shopfloor_base/controllers/main.py b/shopfloor_base/controllers/main.py new file mode 100644 index 0000000000..8f1003b5be --- /dev/null +++ b/shopfloor_base/controllers/main.py @@ -0,0 +1,34 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.http import request + +from odoo.addons.base_rest.controllers.main import RestController + + +class ShopfloorController(RestController): + def _process_endpoint( + self, + app_id, + service_name, + service_method_name, + *args, + collection=None, + **kwargs + ): + """Wrapper for `_process_method` call. + + Behavior is the same for the methods automatically + generated by `rest.service.registration`. + """ + collection = collection or request.env["shopfloor.app"].browse(app_id) + # TODO: in base_rest `*args` is passed based on + # the type of route (eg: //update) + return self._process_method( + service_name, + service_method_name, + *args, + collection=collection, + params=kwargs + ) diff --git a/shopfloor_base/data/module_category_data.xml b/shopfloor_base/data/module_category_data.xml new file mode 100644 index 0000000000..2f5a178be9 --- /dev/null +++ b/shopfloor_base/data/module_category_data.xml @@ -0,0 +1,7 @@ + + + + Shopfloor + 90 + + diff --git a/shopfloor_base/data/server_action.xml b/shopfloor_base/data/server_action.xml new file mode 100644 index 0000000000..712142d2ad --- /dev/null +++ b/shopfloor_base/data/server_action.xml @@ -0,0 +1,13 @@ + + + + Sync registry + ir.actions.server + + + code + +records.filtered(lambda x: not x.registry_sync).write({"registry_sync": True}) + + + diff --git a/shopfloor_base/demo/res_users_demo.xml b/shopfloor_base/demo/res_users_demo.xml new file mode 100644 index 0000000000..3e2e461b3d --- /dev/null +++ b/shopfloor_base/demo/res_users_demo.xml @@ -0,0 +1,8 @@ + + + + + diff --git a/shopfloor_base/demo/shopfloor_menu_demo.xml b/shopfloor_base/demo/shopfloor_menu_demo.xml new file mode 100644 index 0000000000..2e26e1d3f2 --- /dev/null +++ b/shopfloor_base/demo/shopfloor_menu_demo.xml @@ -0,0 +1,7 @@ + + + Simple Thing To Do + 20 + + + diff --git a/shopfloor_base/demo/shopfloor_profile_demo.xml b/shopfloor_base/demo/shopfloor_profile_demo.xml new file mode 100644 index 0000000000..8c96f116b4 --- /dev/null +++ b/shopfloor_base/demo/shopfloor_profile_demo.xml @@ -0,0 +1,9 @@ + + + + Demo Profile 1 + + + Demo Profile 2 + + diff --git a/shopfloor_base/demo/shopfloor_scenario_demo.xml b/shopfloor_base/demo/shopfloor_scenario_demo.xml new file mode 100644 index 0000000000..38c25b44f8 --- /dev/null +++ b/shopfloor_base/demo/shopfloor_scenario_demo.xml @@ -0,0 +1,6 @@ + + + Demo scenario 1 + demo_scenario_1 + + diff --git a/shopfloor_base/i18n/es_AR.po b/shopfloor_base/i18n/es_AR.po new file mode 100644 index 0000000000..a1784abb0d --- /dev/null +++ b/shopfloor_base/i18n/es_AR.po @@ -0,0 +1,495 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 13.0\n" +"Report-Msgid-Bugs-To: \n" +"PO-Revision-Date: 2022-11-09 22:45+0000\n" +"Last-Translator: Ignacio Buioli \n" +"Language-Team: none\n" +"Language: es_AR\n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: nplurals=2; plural=n != 1;\n" +"X-Generator: Weblate 4.14.1\n" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__api_route +msgid "" +"\n" +" Base route for endpoints attached to this app,\n" +" internal controller-ready version.\n" +" " +msgstr "" +"\n" +" Ruta base para endpoints adjuntos a esta aplicación,\n" +" versión para controlador interno lista.\n" +" " + +#. module: shopfloor_base +#: code:addons/shopfloor_base/services/forms/form_mixin.py:0 +#, python-format +msgid "%s updated." +msgstr "%s actualizado." + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "Una Aplicación de Taller" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__active +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__active +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__active +msgid "Active" +msgstr "Activo" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +msgid "All" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__api_docs_url +msgid "Api Docs Url" +msgstr "Api Docs Url" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__api_route +msgid "Api Route" +msgstr "Ruta de Api" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__app_version +msgid "App Version" +msgstr "Versión de la App" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_profile_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_profile_search_view +msgid "Archived" +msgstr "Archivado" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Auth" +msgstr "Auth" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__auth_type +msgid "Auth Type" +msgstr "Tipo de Auth" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__lang_ids +msgid "Available languages" +msgstr "Idiomas disponibles" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__category +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +msgid "Category" +msgstr "Categoría" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_scenario__options_edit +msgid "Configure options via JSON" +msgstr "Configurar opciones vía JSON" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__create_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__create_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__create_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__create_uid +msgid "Created by" +msgstr "Creado por" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__create_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__create_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__create_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__create_date +msgid "Created on" +msgstr "Creado el" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__lang_id +msgid "Default language" +msgstr "Idioma predeterminado" + +#. module: shopfloor_base +#: model:shopfloor.profile,name:shopfloor_base.profile_demo_1 +msgid "Demo Profile 1" +msgstr "Perfil de Demo 1" + +#. module: shopfloor_base +#: model:shopfloor.profile,name:shopfloor_base.profile_demo_2 +msgid "Demo Profile 2" +msgstr "Perfil de Demo 2" + +#. module: shopfloor_base +#: model:shopfloor.scenario,name:shopfloor_base.shopfloor_scenario_demo_1 +msgid "Demo scenario 1" +msgstr "Escenario de demostración 1" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Developer" +msgstr "Desarrollador" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_ir_http__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__display_name +msgid "Display Name" +msgstr "Mostrar Nombre" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +msgid "Group By" +msgstr "Agrupar por" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_ir_http +msgid "HTTP Routing" +msgstr "Ruta HTTP" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_ir_http__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__id +msgid "ID" +msgstr "ID" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_scenario__key +msgid "" +"Identify scenario univocally. This value must match a service component's " +"`usage`." +msgstr "" +"Identifique el escenario de manera unívoca. Este valor debe coincidir con el " +"\"uso\" de un componente de servicio." + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__lang_id +msgid "If set, the app will be first loaded with this lang." +msgstr "Si está configurado, la app se cargará inicialmente con este idioma." + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__key +msgid "Key" +msgstr "Clave" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Language" +msgstr "Idioma" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_ir_http____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario____last_update +msgid "Last Modified on" +msgstr "Última Modificación el" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__write_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__write_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__write_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__write_uid +msgid "Last Updated by" +msgstr "Última Actualización por" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__write_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__write_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__write_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__write_date +msgid "Last Updated on" +msgstr "Última Actualización el" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__scenario +msgid "Legacy scenario field" +msgstr "Campo de escenario heredado" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Main" +msgstr "Principal" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_form_view +msgid "Menu Options" +msgstr "Opciones del Menú" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "Menú mostrado en la aplicación de escaner" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_menu +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__menu_ids +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_menu +msgid "Menus" +msgstr "Menús" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_profile__menu_ids +msgid "Menus visible for this profile" +msgstr "Menús visibles para este perfil" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__name +msgid "Name" +msgstr "Nombre" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__short_name +msgid "Needed for app manifest" +msgstr "Requerido para el manifiesto de la app" + +#. module: shopfloor_base +#: model:ir.model.fields.selection,name:shopfloor_base.selection__shopfloor_app__category__ +msgid "None" +msgstr "Ninguno" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Open app" +msgstr "Abrir app" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__options +msgid "Options" +msgstr "Opciones" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__options_edit +msgid "Options Edit" +msgstr "Editar Opciones" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__profile_id +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +msgid "Profile" +msgstr "Perfil" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__profile_required +msgid "Profile Required" +msgstr "Perfil Requerido" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_profile +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__profile_ids +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_profile +msgid "Profiles" +msgstr "Perfiles" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Profiles and menu items" +msgstr "Perfiles y elementos del menú" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__profile_ids +msgid "" +"Profiles used by this app. This will determine menu items too.However this " +"field is not required in case you don't need profiles and menu items from " +"the backend." +msgstr "" +"Perfiles utilizados por esta aplicación. Esto también determinará los " +"elementos del menú. Sin embargo, este campo no es obligatorio en caso de que " +"no necesite perfiles ni elementos del menú del backend." + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__api_docs_url +msgid "Public URL for api docs." +msgstr "URL pública para los api docs." + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__url +msgid "Public URL to use the app." +msgstr "URL pública para usar la app." + +#. module: shopfloor_base +#: code:addons/shopfloor_base/actions/message.py:0 +#, python-format +msgid "Record not found." +msgstr "Registro no encontrado." + +#. module: shopfloor_base +#: code:addons/shopfloor_base/services/scan_anything.py:0 +#, python-format +msgid "" +"Record not found.\n" +"We've tried with the following types: {}" +msgstr "" +"Registro no encontrado.\n" +"Hemos tratado con los siguientes tipos: {}" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__registered_routes +msgid "Registered Routes" +msgstr "Rutas Registradas" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_scenario +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__scenario_id +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_scenario +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +msgid "Scenario" +msgstr "Escenario" + +#. module: shopfloor_base +#: model:ir.model.constraint,message:shopfloor_base.constraint_shopfloor_scenario_key +msgid "Scenario key must be unique" +msgstr "Clave de Escenario debe ser única" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__sequence +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__sequence +msgid "Sequence" +msgstr "Secuencia" + +#. module: shopfloor_base +#: model:ir.module.category,name:shopfloor_base.module_category_shopfloor +#: model:ir.ui.menu,name:shopfloor_base.menu_shopfloor_root +msgid "Shopfloor" +msgstr "Taller" + +#. module: shopfloor_base +#: model:res.groups,name:shopfloor_base.group_shopfloor_manager +msgid "Shopfloor Manager" +msgstr "Gerente del Taller" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_scenario +msgid "Shopfloor Scenario" +msgstr "Escenario del Taller" + +#. module: shopfloor_base +#: model:res.groups,name:shopfloor_base.group_shopfloor_user +msgid "Shopfloor User" +msgstr "Usuario del Taller" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_app +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_app +msgid "Shopfloor apps" +msgstr "Apps del taller" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_profile +msgid "Shopfloor profile settings" +msgstr "Ajustes del perfil de taller" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__short_name +msgid "Short Name" +msgstr "Nombre Corto" + +#. module: shopfloor_base +#: model:shopfloor.menu,name:shopfloor_base.shopfloor_menu_demo_1 +msgid "Simple Thing To Do" +msgstr "Cosas Simples Para Hacer" + +#. module: shopfloor_base +#: model:ir.actions.server,name:shopfloor_base.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__tech_name +msgid "Tech Name" +msgstr "Nombre Técnico" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__registered_routes +msgid "" +"Technical field to allow developers to check registered routes on the form" +msgstr "" +"Campo técnico para permitir a los desarrolladores verificar las rutas " +"registradas en el formulario" + +#. module: shopfloor_base +#: code:addons/shopfloor_base/services/service.py:0 +#, python-format +msgid "The record {model} {_id} does not exist" +msgstr "El registro {model} {_id} no existe" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +msgid "To sync" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__url +msgid "Url" +msgstr "Url" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done " +"with edits and creates." +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "View api docs" +msgstr "Ver api docs" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "View menu items" +msgstr "Ver elementos del menú" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_menu__profile_id +msgid "Visible on this profile only" +msgstr "Visible solo en este perfil" + +#. module: shopfloor_base +#: model:ir.model.constraint,message:shopfloor_base.constraint_shopfloor_app_tech_name +msgid "tech_name must be unique" +msgstr "tech_name debe ser único" diff --git a/shopfloor_base/i18n/shopfloor_base.pot b/shopfloor_base/i18n/shopfloor_base.pot new file mode 100644 index 0000000000..9bdb924d93 --- /dev/null +++ b/shopfloor_base/i18n/shopfloor_base.pot @@ -0,0 +1,479 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * shopfloor_base +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 14.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__api_route +msgid "" +"\n" +" Base route for endpoints attached to this app,\n" +" internal controller-ready version.\n" +" " +msgstr "" + +#. module: shopfloor_base +#: code:addons/shopfloor_base/services/forms/form_mixin.py:0 +#, python-format +msgid "%s updated." +msgstr "" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_app +msgid "A Shopfloor application" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__active +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__active +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__active +msgid "Active" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +msgid "All" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__api_docs_url +msgid "Api Docs Url" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__api_route +msgid "Api Route" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__app_version +msgid "App Version" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_profile_form_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_profile_search_view +msgid "Archived" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Auth" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__auth_type +msgid "Auth Type" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__lang_ids +msgid "Available languages" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__category +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +msgid "Category" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_scenario__options_edit +msgid "Configure options via JSON" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__create_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__create_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__create_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__create_uid +msgid "Created by" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__create_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__create_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__create_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__create_date +msgid "Created on" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__lang_id +msgid "Default language" +msgstr "" + +#. module: shopfloor_base +#: model:shopfloor.profile,name:shopfloor_base.profile_demo_1 +msgid "Demo Profile 1" +msgstr "" + +#. module: shopfloor_base +#: model:shopfloor.profile,name:shopfloor_base.profile_demo_2 +msgid "Demo Profile 2" +msgstr "" + +#. module: shopfloor_base +#: model:shopfloor.scenario,name:shopfloor_base.shopfloor_scenario_demo_1 +msgid "Demo scenario 1" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Developer" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_ir_http__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__display_name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__display_name +msgid "Display Name" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +msgid "Group By" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_ir_http +msgid "HTTP Routing" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_ir_http__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__id +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__id +msgid "ID" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_scenario__key +msgid "" +"Identify scenario univocally. This value must match a service component's " +"`usage`." +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__lang_id +msgid "If set, the app will be first loaded with this lang." +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__key +msgid "Key" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Language" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_ir_http____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile____last_update +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario____last_update +msgid "Last Modified on" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__write_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__write_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__write_uid +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__write_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__write_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__write_date +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__write_date +msgid "Last Updated on" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__scenario +msgid "Legacy scenario field" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Main" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_form_view +msgid "Menu Options" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_menu +msgid "Menu displayed in the scanner application" +msgstr "" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_menu +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__menu_ids +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_menu +msgid "Menus" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_profile__menu_ids +msgid "Menus visible for this profile" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__name +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__name +msgid "Name" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__short_name +msgid "Needed for app manifest" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields.selection,name:shopfloor_base.selection__shopfloor_app__category__ +msgid "None" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__registry_sync +msgid "" +"ON: the record has been modified and registry was not notified.\n" +"No change will be active until this flag is set to false via proper action.\n" +"\n" +"OFF: record in line with the registry, nothing to do." +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Open app" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__options +msgid "Options" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_scenario__options_edit +msgid "Options Edit" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__profile_id +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +msgid "Profile" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__profile_required +msgid "Profile Required" +msgstr "" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_profile +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__profile_ids +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_profile +msgid "Profiles" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "Profiles and menu items" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__profile_ids +msgid "" +"Profiles used by this app. This will determine menu items too.However this " +"field is not required in case you don't need profiles and menu items from " +"the backend." +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__api_docs_url +msgid "Public URL for api docs." +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__url +msgid "Public URL to use the app." +msgstr "" + +#. module: shopfloor_base +#: code:addons/shopfloor_base/actions/message.py:0 +#, python-format +msgid "Record not found." +msgstr "" + +#. module: shopfloor_base +#: code:addons/shopfloor_base/services/scan_anything.py:0 +#, python-format +msgid "" +"Record not found.\n" +"We've tried with the following types: {}" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__registered_routes +msgid "Registered Routes" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__registry_sync +msgid "Registry Sync" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "" +"Registry out of sync. Likely the record has been modified but not sync'ed " +"with the routing registry." +msgstr "" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_scenario +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__scenario_id +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_scenario +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_menu_search_view +msgid "Scenario" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.constraint,message:shopfloor_base.constraint_shopfloor_scenario_key +msgid "Scenario key must be unique" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_menu__sequence +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_profile__sequence +msgid "Sequence" +msgstr "" + +#. module: shopfloor_base +#: model:ir.module.category,name:shopfloor_base.module_category_shopfloor +#: model:ir.ui.menu,name:shopfloor_base.menu_shopfloor_root +msgid "Shopfloor" +msgstr "" + +#. module: shopfloor_base +#: model:res.groups,name:shopfloor_base.group_shopfloor_manager +msgid "Shopfloor Manager" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_scenario +msgid "Shopfloor Scenario" +msgstr "" + +#. module: shopfloor_base +#: model:res.groups,name:shopfloor_base.group_shopfloor_user +msgid "Shopfloor User" +msgstr "" + +#. module: shopfloor_base +#: model:ir.actions.act_window,name:shopfloor_base.action_shopfloor_app +#: model:ir.ui.menu,name:shopfloor_base.menu_action_shopfloor_app +msgid "Shopfloor apps" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model,name:shopfloor_base.model_shopfloor_profile +msgid "Shopfloor profile settings" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__short_name +msgid "Short Name" +msgstr "" + +#. module: shopfloor_base +#: model:shopfloor.menu,name:shopfloor_base.shopfloor_menu_demo_1 +msgid "Simple Thing To Do" +msgstr "" + +#. module: shopfloor_base +#: model:ir.actions.server,name:shopfloor_base.server_action_registry_sync +msgid "Sync registry" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__tech_name +msgid "Tech Name" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_app__registered_routes +msgid "" +"Technical field to allow developers to check registered routes on the form" +msgstr "" + +#. module: shopfloor_base +#: code:addons/shopfloor_base/services/service.py:0 +#, python-format +msgid "The record {model} {_id} does not exist" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_search_view +msgid "To sync" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,field_description:shopfloor_base.field_shopfloor_app__url +msgid "Url" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "" +"Use the action \"Sync registry\" to make changes effective once you are done" +" with edits and creates." +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "View api docs" +msgstr "" + +#. module: shopfloor_base +#: model_terms:ir.ui.view,arch_db:shopfloor_base.shopfloor_app_form_view +msgid "View menu items" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.fields,help:shopfloor_base.field_shopfloor_menu__profile_id +msgid "Visible on this profile only" +msgstr "" + +#. module: shopfloor_base +#: model:ir.model.constraint,message:shopfloor_base.constraint_shopfloor_app_tech_name +msgid "tech_name must be unique" +msgstr "" diff --git a/shopfloor_base/models/__init__.py b/shopfloor_base/models/__init__.py new file mode 100644 index 0000000000..31edaf457b --- /dev/null +++ b/shopfloor_base/models/__init__.py @@ -0,0 +1,5 @@ +from . import shopfloor_app +from . import shopfloor_menu +from . import shopfloor_profile +from . import shopfloor_scenario +from . import ir_http diff --git a/shopfloor_base/models/ir_http.py b/shopfloor_base/models/ir_http.py new file mode 100644 index 0000000000..07c7eca97f --- /dev/null +++ b/shopfloor_base/models/ir_http.py @@ -0,0 +1,44 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import werkzeug + +from odoo import api, models +from odoo.http import request + +from odoo.addons.base.models.ir_http import RequestUID + + +class TechNameConverter(werkzeug.routing.BaseConverter): + """Record converter via tech_name field. + + Similar to the standard model converter but uses the `tech_name` field + of a model to retrieve the record. + """ + + def __init__(self, url_map, model=False): + super().__init__(url_map) + self.model = model + + def to_python(self, value): + # First lookup for the query + query = ( + request.env[self.model].sudo()._search([("tech_name", "=", value)], limit=1) + ) + # Then browse the record w/ proper environment (as per ModelConverter) + _uid = RequestUID(value=value, converter=self) + env = api.Environment(request.cr, _uid, request.context) + return env[self.model].browse(query) + + def to_url(self, value): + return value.tech_name + + +class IrHttp(models.AbstractModel): + _inherit = "ir.http" + + @classmethod + def _get_converters(cls): + converters = super()._get_converters() + converters["tech_name"] = TechNameConverter + return converters diff --git a/shopfloor_base/models/shopfloor_app.py b/shopfloor_base/models/shopfloor_app.py new file mode 100644 index 0000000000..22b9524a7d --- /dev/null +++ b/shopfloor_base/models/shopfloor_app.py @@ -0,0 +1,358 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo import api, fields, models, tools +from odoo.tools import DotDict + +from odoo.addons.base_rest.tools import ROUTING_DECORATOR_ATTR, _inspect_methods +from odoo.addons.component.core import _component_databases + +from ..utils import APP_VERSION, RUNNING_ENV + + +class ShopfloorApp(models.Model): + """Backend for a Shopfloor app.""" + + _name = "shopfloor.app" + _inherit = ["collection.base", "endpoint.route.sync.mixin"] + _description = "A Shopfloor application" + + name = fields.Char(required=True, translate=True) + short_name = fields.Char( + required=True, translate=True, help="Needed for app manifest" + ) + # Unique name + tech_name = fields.Char(required=True, index=True) + active = fields.Boolean(default=True) + category = fields.Selection(selection=[("", "None")]) + api_route = fields.Char( + compute="_compute_api_route", + compute_sudo=True, + help="Base route for endpoints attached to this app, public version.", + ) + api_route = fields.Char( + compute="_compute_api_route", + compute_sudo=True, + help=""" + Base route for endpoints attached to this app, + internal controller-ready version. + """, + ) + url = fields.Char(compute="_compute_url", help="Public URL to use the app.") + api_docs_url = fields.Char(compute="_compute_url", help="Public URL for api docs.") + auth_type = fields.Selection( + selection="_selection_auth_type", default="user_endpoint" + ) + registered_routes = fields.Text( + compute="_compute_registered_routes", + compute_sudo=True, + help="Technical field to allow developers to check registered routes on the form", + groups="base.group_no_one", + ) + profile_ids = fields.Many2many( + comodel_name="shopfloor.profile", + string="Profiles", + help="Profiles used by this app. " + "This will determine menu items too." + "However this field is not required " + "in case you don't need profiles and menu items from the backend.", + ) + profile_required = fields.Boolean(compute="_compute_profile_required", store=True) + app_version = fields.Char(compute="_compute_app_version") + lang_id = fields.Many2one( + "res.lang", + string="Default language", + help="If set, the app will be first loaded with this lang.", + ) + lang_ids = fields.Many2many("res.lang", string="Available languages") + + _sql_constraints = [("tech_name", "unique(tech_name)", "tech_name must be unique")] + + _api_route_path = "/shopfloor/api/" + + @api.depends("tech_name") + def _compute_api_route(self): + for rec in self: + rec.api_route = rec._api_route_path + rec.tech_name + + _base_url_path = "/shopfloor/app/" + _base_api_docs_url_path = "/shopfloor/api-docs/" + + @api.depends("tech_name") + def _compute_url(self): + for rec in self: + full_url = rec._base_url_path + rec.tech_name + rec.url = full_url.rstrip("/") + "/" + rec.api_docs_url = rec._base_api_docs_url_path + rec.tech_name + + @api.depends("tech_name") + def _compute_registered_routes(self): + for rec in self: + routes = sorted(rec._registered_routes(), key=lambda x: x.route) + vals = [] + for endpoint_rule in routes: + vals.append( + f"{endpoint_rule.route} ({', '.join(endpoint_rule.routing['methods'])})" + ) + rec.registered_routes = "\n".join(vals) + + @api.depends("profile_ids") + def _compute_profile_required(self): + for rec in self: + rec.profile_required = bool(rec.profile_ids) + + def _compute_app_version(self): + # Override this to choose your own versioning policy + for rec in self: + rec.app_version = APP_VERSION + + def _selection_auth_type(self): + return self.env["endpoint.route.handler"]._selection_auth_type() + + def api_url_for_service(self, service_name, endpoint=None): + """Handy method to generate services' API URLs for current app.""" + return f"{self.api_route}/{service_name}/{endpoint or ''}".rstrip("/") + + def action_open_app(self): + return { + "type": "ir.actions.act_url", + "name": self.name, + "url": self.url, + "target": "new", + } + + def action_open_app_docs(self): + return { + "type": "ir.actions.act_url", + "name": self.name, + "url": self.api_docs_url, + "target": "new", + } + + def action_view_menu_items(self): + xid = "shopfloor_base.action_shopfloor_menu" + action = self.env["ir.actions.act_window"]._for_xml_id(xid) + action["domain"] = [ + "|", + ("id", "in", self.profile_ids.menu_ids.ids), + ("profile_id", "=", False), + ] + return action + + def _routing_impacting_fields(self): + return ("tech_name", "auth_type") + + def _prepare_endpoint_rules(self, options=None): + # `endpoint.route.sync.mixin` api + services = self._get_services() + routes = [] + for service in services: + self._prepare_non_decorated_endpoints(service) + routes.extend(self._generate_endpoints(service)) + + rules = [ + rec._make_controller_rule(key=rec.name, options=options) + for rec, options in routes + ] + return rules + + def _registered_endpoint_rule_keys(self): + # `endpoint.route.sync.mixin` api + return [x[0] for x in self._registered_routes()] + + def _register_hook(self): + super()._register_hook() + if not tools.sql.column_exists(self.env.cr, self._table, "registry_sync"): + # `registry_sync` has been introduced recently. + # If an env is loaded before the column gets created this can be broken. + return True + self._boot_base_rest_endpoints() + + def _boot_base_rest_endpoints(self): + """Satisfy `base_rest` requirements for REST requests. + + 1. register root paths + 2. decorate non decorated endpoints + + Note that at runtime this is done by + `_register_controllers` and `_prepare_endpoint_rules`. + + TODO: trash for v16 if using `fastapi`. + """ + domain = [("active", "=", True), ("registry_sync", "=", True)] + self.search(domain)._register_base_rest_routes() + services = self._get_services() + for service in services: + self._prepare_non_decorated_endpoints(service) + + def _register_controllers(self, init=False, options=None): + super()._register_controllers(init=init, options=options) + if not self: + return + self._register_base_rest_routes() + + def _register_base_rest_routes(self): + # base_rest patches odoo http request to handle json request + # using a special registry for rest routes + for rec in self: + self.env["rest.service.registration"]._register_rest_route(rec.api_route) + + def _registered_routes(self): + registry = self.env["endpoint.route.handler"]._endpoint_registry + return registry.get_rules_by_group(self._route_group()) + + @api.model + def _prepare_non_decorated_endpoints(self, service): + # Autogenerate routing info where missing + self.env["rest.service.registration"]._prepare_non_decorated_endpoints(service) + + def _generate_endpoints(self, service): + res = [] + for rec in self: + values = rec._generate_endpoints_values(service) + for vals in values: + route, options = rec._generate_endpoints_route(service, vals) + res.append((route, options)) + return res + + def _generate_endpoints_values(self, service): + values = [] + root_path = self.api_route.rstrip("/") + "/" + service._usage + for name, method in _inspect_methods(service.__class__): + routing = getattr(method, ROUTING_DECORATOR_ATTR, None) + if not routing: + continue + for routes, http_method in routing["routes"]: + # TODO: why on base_rest we have this instead of pure method name? + # method_name = "{}_{}".format(http_method.lower(), name) + method_name = name + default_route = root_path + "/" + routes[0].lstrip("/") + route_params = dict( + route=["{}{}".format(root_path, r) for r in routes], + methods=[http_method], + ) + # TODO: get this params from self? + for attr in {"auth", "cors", "csrf", "save_session"}: + if attr in routing: + route_params[attr] = routing[attr] + # {'route': ['/foo/testing/app/user_config'], 'methods': ['POST']} + values.append( + self._prepare_endpoint_vals( + service, method_name, default_route, route_params + ) + ) + return values + + def _generate_endpoints_route(self, service, vals): + method_name = vals.pop("_method_name") + route_handler = self.env["endpoint.route.handler.tool"] + new_route = route_handler.new(vals) + new_route._refresh_endpoint_data() + options = { + "handler": { + "klass_dotted_path": ( + "odoo.addons.shopfloor_base.controllers.main.ShopfloorController" + ), + "method_name": "_process_endpoint", + "default_pargs": (self.id, service._usage, method_name), + } + } + return new_route, options + + def _prepare_endpoint_vals(self, service, method_name, route, routing_params): + request_method = routing_params["methods"][0] + name = f"app#{self.id}::{service._name}/{method_name}__{request_method.lower()}" + endpoint_vals = dict( + name=name, + request_method=request_method, + route=route, + route_group=self._route_group(), + auth_type=self.auth_type, + _method_name=method_name, + ) + return endpoint_vals + + def _route_group(self): + return f"{self._name}:{self.tech_name}" + + def _is_component_registry_ready(self): + comp_registry = _component_databases.get(self.env.cr.dbname) + return comp_registry and comp_registry.ready + + def _get_services(self): + if not self._is_component_registry_ready(): + # No service is available before the registry has been loaded. + # This is a very special case, when the odoo registry is being + # built, it calls odoo.modules.loading.load_modules(). + return [] + return self.env["rest.service.registration"]._get_services(self._name) + + def _name_with_env(self): + name = self.name + if RUNNING_ENV and RUNNING_ENV != "prod": + name += f" ({RUNNING_ENV})" + return name + + def _make_app_info(self, demo=False): + base_url = self.api_route.rstrip("/") + "/" + return DotDict( + name=self._name_with_env(), + short_name=self.short_name, + base_url=base_url, + url=self.url, + manifest_url=self.url + "manifest.json", + auth_type=self.auth_type, + profile_required=self.profile_required, + demo_mode=demo, + version=self.app_version, + running_env=RUNNING_ENV, + lang=self._app_info_lang(), + ) + + def _app_info_lang(self): + enabled = [] + conv = self._app_convert_lang_code + if self.lang_ids: + enabled = [conv(x.code) for x in self.lang_ids] + return dict( + default=conv(self.lang_id.code) if self.lang_id else False, + enabled=enabled, + ) + + def _app_convert_lang_code(self, code): + # TODO: we should probably let the front decide the format + return code.replace("_", "-") + + def _make_app_manifest(self, icons=None, **kw): + param = ( + self.env["ir.config_parameter"] + .sudo() + .get_param("web.base.url", "") + .rstrip("/") + ) + manifest = { + "name": self._name_with_env(), + "short_name": self.short_name, + "start_url": param + self.url, + "scope": param + self.url, + "id": self.url, + "display": "fullscreen", + "icons": icons or [], + } + manifest.update(kw) + return manifest + + @api.onchange("lang_id") + def _onchange_lang_id(self): + if self.env.context.get("from_onchange__lang_ids"): + return + if self.lang_id and self.lang_id not in self.lang_ids: + self.with_context(from_onchange__lang_id=1).lang_ids += self.lang_id + + @api.onchange("lang_ids") + def _onchange_lang_ids(self): + if self.env.context.get("from_onchange__lang_id"): + return + if self.lang_ids and self.lang_id and self.lang_id not in self.lang_ids: + self.with_context(from_onchange__lang_ids=1).lang_id = False diff --git a/shopfloor_base/models/shopfloor_menu.py b/shopfloor_base/models/shopfloor_menu.py new file mode 100644 index 0000000000..328cb554c5 --- /dev/null +++ b/shopfloor_base/models/shopfloor_menu.py @@ -0,0 +1,44 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import fields, models + + +class ShopfloorMenu(models.Model): + _name = "shopfloor.menu" + _description = "Menu displayed in the scanner application" + _order = "sequence" + + name = fields.Char(required=True, translate=True) + sequence = fields.Integer() + profile_id = fields.Many2one( + "shopfloor.profile", string="Profile", help="Visible on this profile only" + ) + scenario_id = fields.Many2one( + comodel_name="shopfloor.scenario", + required=True, + ondelete="cascade", + compute="_compute_scenario_id", + store=True, + readonly=False, + inverse="_inverse_scenario_id", + ) + # TODO: on next versions we could remove this field and drop the compute on m2o. + # ATM is kept only to have a smooth transition to the m2o field. + scenario = fields.Char(string="Legacy scenario field") + active = fields.Boolean(default=True) + + def _compute_scenario_id(self): + for rec in self: + if not rec.scenario_id and rec.scenario: + rec.with_context( + set_by_compute=True + ).scenario_id = rec.scenario_id.search( + [("key", "=", rec.scenario)], limit=1 + ) + + def _inverse_scenario_id(self): + if not self.env.context.get("set_by_compute"): + for rec in self: + rec.scenario = rec.scenario_id.key diff --git a/shopfloor_base/models/shopfloor_profile.py b/shopfloor_base/models/shopfloor_profile.py new file mode 100644 index 0000000000..71e10c5402 --- /dev/null +++ b/shopfloor_base/models/shopfloor_profile.py @@ -0,0 +1,19 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import fields, models + + +class ShopfloorProfile(models.Model): + _name = "shopfloor.profile" + _description = "Shopfloor profile settings" + _order = "sequence" + + name = fields.Char(required=True, translate=True) + menu_ids = fields.One2many( + comodel_name="shopfloor.menu", + inverse_name="profile_id", + string="Menus", + help="Menus visible for this profile", + ) + active = fields.Boolean(default=True) + sequence = fields.Integer(default=0) diff --git a/shopfloor_base/models/shopfloor_scenario.py b/shopfloor_base/models/shopfloor_scenario.py new file mode 100644 index 0000000000..317f2f98ef --- /dev/null +++ b/shopfloor_base/models/shopfloor_scenario.py @@ -0,0 +1,81 @@ +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json +import logging + +from odoo import api, fields, models + +from odoo.addons.base_sparse_field.models.fields import Serialized +from odoo.addons.http_routing.models.ir_http import slugify + +_logger = logging.getLogger(__name__) + + +class ShopfloorScenario(models.Model): + _name = "shopfloor.scenario" + _description = "Shopfloor Scenario" + + name = fields.Char(required=True, translate=True) + # TODO: make it readonly in UI? + # Make it a Selection field? + # Normally this will be used only by dev implementing new scenario. + key = fields.Char( + required=True, + help="Identify scenario univocally. " + "This value must match a service component's `usage`.", + ) + options = Serialized(compute="_compute_options", default={}) + options_edit = fields.Text( + help="Configure options via JSON", inverse="_inverse_options_edit" + ) + + _sql_constraints = [("key", "unique(key)", "Scenario key must be unique")] + + @api.depends("options_edit") + def _compute_options(self): + for rec in self: + rec.options = rec._load_options() + + def _inverse_options_edit(self): + for rec in self: + # Make sure options_edit is always readable + rec.options_edit = json.dumps(rec.options or {}, indent=4, sort_keys=True) + + def _load_options(self): + return json.loads(self.options_edit or "{}") + + @api.onchange("name") + def _onchange_name_for_key(self): + # Keep this specific name for the method to avoid possible overrides + # of existing `_onchange_name` methods + if self.name and not self.key: + self.key = self.name + + @api.onchange("key") + def _onchange_key(self): + if self.key: + # make sure is normalized + self.key = self._normalize_key(self.key) + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + self._handle_key(vals) + return super().create(vals_list) + + def write(self, vals): + self._handle_key(vals) + return super().write(vals) + + def _handle_key(self, vals): + # make sure technical names are always there + if not vals.get("key") and vals.get("name"): + vals["key"] = self._normalize_key(vals["name"]) + + @staticmethod + def _normalize_key(name): + return slugify(name).replace("-", "_") + + def has_option(self, key): + return self.options.get(key, False) diff --git a/shopfloor_base/readme/CONFIGURE.rst b/shopfloor_base/readme/CONFIGURE.rst new file mode 100644 index 0000000000..029fdce00b --- /dev/null +++ b/shopfloor_base/readme/CONFIGURE.rst @@ -0,0 +1,38 @@ +Shopfloor config menu +~~~~~~~~~~~~~~~~~~~~~ + +In the main menu (or home screen) click on "Shopfloor". + + +Profiles +~~~~~~~~ + +In Shopfloor / Profiles. + +The profiles are used to restrict which menus are shown on the frontend +application. When a user logs in the scanner application, they have to +select their profile, so the correct menus are shown. + +Menus +~~~~~ + +In Shopfloor / Menus. + +The menus are displayed on the frontend application. +The configuration may come from the menu itself +and/or from the scenario linked to it. + +Their profile will restrict the visibility to the profile chosen on the device. +If a menu has no profile, it is shown in every profile. + +Some scenario may have additional options. + + +Scenario +~~~~~~~~ + +In Shopfloor / Scenario. + +A Scenario represents a flow (or more basically "something to do" with the app. +Each scenario must have a name and a unique key. +The key must match a registered shopfloor service component. diff --git a/shopfloor_base/readme/CONTRIBUTORS.rst b/shopfloor_base/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..d89683343f --- /dev/null +++ b/shopfloor_base/readme/CONTRIBUTORS.rst @@ -0,0 +1,13 @@ +* Guewen Baconnier +* Simone Orsi +* Sébastien Alix +* Alexandre Fayolle +* Benoit Guillot +* Thierry Ducrest +* Michael Tietz (MT Software) + +Design +~~~~~~ + +* Joël Grand-Guillaume +* Jacques-Etienne Baudoux diff --git a/shopfloor_base/readme/CREDITS.rst b/shopfloor_base/readme/CREDITS.rst new file mode 100644 index 0000000000..b2141e8f1a --- /dev/null +++ b/shopfloor_base/readme/CREDITS.rst @@ -0,0 +1,10 @@ +**Financial support** + +* Cosanum +* Camptocamp R&D +* Akretion R&D +* ACSONE R&D + +**Icons** + +* Tablet app icon by Gregor Cresnar from the Noun Project diff --git a/shopfloor_base/readme/DESCRIPTION.rst b/shopfloor_base/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..6d24f63c12 --- /dev/null +++ b/shopfloor_base/readme/DESCRIPTION.rst @@ -0,0 +1,5 @@ +Shopfloor is a barcode scanner application. + +This module provides REST APIs to support scenario. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by ``shopfloor_mobile_base``. diff --git a/shopfloor_base/readme/HISTORY.rst b/shopfloor_base/readme/HISTORY.rst new file mode 100644 index 0000000000..dc27ee2605 --- /dev/null +++ b/shopfloor_base/readme/HISTORY.rst @@ -0,0 +1,4 @@ +13.0.1.0.0 +~~~~~~~~~~ + +First official version. diff --git a/shopfloor_base/readme/ROADMAP.rst b/shopfloor_base/readme/ROADMAP.rst new file mode 100644 index 0000000000..47cd17a12f --- /dev/null +++ b/shopfloor_base/readme/ROADMAP.rst @@ -0,0 +1,2 @@ +* improve documentation +* change shopfloor.scenario.key to selection? See comment in model diff --git a/shopfloor_base/readme/USAGE.rst b/shopfloor_base/readme/USAGE.rst new file mode 100644 index 0000000000..9f4832dd09 --- /dev/null +++ b/shopfloor_base/readme/USAGE.rst @@ -0,0 +1,6 @@ +An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header ``API-KEY`` is: 72B044F7AC780DAC + +Curl example:: + + curl -X POST "http://localhost:8069/shopfloor/user/menu" -H "accept: */*" -H "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC" diff --git a/shopfloor_base/security/groups.xml b/shopfloor_base/security/groups.xml new file mode 100644 index 0000000000..db2a0e5800 --- /dev/null +++ b/shopfloor_base/security/groups.xml @@ -0,0 +1,17 @@ + + + + + Shopfloor User + + + + Shopfloor Manager + + + + diff --git a/shopfloor_base/security/ir.model.access.csv b/shopfloor_base/security/ir.model.access.csv new file mode 100644 index 0000000000..45768938f6 --- /dev/null +++ b/shopfloor_base/security/ir.model.access.csv @@ -0,0 +1,9 @@ +"id","name","model_id/id","group_id/id","perm_read","perm_write","perm_create","perm_unlink" +"access_shopfloor_menu_users","shopfloor menu","model_shopfloor_menu","shopfloor_base.group_shopfloor_user",1,0,0,0 +"access_shopfloor_menu_manager","shopfloor menu manager","model_shopfloor_menu","shopfloor_base.group_shopfloor_manager",1,1,1,1 +"access_shopfloor_profile_users","shopfloor profile","model_shopfloor_profile","shopfloor_base.group_shopfloor_user",1,0,0,0 +"access_shopfloor_profile_manager","shopfloor profile manager","model_shopfloor_profile","shopfloor_base.group_shopfloor_manager",1,1,1,1 +"access_shopfloor_scenario_users","shopfloor scenario","model_shopfloor_scenario","shopfloor_base.group_shopfloor_user",1,0,0,0 +"access_shopfloor_scenario_manager","shopfloor scenario manager","model_shopfloor_scenario","shopfloor_base.group_shopfloor_manager",1,1,1,1 +"access_shopfloor_app_users","shopfloor app","model_shopfloor_app","",1,0,0,0 +"access_shopfloor_app_manager","shopfloor app manager","model_shopfloor_app","shopfloor_base.group_shopfloor_manager",1,1,1,1 diff --git a/shopfloor_base/services/__init__.py b/shopfloor_base/services/__init__.py new file mode 100644 index 0000000000..5a84de47c3 --- /dev/null +++ b/shopfloor_base/services/__init__.py @@ -0,0 +1,13 @@ +# core classes +from . import service +from . import validator + +# generic services +from . import app +from . import user +from . import menu +from . import profile +from . import scan_anything + +# forms +from . import forms diff --git a/shopfloor_base/services/app.py b/shopfloor_base/services/app.py new file mode 100644 index 0000000000..d6378c8f4e --- /dev/null +++ b/shopfloor_base/services/app.py @@ -0,0 +1,63 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.component.core import Component + + +class ShopfloorApp(Component): + """Generic endpoints for the Application.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.service.app" + _usage = "app" + _description = __doc__ + + # TODO: RENAME TO `sync` + def user_config(self): + return self._response(data=self._sync_data()) + + def _sync_data(self): + profiles_comp = self.component("profile") + profiles = profiles_comp._to_json(profiles_comp._search()) + user_comp = self.component("user") + user_info = user_comp._user_info() + return {"profiles": profiles, "user_info": user_info} + + +class ShopfloorAppValidator(Component): + """Validators for the Application endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.service.app.validator" + _usage = "app.validator" + + def user_config(self): + return {} + + +class ShopfloorAppValidatorResponse(Component): + """Validators for the Application endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.service.app.validator.response" + _usage = "app.validator.response" + + def user_config(self): + profile_return_validator = self.component("profile.validator.response") + user_return_validator = self.component("user.validator.response") + return self._response_schema( + { + "profiles": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": profile_return_validator._record_schema, + }, + }, + "user_info": { + "type": "dict", + "required": True, + "schema": user_return_validator._user_info_schema(), + }, + } + ) diff --git a/shopfloor_base/services/forms/__init__.py b/shopfloor_base/services/forms/__init__.py new file mode 100644 index 0000000000..19f4552043 --- /dev/null +++ b/shopfloor_base/services/forms/__init__.py @@ -0,0 +1 @@ +from . import form_mixin diff --git a/shopfloor_base/services/forms/form_mixin.py b/shopfloor_base/services/forms/form_mixin.py new file mode 100644 index 0000000000..28f1301ad5 --- /dev/null +++ b/shopfloor_base/services/forms/form_mixin.py @@ -0,0 +1,97 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import _ + +from odoo.addons.component.core import AbstractComponent + + +class ShopfloorFormMixin(AbstractComponent): + """Allow to edit records.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.form.mixin" + _usage = "form_mixin" + _description = __doc__ + _expose_model = "" + _requires_header_profile = True + _requires_header_menu = False + + def get(self, _id): + record = self._get(_id) + return self._response_for_form(record) + + def update(self, _id, **params): + record = self._get(_id) + record.write(self._prepare_params(params, mode="update")) + return self._response_for_form(record, message=self._msg_record_updated(record)) + + def _response_for_form(self, record, **kw): + record_data = self._record_data(record) + form_data = self._form_data(record) + return self._response(data={"record": record_data, "form": form_data}, **kw) + + def _record_data(self, record): + raise NotImplementedError() + + def _form_data(self, record): + raise NotImplementedError() + + def _prepare_params(self, params, mode="update"): + return params + + def _msg_record_updated(self, record): + model = self.env["ir.model"]._get(record._name) + body = _("%s updated.") % model.name + return {"message_type": "info", "body": body} + + +class ShopfloorFormMixinValidator(AbstractComponent): + """Validators for the ShopfloorFormMixin endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.form.validator.mixin" + _usage = "form_mixin.validator" + + def get(self): + return { + # TODO @simahawk: remove the need for this + # The black magic in base_rest + # is going to register the endpoint as `/int:id/get` (or `/update`) + # hence what is coming after as param is going to value `id` + # and `_id` as we expect in the method (to avoid using a built-in name). + # In theory we can replace this validator w/ proper restapi decorator. + "id": {"type": "integer", "rename": "_id"}, + "_id": {"type": "integer"}, + } + + def update(self): + return { + # TODO @simahawk: remove the need for this + "id": {"type": "integer", "rename": "_id"}, + "_id": {"type": "integer"}, + } + + +class ShopfloorFormMixinValidatorResponse(AbstractComponent): + """Validators for the ShopfloorFormMixin endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.form.validator.response.mixin" + _usage = "form_mixin.validator.response" + + def get(self): + schema = { + "record": self.schemas._schema_dict_of(self._record_schema()), + "form": self.schemas._schema_dict_of(self._form_schema()), + } + return self._response_schema(schema) + + def update(self): + return self.get() + + def _record_schema(self): + raise NotImplementedError() + + def _form_schema(self): + raise NotImplementedError() diff --git a/shopfloor_base/services/menu.py b/shopfloor_base/services/menu.py new file mode 100644 index 0000000000..77cb117962 --- /dev/null +++ b/shopfloor_base/services/menu.py @@ -0,0 +1,111 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.osv import expression + +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class ShopfloorMenu(Component): + """ + Menu Structure for the client application. + + The list of menus is restricted by the profiles. + A menu without profile is shown in every profiles. + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.service.menu" + _usage = "menu" + _expose_model = "shopfloor.menu" + _description = __doc__ + + @property + def _exposed_model(self): + # Use sudo because we don't care + # if the current user can see menu items or not. + # They should always be loaded by the app. + return super()._exposed_model.sudo() + + def _get_base_search_domain(self): + base_domain = super()._get_base_search_domain() + if self._profile: + profile_domain = [ + "|", + ("profile_id", "=", False), + ("profile_id", "=", self._profile.id), + ] + else: + profile_domain = [("profile_id", "=", False)] + return expression.AND([base_domain, profile_domain]) + + def _search(self, name_fragment=None): + if not self._profile: + # we need to know the profile to load menus + return self.env["shopfloor.menu"].browse() + domain = self._get_base_search_domain() + if name_fragment: + domain.append(("name", "ilike", name_fragment)) + records = self._exposed_model.search(domain) + return records + + def search(self, name_fragment=None): + """List available menu entries for current profile""" + records = self._search(name_fragment=name_fragment) + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) + + def _convert_one_record(self, record): + # TODO: drop this ctx flag for v15 as `jsonifier` makes it default + values = record.with_context(jsonifier__date_user_tz=False).jsonify( + self._one_record_parser(record), one=True + ) + return values + + def _one_record_parser(self, record): + return [ + "id", + "name", + "scenario", + ] + + +class ShopfloorMenuValidator(Component): + """Validators for the Menu endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.service.menu.validator" + _usage = "menu.validator" + + def search(self): + return { + "name_fragment": {"type": "string", "nullable": True, "required": False} + } + + +class ShopfloorMenuValidatorResponse(Component): + """Validators for the Menu endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.service.menu.validator.response" + _usage = "menu.validator.response" + + def return_search(self): + record_schema = self._record_schema + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": self.schemas._schema_list_of(record_schema), + } + ) + + @property + def _record_schema(self): + schema = { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "scenario": {"type": "string", "nullable": False, "required": True}, + } + schema.update(self.schemas.menu_item_counters()) + return schema diff --git a/shopfloor_base/services/profile.py b/shopfloor_base/services/profile.py new file mode 100644 index 0000000000..e327bc0df7 --- /dev/null +++ b/shopfloor_base/services/profile.py @@ -0,0 +1,93 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class ShopfloorProfile(Component): + """ + Profile storing the configuration for the interaction from the client. + + A client application must use a profile, passed to every request in the + HTTP header HTTP_SERVICE_CTX_PROFILE_ID. + + Only stock managers should be allowed to change the profile for a device. + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.service.profile" + _usage = "profile" + _expose_model = "shopfloor.profile" + _description = __doc__ + + @property + def _exposed_model(self): + # Use sudo because we don't care + # if the current user can see profiles or not. + # They should always be loaded by the app. + return super()._exposed_model.sudo() + + def _get_base_search_domain(self): + res = super()._get_base_search_domain() + if self.collection.profile_ids: + return [("id", "in", self.collection.profile_ids.ids)] + return res + + def _search(self, name_fragment=None): + domain = self._get_base_search_domain() + if name_fragment: + domain.append(("name", "ilike", name_fragment)) + records = self._exposed_model.search(domain) + return records + + def search(self, name_fragment=None): + """List available profiles""" + records = self._search(name_fragment=name_fragment) + return self._response( + data={"size": len(records), "records": self._to_json(records)} + ) + + def _convert_one_record(self, record): + return { + "id": record.id, + "name": record.name, + } + + +class ShopfloorProfileValidator(Component): + """Validators for the Profile endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.service.profile.validator" + _usage = "profile.validator" + + def search(self): + return { + "name_fragment": {"type": "string", "nullable": True, "required": False} + } + + +class ShopfloorProfileValidatorResponse(Component): + """Validators for the Profile endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.service.profile.validator.response" + _usage = "profile.validator.response" + + def search(self): + return self._response_schema( + { + "size": {"coerce": to_int, "required": True, "type": "integer"}, + "records": { + "type": "list", + "schema": {"type": "dict", "schema": self._record_schema}, + }, + } + ) + + @property + def _record_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + } diff --git a/shopfloor_base/services/scan_anything.py b/shopfloor_base/services/scan_anything.py new file mode 100644 index 0000000000..fa76759803 --- /dev/null +++ b/shopfloor_base/services/scan_anything.py @@ -0,0 +1,163 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo import _ + +from odoo.addons.component.core import AbstractComponent, Component + +from ..actions.base_action import get_actions_for + + +class ShopfloorScanAnything(Component): + """Endpoints to scan any record. + + You can register your own record scanner. + + NOTE for swagger docs: using `anyof_schema` for `record` response key + does not work in swagger UI. Hence, you won't see any detail. + + Issue: https://github.com/swagger-api/swagger-ui/issues/3803 + PR: https://github.com/swagger-api/swagger-ui/pull/5530 + """ + + _inherit = "base.shopfloor.service" + _name = "shopfloor.scan.anything" + _usage = "scan_anything" + _description = __doc__ + + def scan(self, identifier, record_types=None): + """Scan an item identifier and return its info if found. + + :param identifier: a string, normally a barcode or name + :param record_types: limit scan to specific record types + """ + data = {} + tried = [] + record = None + for handler in self._scan_handlers(): + if record_types and handler.record_type not in record_types: + continue + tried.append(handler.record_type) + record = handler.search(identifier) + if record: + data.update( + { + "identifier": identifier, + "record": handler.converter(record), + "type": handler.record_type, + } + ) + break + if not record: + return self._response_for_not_found(tried) + return self._response_for_found(data) + + def _response_for_found(self, data): + return self._response(data=data) + + def _response_for_not_found(self, tried): + message = { + "body": _( + "Record not found.\n" "We've tried with the following types: {}" + ).format(", ".join(tried)), + "message_type": "error", + } + return self._response(message=message) + + def _scan_handlers(self): + """Return components to handle scan requests.""" + return self.many_components(usage="scan_anything.handler") + + +class ShopfloorScanAnythingHandler(AbstractComponent): + """Handle record search for ScanAnything service.""" + + _name = "shopfloor.scan.anything.handler" + _usage = "scan_anything.handler" + _description = __doc__ + _collection = "shopfloor.app" + _actions_collection_name = "shopfloor.action" + + @property + def _data(self): + return get_actions_for(self, "data") + + @property + def _data_detail(self): + return get_actions_for(self, "data_detail") + + @property + def _schema(self): + return get_actions_for(self, "schema") + + @property + def _schema_detail(self): + return get_actions_for(self, "schema_detail") + + @property + def _search(self): + return get_actions_for(self, "search") + + @property + def record_type(self): + """Return unique record type for this handler""" + raise NotImplementedError() + + def search(self, identifier): + """Find and return Odoo record.""" + raise NotImplementedError() + + @property + def converter(self): + """Return data converter to json.""" + raise NotImplementedError() + + @property + def schema(self): + """Return schema to validate record converter.""" + raise NotImplementedError() + + +class ShopfloorScanAnythingValidator(Component): + """Validators for the Application endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.scan_anything.validator" + _usage = "scan_anything.validator" + + def scan(self): + return { + "identifier": {"type": "string", "nullable": False, "required": True}, + "record_types": {"type": "list", "nullable": True, "required": False}, + } + + +class ShopfloorScanAnythingValidatorResponse(Component): + """Validators for the scan anything endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.scan_anything.validator.response" + _usage = "scan_anything.validator.response" + + def scan(self): + scan_service = self.component(usage="scan_anything") + allowed_types = [x.record_type for x in scan_service._scan_handlers()] + allowed_schemas = [x.schema() for x in scan_service._scan_handlers()] + data_schema = { + "identifier": {"type": "string", "nullable": True, "required": False}, + "type": { + "type": "string", + "nullable": True, + "required": False, + "allowed": allowed_types, + }, + "record": { + "type": "dict", + "required": False, + "nullable": True, + "anyof_schema": allowed_schemas, + "dependencies": ["identifier", "type"], + }, + } + return self._response_schema(data_schema) diff --git a/shopfloor_base/services/service.py b/shopfloor_base/services/service.py new file mode 100644 index 0000000000..d537e323b0 --- /dev/null +++ b/shopfloor_base/services/service.py @@ -0,0 +1,297 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2020 Akretion (http://www.akretion.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from werkzeug.exceptions import BadRequest + +from odoo import _, exceptions +from odoo.http import request +from odoo.osv import expression +from odoo.tools import DotDict + +from odoo.addons.component.core import AbstractComponent + +from ..actions.base_action import get_actions_for +from ..apispec.service_apispec import ShopfloorRestServiceAPISpec + + +class BaseShopfloorService(AbstractComponent): + """Base class for REST services""" + + _inherit = "base.rest.service" + _name = "base.shopfloor.service" + _collection = "shopfloor.app" + _expose_model = None + + def __init__(self, work_context): + super().__init__(work_context) + # User private attributes to not mess up w/ public endpoints + self._profile = getattr(self.work, "profile", self.env["shopfloor.profile"]) + self._menu = getattr(self.work, "menu", self.env["shopfloor.menu"]) + + def _get_api_spec(self, **params): + return ShopfloorRestServiceAPISpec(self, **params) + + def dispatch(self, method_name, *args, params=None): + self._validate_headers_update_work_context(request, method_name) + return super().dispatch(method_name, *args, params=params) + + def _actions_for(self, usage, **kw): + return get_actions_for(self, usage, **kw) + + @property + def _exposed_model(self): + # Use `.get` to avoid failure on `inspect.getmembers` call + # which load this on services w/out a model. + return self.env.get(self._expose_model) + + def _get(self, _id): + domain = expression.normalize_domain(self._get_base_search_domain()) + domain = expression.AND([domain, [("id", "=", _id)]]) + record = self._exposed_model.search(domain) + if not record: + raise exceptions.MissingError( + _("The record {model} {_id} does not exist").format( + model=self._expose_model, _id=_id + ) + ) + else: + return record + + def _get_base_search_domain(self): + return [] + + def _convert_one_record(self, record): + """To implement in service Components""" + return {} + + def _to_json(self, records): + res = [] + for record in records: + res.append(self._convert_one_record(record)) + return res + + def _response( + self, base_response=None, data=None, next_state=None, message=None, popup=None + ): + """Base "envelope" for the responses + + All the keys are optional. + + :param base_response: optional dictionary of values to extend + (typically already created by a call to _response()) + :param data: dictionary of values, when a next_state is provided, + the data is enclosed in a key of the same name (to support polymorphism + in the schema) + :param next_state: string describing the next state that the client + application must reach + :param message: dictionary for the message to show in the client + application (see ``_response_schema`` for the keys) + :param popup: dictionary for a popup to show in the client application + (see ``_response_schema`` for the keys). The popup is displayed before + reaching the next state. + """ + if base_response: + response = base_response.copy() + else: + response = {} + if next_state: + # data for a state is always enclosed in a key with the name + # of the state, so an endpoint can return to different states + # that need different data: the schema can be different for + # every state this way + response.update( + { + # ensure we have an empty dict when the state + # does not need any data, so the client does not need + # to check this + "data": {next_state: data or {}}, + "next_state": next_state, + } + ) + + elif data: + response["data"] = data + + if message: + response["message"] = message + + if popup: + response["popup"] = popup + + return response + + _requires_header_menu = False + _requires_header_profile = False + + def _get_openapi_default_parameters(self): + defaults = super()._get_openapi_default_parameters() + # Normal users can't read an API key, ignore it using sudo() only + # because it's a demo key. + demo_api_key = self.env.ref("shopfloor.api_key_demo", raise_if_not_found=False) + if demo_api_key: + demo_api_key = demo_api_key.sudo() + + service_params = [ + { + "name": "API-KEY", + "in": "header", + "description": "API key for Authorization", + "required": True, + "schema": {"type": "string"}, + "style": "simple", + "value": demo_api_key.key if demo_api_key else "", + }, + ] + menu_model = self.env["shopfloor.menu"].sudo() + profile_model = self.env["shopfloor.profile"].sudo() + if self._requires_header_menu: + # Try to first the first menu that implements the current service. + # Not all usages have a process, in that case, we'll set the first + # menu found + menu = menu_model.search([("scenario", "=", self._usage)], limit=1) + if not menu: + menu = menu_model.search([], limit=1) + service_params.append( + { + "name": "SERVICE_CTX_MENU_ID", + "in": "header", + "description": "ID of the current menu", + "required": True, + "schema": {"type": "integer"}, + "style": "simple", + "value": menu.id, + } + ) + if self._requires_header_profile: + profile = profile_model.search([], limit=1) + service_params.append( + { + "name": "SERVICE_CTX_PROFILE_ID", + "in": "header", + "description": "ID of the current profile", + "required": True, + "schema": {"type": "integer"}, + "style": "simple", + "value": profile.id, + } + ), + defaults.extend(service_params) + return defaults + + @property + def data(self): + return self._actions_for("data") + + @property + def data_detail(self): + return self._actions_for("data_detail") + + @property + def schema(self): + return self._actions_for("schema") + + @property + def schema_detail(self): + return self._actions_for("schema_detail") + + @property + def msg_store(self): + return self._actions_for("message") + + # TODO: maybe to be proposed to base_rest + # TODO: add tests + def _validate_headers_update_work_context(self, request, method_name): + """Validate request and update context per service. + + Our services may require extra headers. + The service component is loaded after the ctx has been initialized + hence we need an hook were we can validate by component/service + if the request is compliant with what we need (eg: missing header) + """ + if self.env.context.get("_service_skip_request_validation"): + return + extra_work_ctx = {} + headers = request.httprequest.environ + for rule, active in self._validation_rules: + if callable(active): + active = active(request, method_name) + if not active: + continue + header_name, coerce_func, ctx_value_handler_name, mandatory = rule + try: + header_value = coerce_func(headers.get(header_name)) + except (TypeError, ValueError) as err: + if not mandatory: + continue + raise BadRequest( + "{} header validation error: {}".format(header_name, str(err)) + ) from err + ctx_value_handler = getattr(self, ctx_value_handler_name) + dest_key, value = ctx_value_handler(header_value) + if not value: + raise BadRequest("{} header value lookup error".format(header_name)) + extra_work_ctx[dest_key] = value + for k, v in extra_work_ctx.items(): + setattr(self.work, k, v) + + @property + def _validation_rules(self): + return ( + # rule to apply, active flag + (self.MENU_ID_HEADER_RULE, self._requires_header_menu), + (self.PROFILE_ID_HEADER_RULE, self._requires_header_profile), + ) + + MENU_ID_HEADER_RULE = ( + # header name, coerce func, ctx handler, mandatory + "HTTP_SERVICE_CTX_MENU_ID", + int, + "_work_ctx_get_menu_id", + True, + ) + PROFILE_ID_HEADER_RULE = ( + # header name, coerce func, ctx value handler, mandatory + "HTTP_SERVICE_CTX_PROFILE_ID", + int, + "_work_ctx_get_profile_id", + True, + ) + + def _work_ctx_get_menu_id(self, rec_id): + return "menu", self.env["shopfloor.menu"].browse(rec_id).exists() + + def _work_ctx_get_profile_id(self, rec_id): + profile = self.env["shopfloor.profile"].browse(rec_id).exists() + limited_profiles = self.collection.profile_ids + if limited_profiles and profile not in limited_profiles: + raise BadRequest("Profile not allowed") + return "profile", profile + + _options = {} + + @property + def options(self): + """Compute options for current service. + + If the service has a menu, options coming from the menu are injected. + """ + if self._options: + return self._options + + options = {} + if self._requires_header_menu and self._menu: + options = self._menu.scenario_id.options or {} + options.update(getattr(self.work, "options", {})) + self._options = DotDict(options) + return self._options + + +class BaseShopfloorProcess(AbstractComponent): + """Base class for process rest service""" + + _inherit = "base.shopfloor.service" + _name = "base.shopfloor.process" + + _requires_header_menu = True + _requires_header_profile = True diff --git a/shopfloor_base/services/user.py b/shopfloor_base/services/user.py new file mode 100644 index 0000000000..ceec4b5c28 --- /dev/null +++ b/shopfloor_base/services/user.py @@ -0,0 +1,90 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.addons.base_rest.components.service import to_int +from odoo.addons.component.core import Component + + +class ShopfloorUser(Component): + """Generic endpoints for user specific info.""" + + _inherit = "base.shopfloor.service" + _name = "shopfloor.user" + _usage = "user" + _description = __doc__ + _requires_header_profile = True + + def menu(self): + menu_comp = self.component("menu") + menus = menu_comp._to_json(menu_comp._search()) + return self._response(data={"menus": menus}) + + # TODO: this endpoint does not require profile header + def user_info(self): + return self._response(data={"user_info": self._user_info()}) + + def _user_info(self): + return self.env.user.jsonify(self._user_info_parser, one=True) + + @property + def _user_info_parser(self): + return ["id", "name", ("lang", self._user_lang_parser)] + + def _user_lang_parser(self, rec, fname): + if not rec[fname]: + return False + return self.collection._app_convert_lang_code(rec[fname]) + + +class ShopfloorUserValidator(Component): + """Validators for the User endpoints""" + + _inherit = "base.shopfloor.validator" + _name = "shopfloor.user.validator" + _usage = "user.validator" + + def menu(self): + return {} + + def user_info(self): + return {} + + +class ShopfloorUserValidatorResponse(Component): + """Validators for the User endpoints responses""" + + _inherit = "base.shopfloor.validator.response" + _name = "shopfloor.user.validator.response" + _usage = "user.validator.response" + + def menu(self): + menu_return_validator = self.component("menu.validator.response") + return self._response_schema( + { + "menus": { + "type": "list", + "required": True, + "schema": { + "type": "dict", + "schema": menu_return_validator._record_schema, + }, + }, + } + ) + + def user_info(self): + return self._response_schema( + { + "user_info": { + "type": "dict", + "required": True, + "schema": self._user_info_schema(), + } + } + ) + + def _user_info_schema(self): + return { + "id": {"coerce": to_int, "required": True, "type": "integer"}, + "name": {"type": "string", "nullable": False, "required": True}, + "lang": {"type": "string", "nullable": False, "required": False}, + } diff --git a/shopfloor_base/services/validator.py b/shopfloor_base/services/validator.py new file mode 100644 index 0000000000..0d06162e0e --- /dev/null +++ b/shopfloor_base/services/validator.py @@ -0,0 +1,255 @@ +# Copyright 2020-2021 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +from odoo.addons.component.core import AbstractComponent, Component +from odoo.addons.component.exception import NoComponentError + +from ..actions.base_action import get_actions_for + +_logger = logging.getLogger(__name__) + + +class ShopfloorRestCerberusValidator(Component): + """Customize the handling of validators + + In the initial implementation of rest_api, the schema validators + had to be returned by methods in the same service as the method, named + after the endpoint's method with a prefix: "_validator_" or + "_validator_return_". + + As we have a lot of endpoints methods in some services, we extracted + the validator methods in dedicated components with + "base.shopfloor.validator" and "base.shopfloor.validator.response" usages, + and methods of the same name as the endpoint's method. + + With the new API, endpoints are decorated with "@restapi.method" and the + validator is defined there. Example: + + @restapi.method( + [(["//get", "/"], "GET")], + input_param=restapi.CerberusValidator("_get_partner_input_schema"), + output_param=restapi.CerberusValidator("_get_partner_output_schema"), + auth="public", + ) + + The schema is get by calling the method "_get..." on the service. + + For backward compatilibity, base_rest patches the methods not decorated + and sets the "input_param" and "output_param" to call the + "_validator_" or "_validator_return_": + + https://github.com/OCA/rest-framework/blob/abd74cd7241d3b93054825cc3e41cb7b693c9000/base_rest/models/rest_service_registration.py#L240-L250 # noqa + + The following change in base_rest allows to customize the way the validator + handler is get: https://github.com/OCA/rest-framework/pull/99 + + This is what is used here to delegate to our ".validator" and + ".validator.response" components. + """ + + _name = "shopfloor.rest.cerberus.validator" + _inherit = "base.rest.cerberus.validator" + _usage = "cerberus.validator" + _collection = "shopfloor.app" + _is_rest_service_component = False + + def _get_validator_component(self, service, method_name, direction): + assert direction in ("input", "output") + if direction == "input": + suffix = "validator" + method_name = method_name.replace("_validator_", "") + else: + suffix = "validator.response" + method_name = method_name.replace("_validator_return_", "") + validator_component = self.component( + usage="{}.{}".format(service._usage, suffix) + ) + return validator_component, method_name + + def get_validator_handler(self, service, method_name, direction): + """Get the validator handler for a method + + By default, it returns the method on the current service instance. It + can be customized to delegate the validators to another component. + """ + try: + validator_component, method_name = self._get_validator_component( + service, method_name, direction + ) + except NoComponentError: + _logger.warning("no component found for %s method %s", service, method_name) + return None + + try: + return getattr(validator_component, method_name) + except AttributeError: + _logger.warning( + "no validator method found for %s method %s", service, method_name + ) + return None + + def has_validator_handler(self, service, method_name, direction): + """Return if the service has a validator handler for a method + + By default, it returns True if the the method exists on the service. It + can be customized to delegate the validators to another component. + """ + try: + validator_component, method_name = self._get_validator_component( + service, method_name, direction + ) + except NoComponentError: + return False + return hasattr(validator_component, method_name) + + +class BaseShopfloorValidator(AbstractComponent): + """Base class for Validators""" + + # pylint: disable = consider-merging-classes-inherited + _inherit = "base.rest.service" + _name = "base.shopfloor.validator" + _collection = "shopfloor.app" + _is_rest_service_component = False + + +class BaseShopfloorValidatorResponse(AbstractComponent): + """Base class for Validator for Responses + + When an endpoint returns data for a state, the data is enclosed + in a key with the same name as the state, this is in order to support + polymorphism in schemas (an endpoint being able to return different data + depending on the next state). + + General idea of a schema for a method that changes state (data may vary, + in this example, next_state will be one of "confirm_start", "start", + "scan_location"): + + { + message { + message_type* string + message* string + } + next_state string + data { + confirm_start {...} + start {...} + scan_location {...} + } + } + + General idea of a schema for a generic method (data may vary): + + { + message { + message_type* string + message* string + } + data { + size* integer + records* integer + } + } + + """ + + _inherit = "base.rest.service" + _name = "base.shopfloor.validator.response" + _collection = "shopfloor.app" + _is_rest_service_component = False + + # Initial state of a workflow + _start_state = "start" + + def _states(self): + """List of possible next states + + With the schema of the data send to the client to transition + to the next state. + """ + return {} + + def _actions_for(self, usage, **kw): + return get_actions_for(self, usage, **kw) + + @property + def schemas(self): + return self._actions_for("schema") + + @property + def schemas_detail(self): + return self._actions_for("schema_detail") + + def _response_schema(self, data_schema=None, next_states=None): + """Schema for the return validator + + Must be used for the schema of all responses. + The "data" part can be customized and is optional, + it must be a dictionary. + + next_states is a list of allowed states to which the client + can transition. The schema of the data needed for every state + of the list must be defined in the ``_states`` method. + + The initial state does not need to be included in the list, it + is implicit as we assume that any state can go back to the initial + state in case of unrecoverable error. + """ + response_schema = { + "message": { + "type": "dict", + "required": False, + "schema": { + "message_type": { + "type": "string", + "required": True, + "allowed": ["info", "warning", "error", "success"], + }, + "body": {"type": "string", "required": True}, + }, + }, + "popup": { + "type": "dict", + "required": False, + "schema": {"body": {"type": "string", "required": True}}, + }, + "log_entry_url": {"type": "string", "required": False}, + } + if not data_schema: + data_schema = {} + + # TODO: shall we keep `next_state` as part of base module? + # In theory the next state is what leads users to the next step. + if next_states: + next_states = set(next_states) + next_states.add(self._start_state) + states_schemas = self._states() + if self._start_state not in states_schemas: + raise ValueError( + "the _start_state is {} but this state does not exist" + ", you may want to change the property's value".format( + self._start_state + ) + ) + unknown_states = set(next_states) - states_schemas.keys() + if unknown_states: + raise ValueError( + "states {!r} are not defined in _states".format(unknown_states) + ) + + data_schema = data_schema.copy() + data_schema.update( + { + state: {"type": "dict", "schema": states_schemas[state]} + for state in next_states + } + ) + response_schema["next_state"] = {"type": "string", "required": False} + + response_schema["data"] = { + "type": "dict", + "required": False, + "schema": data_schema, + } + return response_schema diff --git a/shopfloor_base/static/description/icon.png b/shopfloor_base/static/description/icon.png new file mode 100644 index 0000000000..5df155ee5a Binary files /dev/null and b/shopfloor_base/static/description/icon.png differ diff --git a/shopfloor_base/static/description/icon.svg b/shopfloor_base/static/description/icon.svg new file mode 100644 index 0000000000..97b45b46f3 --- /dev/null +++ b/shopfloor_base/static/description/icon.svg @@ -0,0 +1,119 @@ + + + + + + image/svg+xml + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/shopfloor_base/static/description/index.html b/shopfloor_base/static/description/index.html new file mode 100644 index 0000000000..d768a83447 --- /dev/null +++ b/shopfloor_base/static/description/index.html @@ -0,0 +1,522 @@ + + + + + + +Shopfloor Base + + + +
+

Shopfloor Base

+ + +

Beta License: LGPL-3 OCA/wms Translate me on Weblate Try me on Runbot

+

Shopfloor is a barcode scanner application.

+

This module provides REST APIs to support scenario. It needs a frontend +to consume the backend APIs and provide screens for users on barcode devices. +A default front-end application is provided by shopfloor_mobile_base.

+

Table of contents

+ +
+

Configuration

+
+

Shopfloor config menu

+

In the main menu (or home screen) click on “Shopfloor”.

+
+
+

Profiles

+

In Shopfloor / Profiles.

+

The profiles are used to restrict which menus are shown on the frontend +application. When a user logs in the scanner application, they have to +select their profile, so the correct menus are shown.

+
+ +
+

Scenario

+

In Shopfloor / Scenario.

+

A Scenario represents a flow (or more basically “something to do” with the app. +Each scenario must have a name and a unique key. +The key must match a registered shopfloor service component.

+
+
+
+

Usage

+

An API key is created in the Demo data (for development), using +the Demo user. The key to use in the HTTP header API-KEY is: 72B044F7AC780DAC

+

Curl example:

+
+curl -X POST "http://localhost:8069/shopfloor/user/menu" -H  "accept: */*" -H  "Content-Type: application/json" -H "API-KEY: 72B044F7AC780DAC"
+
+
+
+

Known issues / Roadmap

+
    +
  • improve documentation
  • +
  • change shopfloor.scenario.key to selection? See comment in model
  • +
+
+
+

Changelog

+
+

13.0.1.0.0

+

First official version.

+
+
+
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Camptocamp
  • +
  • BCIM
  • +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

Design

+ +
+
+

Other credits

+

Financial support

+
    +
  • Cosanum
  • +
  • Camptocamp R&D
  • +
  • Akretion R&D
  • +
  • ACSONE R&D
  • +
+

Icons

+
    +
  • Tablet app icon by Gregor Cresnar from the Noun Project
  • +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+Odoo Community Association +

OCA, or the Odoo Community Association, is a nonprofit organization whose +mission is to support the collaborative development of Odoo features and +promote its widespread use.

+

Current maintainers:

+

guewen simahawk sebalix

+

This module is part of the OCA/wms project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/shopfloor_base/tests/__init__.py b/shopfloor_base/tests/__init__.py new file mode 100644 index 0000000000..a82e511759 --- /dev/null +++ b/shopfloor_base/tests/__init__.py @@ -0,0 +1,9 @@ +from . import test_actions_data +from . import test_menu_service +from . import test_profile_service +from . import test_scan_anything_service +from . import test_user_service +from . import test_user_config_service +from . import test_openapi +from . import test_shopfloor_scenario +from . import test_shopfloor_app diff --git a/shopfloor_base/tests/common.py b/shopfloor_base/tests/common.py new file mode 100644 index 0000000000..511adac7cd --- /dev/null +++ b/shopfloor_base/tests/common.py @@ -0,0 +1,204 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from contextlib import contextmanager +from pprint import pformat +from unittest import mock + +from odoo.tests.common import TransactionCase + +from odoo.addons.base_rest.controllers.main import _PseudoCollection +from odoo.addons.base_rest.tests.common import RegistryMixin +from odoo.addons.component.core import WorkContext +from odoo.addons.component.tests.common import ComponentMixin + + +class AnyObject: + def __repr__(self): + return "ANY" + + def __deepcopy__(self, memodict=None): + return self + + def __copy__(self): + return self + + def __eq__(self, other): + return True + + +class CommonCase(TransactionCase, RegistryMixin, ComponentMixin): + """Base class for writing Shopfloor tests + + All tests are run as normal stock user by default, to check that all the + services work without manager permissions. + + The consequences on writing tests: + + * Records created or written in a test setup must use sudo() + if the user has no permission on these models. + * Tests setUps should not extend setUpClass but setUpClassVars + and setUpClassBaseData, which already have an environment using + the stock user. + * Be wary of creating records before setUpClassUsers is called, because + it their "env.user" would be admin and could lead to inconsistencies + in tests. + + This class provides several helpers which are used throughout all the tests. + """ + + # by default disable tracking suite-wise, it's a time saver :) + tracking_disable = True + + ANY = AnyObject() # allow accepting anything in assert_response() + + maxDiff = None + + @contextmanager + def work_on_services(self, collection=None, env=None, **params): + collection = collection or self.shopfloor_app + if env: + collection = collection.with_env(env) + params = params or {} + yield WorkContext( + model_name="rest.service.registration", + collection=collection, + # No need for a real request mock + # as we don't deal w/ real request for testing + # but base_rest context provider needs it. + request=mock.Mock(), + **params + ) + + def get_service(self, usage, collection=None, env=None, **kw): + with self.work_on_services(collection=collection, env=env, **kw) as work: + service = work.component(usage=usage) + # Thanks to shopfloor.app we don't need controllers + # but not having a controller means that non decorated methods + # stay undecorated as they are not fixed at startup by base_rest. + self.env["shopfloor.app"]._prepare_non_decorated_endpoints(service) + return service + + @contextmanager + def work_on_actions(self, **params): + params = params or {} + collection = _PseudoCollection("shopfloor.action", self.env) + yield WorkContext( + model_name="rest.service.registration", collection=collection, **params + ) + + # pylint: disable=method-required-super + # super is called "the old-style way" to call both super classes in the + # order we want + def setUp(self): + # Have to initialize both odoo env and stuff + + # the Component registry of the mixin + TransactionCase.setUp(self) + ComponentMixin.setUp(self) + + @classmethod + def setUpClass(cls): + super(CommonCase, cls).setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=cls.tracking_disable, + _service_skip_request_validation=True, + ) + ) + cls.setUpComponent() + cls.setUpRegistry() + cls.setUpClassUsers() + cls.setUpClassVars() + cls.setUpClassBaseData() + + # Keep this here to have the whole env set up already + cls.setUpShopfloorApp() + + with cls.work_on_actions(cls) as work: + cls.data = work.component(usage="data") + cls.data_detail = work.component(usage="data_detail") + cls.msg_store = work.component(usage="message") + cls.schema = work.component(usage="schema") + cls.schema_detail = work.component(usage="schema_detail") + + @classmethod + def setUpClassUsers(cls): + Users = cls.env["res.users"].with_context( + no_reset_password=True, mail_create_nosubscribe=True + ) + cls.shopfloor_user = Users.create(cls._shopfloor_user_values()) + cls.shopfloor_manager = Users.create(cls._shopfloor_manager_values()) + cls.env = cls.env(user=cls.shopfloor_user) + + @classmethod + def _shopfloor_user_values(cls): + return { + "name": "Pauline Poivraisselle", + "login": "pauline2", + "email": "p.p@example.com", + "groups_id": [ + (6, 0, [cls.env.ref("shopfloor_base.group_shopfloor_user").id]) + ], + "tz": cls.env.user.tz, + } + + @classmethod + def _shopfloor_manager_values(cls): + return { + "name": "Johnny Manager", + "login": "jmanager", + "email": "jmanager@example.com", + "groups_id": [ + (6, 0, [cls.env.ref("shopfloor_base.group_shopfloor_manager").id]) + ], + "tz": cls.env.user.tz, + } + + @classmethod + def setUpClassVars(cls): + pass + + @classmethod + def setUpClassBaseData(cls): + pass + + @classmethod + def setUpShopfloorApp(cls): + cls.shopfloor_app = ( + cls.env["shopfloor.app"] + .with_user(cls.shopfloor_manager) + .create( + { + "tech_name": "test", + "name": "Test", + "short_name": "test", + } + ) + .with_user(cls.shopfloor_user) + ) + + def assert_response( + self, response, next_state=None, message=None, data=None, popup=None + ): + """Assert a response from the webservice + + The data and message dictionaries can use ``self.ANY`` to accept any + value. + """ + expected = {} + if message: + expected["message"] = message + if popup: + expected["popup"] = popup + if next_state: + expected.update( + {"next_state": next_state, "data": {next_state: data or {}}} + ) + elif data: + expected["data"] = data + self.assertDictEqual( + response, + expected, + "\n\nActual:\n%s" + "\n\nExpected:\n%s" % (pformat(response), pformat(expected)), + ) diff --git a/shopfloor_base/tests/common_http.py b/shopfloor_base/tests/common_http.py new file mode 100644 index 0000000000..d8092fe452 --- /dev/null +++ b/shopfloor_base/tests/common_http.py @@ -0,0 +1,81 @@ +# Copyright 2022 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +import os +import unittest + +import requests + +from odoo import tools +from odoo.tests.common import HttpSavepointCase + +from odoo.addons.base_rest.tests.common import RegistryMixin +from odoo.addons.component.tests.common import ComponentMixin + + +@unittest.skipIf(os.getenv("SKIP_HTTP_CASE"), "HttpCase skipped") +class HttpCommonCase(HttpSavepointCase, RegistryMixin, ComponentMixin): + """Common class for testing endpoints. + + Testing services is very good for unit/integration testing. + Testing that those services and their methods are properly exposed + via automatic controllers is very important as well. + + Use this class to make sure your services are working as expected. + """ + + tracking_disable = True + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env = cls.env( + context=dict( + cls.env.context, + tracking_disable=cls.tracking_disable, + ) + ) + cls.setUpComponent() + cls.setUpRegistry() + cls.setUpClassBaseData() + # Keep this here to have the whole env set up already + cls.setUpShopfloorApp() + + @classmethod + def setUpClassBaseData(cls): + pass + + @classmethod + def setUpShopfloorApp(cls): + cls.shopfloor_app = cls.env["shopfloor.app"].create( + { + "tech_name": "http_test", + "name": "HTTP Test", + "short_name": "HTTP test", + } + ) + + # pylint: disable=method-required-super + # super is called "the old-style way" to call both super classes in the + # order we want + def setUp(self): + # Have to initialize both odoo env and stuff + + # the Component registry of the mixin + HttpSavepointCase.setUp(self) + ComponentMixin.setUp(self) + # Make sure endpoints are available + self.shopfloor_app._handle_registry_sync() + + def _make_url(self, route): + return "http://127.0.0.1:%s%s" % (tools.config["http_port"], route) + + def _make_request(self, route, api_key=None, menu=None, profile=None, headers=None): + # use requests because you cannot easily manipulate the request w/ `url_open` + headers = headers or {} + if api_key: + headers["API-KEY"] = api_key.key + if menu: + headers["SERVICE-CTX-MENU-ID"] = str(menu.id) + if profile: + headers["SERVICE-CTX-PROFILE-ID"] = str(profile.id) + return requests.get(self._make_url(route), headers=headers, timeout=10) diff --git a/shopfloor_base/tests/common_misc.py b/shopfloor_base/tests/common_misc.py new file mode 100644 index 0000000000..99c30e1269 --- /dev/null +++ b/shopfloor_base/tests/common_misc.py @@ -0,0 +1,68 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import logging + +_logger = logging.getLogger(__name__) + + +try: + from cerberus import Validator +except ImportError: + _logger.debug("Can not import cerberus") + + +class ActionsDataTestMixin(object): + def assert_schema(self, schema, data): + validator = Validator(schema) + self.assertTrue(validator.validate(data), validator.errors) + + +class MenuTestMixin(object): + def _assert_menu_response(self, response, menus, **kw): + self.assert_response( + response, + data={ + "size": len(menus), + "records": [self._data_for_menu_item(menu, **kw) for menu in menus], + }, + ) + + def _data_for_menu_item(self, menu, **kw): + data = { + "id": menu.id, + "name": menu.name, + "scenario": menu.scenario_id.key, + } + return data + + +class OpenAPITestMixin(object): + def _test_openapi(self, **kw): + with self.work_on_services(**kw) as work: + components = work.many_components() + for comp in components: + if getattr(comp, "_is_rest_service_component", None) and comp._usage: + # will raise if it fails to generate the openapi specs + comp.to_openapi() + + +class ScanAnythingTestMixin(object): + def _test_response_ok(self, rec_type, data, identifier, record_types=None): + service = self.get_service("scan_anything") + params = {"identifier": identifier, "record_types": record_types} + response = service.dispatch("scan", params=params) + self.assert_response( + response, + data={"type": rec_type, "identifier": identifier, "record": data}, + ) + + def _test_response_ko(self, identifier, record_types=None): + service = self.get_service("scan_anything") + tried = record_types or [x.record_type for x in service._scan_handlers()] + params = {"identifier": identifier, "record_types": record_types} + response = service.dispatch("scan", params=params) + message = response["message"] + self.assertEqual(message["message_type"], "error") + self.assertIn("Record not found", message["body"]) + for rec_type in tried: + self.assertIn(rec_type, message["body"]) diff --git a/shopfloor_base/tests/test_actions_data.py b/shopfloor_base/tests/test_actions_data.py new file mode 100644 index 0000000000..baea9f6467 --- /dev/null +++ b/shopfloor_base/tests/test_actions_data.py @@ -0,0 +1,19 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import CommonCase +from .common_misc import ActionsDataTestMixin + + +class ActionsDataCase(CommonCase, ActionsDataTestMixin): + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.partner = cls.env.ref("base.res_partner_12").sudo() + return + + def test_data_partner(self): + data = self.data.partner(self.partner) + self.assert_schema(self.schema._simple_record(), data) + self.assertDictEqual( + data, {"id": self.partner.id, "name": self.partner.display_name} + ) diff --git a/shopfloor_base/tests/test_menu_service.py b/shopfloor_base/tests/test_menu_service.py new file mode 100644 index 0000000000..0471e4f893 --- /dev/null +++ b/shopfloor_base/tests/test_menu_service.py @@ -0,0 +1,36 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from .common import CommonCase +from .common_misc import MenuTestMixin + + +class MenuCase(CommonCase, MenuTestMixin): + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.profile = cls.env.ref("shopfloor_base.profile_demo_2") + return + + def test_menu_search(self): + """Request /menu/search""" + service = self.get_service("menu", profile=self.profile) + # Simulate the client searching menus + response = service.dispatch("search") + menus = self.env["shopfloor.menu"].search([]) + self._assert_menu_response(response, menus) + + def test_menu_search_restricted(self): + """Request /menu/search with profile attributions""" + # Simulate the client searching menus + menus = self.env["shopfloor.menu"].sudo().search([]) + menus_without_profile = menus[0:2] + # these menus should now be hidden for the current profile + other_profile = self.env.ref("shopfloor_base.profile_demo_1") + menus_without_profile.profile_id = other_profile + + service = self.get_service("menu", profile=self.profile) + response = service.dispatch("search") + + my_menus = menus - menus_without_profile + self._assert_menu_response(response, my_menus) diff --git a/shopfloor_base/tests/test_openapi.py b/shopfloor_base/tests/test_openapi.py new file mode 100644 index 0000000000..4ceab2a16d --- /dev/null +++ b/shopfloor_base/tests/test_openapi.py @@ -0,0 +1,10 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from .common import CommonCase +from .common_misc import OpenAPITestMixin + + +class TestOpenAPICommonCase(CommonCase, OpenAPITestMixin): + def test_openapi(self): + self._test_openapi() diff --git a/shopfloor_base/tests/test_profile_service.py b/shopfloor_base/tests/test_profile_service.py new file mode 100644 index 0000000000..29ce21819e --- /dev/null +++ b/shopfloor_base/tests/test_profile_service.py @@ -0,0 +1,22 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from .common import CommonCase + + +class ProfileCase(CommonCase): + def test_profile_search(self): + """Request /profile/search""" + service = self.get_service("profile") + # Simulate the client searching profiles + response = service.dispatch("search") + self.assert_response( + response, + data={ + "size": 2, + "records": [ + {"id": self.ANY, "name": "Demo Profile 1"}, + {"id": self.ANY, "name": "Demo Profile 2"}, + ], + }, + ) diff --git a/shopfloor_base/tests/test_scan_anything_service.py b/shopfloor_base/tests/test_scan_anything_service.py new file mode 100644 index 0000000000..91e3700a79 --- /dev/null +++ b/shopfloor_base/tests/test_scan_anything_service.py @@ -0,0 +1,89 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). + +from odoo.addons.component.core import Component + +from .common import CommonCase +from .common_misc import ScanAnythingTestMixin + + +class ScanAnythingCase(CommonCase, ScanAnythingTestMixin): + def _get_test_handlers(self): + class PartnerFinder(Component): + _name = "shopfloor.scan.partner.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "testpartner" + + def search(self, identifier): + return ( + self.env["res.partner"] + .sudo() + .search([("ref", "=", identifier)], limit=1) + ) + + @property + def converter(self): + return self._data.partner + + def schema(self): + return self._schema._simple_record() + + class CurrencyFinder(Component): + _name = "shopfloor.scan.currency.handler" + _inherit = "shopfloor.scan.anything.handler" + + record_type = "testcurrency" + + def search(self, identifier): + return ( + self.env["res.currency"] + .sudo() + .search([("name", "=", identifier)], limit=1) + ) + + @property + def converter(self): + return lambda record: record.jsonify( + self._data._simple_record_parser(), one=True + ) + + def schema(self): + return self._schema._simple_record() + + return (PartnerFinder, CurrencyFinder) + + def test_scan(self): + service = self.get_service("scan_anything") + test_handlers = self._get_test_handlers() + + for finder_class in test_handlers: + finder_class._build_component(service.work.components_registry) + + handlers = service._scan_handlers() + for handler_cls in test_handlers: + self.assertIn(handler_cls._name, [x._name for x in handlers]) + + record = self.env.ref("base.res_partner_4").sudo() + record.ref = "1234" + rec_type = "testpartner" + identifier = record.ref + data = record.jsonify(["id", "name"], one=True) + self._test_response_ok( + rec_type, data, identifier, record_types=("testpartner", "testcurrency") + ) + + record = self.env.ref("base.EUR").sudo() + rec_type = "testcurrency" + identifier = record.name + data = record.jsonify(["id", "name"], one=True) + self._test_response_ok( + rec_type, data, identifier, record_types=("testpartner", "testcurrency") + ) + + def test_scan_error(self): + self._test_response_ko( + "404-NOTFOUND", record_types=("testpartner", "testcurrency") + ) diff --git a/shopfloor_base/tests/test_shopfloor_app.py b/shopfloor_base/tests/test_shopfloor_app.py new file mode 100644 index 0000000000..690e76b76d --- /dev/null +++ b/shopfloor_base/tests/test_shopfloor_app.py @@ -0,0 +1,175 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from odoo.tests.common import Form + +from odoo.addons.shopfloor_base.utils import APP_VERSION + +from .common import CommonCase + + +# @tagged("-at_install") +class TestShopfloorApp(CommonCase): + @classmethod + def setUpClassUsers(cls): + super().setUpClassUsers() + cls.env = cls.env(user=cls.shopfloor_manager) + return + + @classmethod + def setUpClassBaseData(cls): + super().setUpClassBaseData() + cls.records = cls.env["shopfloor.app"].create( + { + "name": "A wonderful test app", + "tech_name": "test_app", + "short_name": "Test app", + } + ) + cls.env["shopfloor.app"].create( + { + "name": "A wonderful test app 2", + "tech_name": "test_app_2", + "short_name": "Test app 2", + } + ) + return + + def test_app_create(self): + # fmt: off + expected = [ + { + "api_route": "/shopfloor/api/test_app", + "url": "/shopfloor/app/test_app/", + }, + { + "api_route": "/shopfloor/api/test_app_2", + "url": "/shopfloor/app/test_app_2/", + }, + ] + # fmt: on + self.assertRecordValues(self.records, expected) + + def _test_registered_routes(self, rec): + # On class setup the registry is not ready thus endpoints are not registered yet + rec._register_controllers(init=True) + routes = rec._registered_routes() + _check = {} + for rule in routes: + self.assertEqual(rule.route_group, rec._route_group()) + self.assertTrue(rule.endpoint_hash) + service, endpoint = rule.route.split("/")[-2:] + expected_handler_opts = { + "default_pargs": [rec.id, service, endpoint], + "klass_dotted_path": ( + "odoo.addons.shopfloor_base.controllers.main.ShopfloorController" + ), + "method_name": "_process_endpoint", + } + self.assertEqual(rule.handler_options, expected_handler_opts) + _check[rule.route] = set(rule.routing["methods"]) + expected = { + # TODO: review methods + f"/shopfloor/api/{rec.tech_name}/app/user_config": {"POST"}, + f"/shopfloor/api/{rec.tech_name}/user/menu": {"POST"}, + f"/shopfloor/api/{rec.tech_name}/user/user_info": {"POST"}, + f"/shopfloor/api/{rec.tech_name}/menu/search": { + "GET", + }, + f"/shopfloor/api/{rec.tech_name}/profile/search": { + "GET", + }, + f"/shopfloor/api/{rec.tech_name}/scan_anything/scan": {"POST"}, + } + for route, method in expected.items(): + self.assertEqual( + _check[route], method, f"{route}: {method} != {_check[route]}" + ) + + expected = sorted([f"{k} ({', '.join(v)})" for k, v in expected.items()]) + rec.invalidate_recordset(["registered_routes"]) + self.assertEqual( + sorted(rec.registered_routes.splitlines()), + expected, + f"{rec.tech_name} failed", + ) + + def test_registered_routes(self): + rec1, rec2 = self.records + self._test_registered_routes(rec1) + self._test_registered_routes(rec2) + # TODO: test after routing_map cleaned + + def test_api_url_for_service(self): + app = self.shopfloor_app + self.assertEqual( + app.api_url_for_service("profile"), + f"/shopfloor/api/{app.tech_name}/profile", + ) + self.assertEqual( + app.api_url_for_service("profile", "search"), + f"/shopfloor/api/{app.tech_name}/profile/search", + ) + self.assertEqual( + app.api_url_for_service("app", "user_config"), + f"/shopfloor/api/{app.tech_name}/app/user_config", + ) + + def test_make_app_info(self): + info = self.shopfloor_app._make_app_info() + expected = { + "auth_type": "user_endpoint", + "base_url": "/shopfloor/api/test/", + "url": "/shopfloor/app/test/", + "demo_mode": False, + "manifest_url": "/shopfloor/app/test/manifest.json", + "name": "Test", + "profile_required": False, + "running_env": "prod", + "short_name": "test", + "version": APP_VERSION, + "lang": { + "default": False, + "enabled": [], + }, + } + self.assertEqual(info, expected) + info = self.shopfloor_app._make_app_info(demo=True) + self.assertEqual(info["demo_mode"], True) + lang_en, lang_fr = self.env.ref("base.lang_en"), self.env.ref("base.lang_fr") + lang_fr.sudo().active = True + self.shopfloor_app.sudo().lang_id = lang_en + self.shopfloor_app.sudo().lang_ids = lang_en + lang_fr + info = self.shopfloor_app._make_app_info() + self.assertEqual( + info["lang"], {"default": "en-US", "enabled": ["en-US", "fr-FR"]} + ) + + def test_make_app_manifest(self): + param = "http://localhost:8069" + manifest = self.shopfloor_app._make_app_manifest() + expected = { + "name": self.shopfloor_app.name, + "short_name": self.shopfloor_app.short_name, + "start_url": param + self.shopfloor_app.url, + "scope": param + self.shopfloor_app.url, + "id": self.shopfloor_app.url, + "display": "fullscreen", + "icons": [], + } + self.assertEqual(manifest, expected) + + def test_lang_onchanges(self): + lang_en, lang_fr = self.env.ref("base.lang_en"), self.env.ref("base.lang_fr") + lang_fr.sudo().active = True + form = Form(self.shopfloor_app) + # No avail langs + self.assertFalse(form.lang_id) + self.assertFalse(form.lang_ids) + form.lang_id = lang_en + # Avail langs updated + self.assertIn(lang_en, form.lang_ids) + # Replace avail w/ FR + form.lang_ids.add(lang_fr) + form.lang_ids.remove(lang_en.id) + # lang wiped out + self.assertFalse(form.lang_id) diff --git a/shopfloor_base/tests/test_shopfloor_scenario.py b/shopfloor_base/tests/test_shopfloor_scenario.py new file mode 100644 index 0000000000..23dadfac05 --- /dev/null +++ b/shopfloor_base/tests/test_shopfloor_scenario.py @@ -0,0 +1,52 @@ +# Copyright 2021 ACSONE SA/NV (http://www.acsone.eu) +# @author Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +import json + +from odoo.tests.common import Form + +from .common import CommonCase + + +class TestShopfloorScenario(CommonCase): + @classmethod + def setUpClassUsers(cls): + super().setUpClassUsers() + cls.env = cls.env(user=cls.shopfloor_manager) + return + + def test_scenario(self): + rec = self.env["shopfloor.scenario"].create( + { + "name": "New Scenario", + "options_edit": """ +{ + "opt1": true, + "opt2": false, + "opt3": + { + "nested": true + } +} + """, + } + ) + self.assertEqual(rec.key, "new_scenario") + # fmt: off + expected = { + "opt1": True, + "opt2": False, + "opt3": { + "nested": True, + }, + } + # fmt: on + self.assertEqual(rec.options, expected) + self.assertEqual( + rec.options_edit, json.dumps(expected, indent=4, sort_keys=True) + ) + with Form(self.env["shopfloor.scenario"]) as form: + form.name = "Test Onchange" + self.assertEqual(form.key, "test_onchange") + + # TODO: test other records (menu, profile) diff --git a/shopfloor_base/tests/test_user_config_service.py b/shopfloor_base/tests/test_user_config_service.py new file mode 100644 index 0000000000..a1c7be9645 --- /dev/null +++ b/shopfloor_base/tests/test_user_config_service.py @@ -0,0 +1,32 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import CommonCase + + +class AppCase(CommonCase): + @classmethod + def setUpClassVars(cls): + super().setUpClassVars() + cls.profile = cls.env.ref("shopfloor_base.profile_demo_1") + cls.profile2 = cls.env.ref("shopfloor_base.profile_demo_2") + return + + def test_user_config(self): + """Request /app/user_config""" + # Simulate the client asking the configuration + service = self.get_service("app", profile=self.profile) + response = service.dispatch("user_config") + profiles = self.env["shopfloor.profile"].search([]) + self.assert_response( + response, + data={ + "profiles": [ + {"id": profile.id, "name": profile.name} for profile in profiles + ], + "user_info": { + "id": self.env.user.id, + "name": self.env.user.name, + "lang": self.env.user.lang.replace("_", "-"), + }, + }, + ) diff --git a/shopfloor_base/tests/test_user_service.py b/shopfloor_base/tests/test_user_service.py new file mode 100644 index 0000000000..4bb28aca3b --- /dev/null +++ b/shopfloor_base/tests/test_user_service.py @@ -0,0 +1,63 @@ +# Copyright 2020 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +from .common import CommonCase +from .common_misc import MenuTestMixin + + +class UserCase(CommonCase, MenuTestMixin): + @classmethod + def setUpClassVars(cls, *args, **kwargs): + super().setUpClassVars(*args, **kwargs) + ref = cls.env.ref + profile1 = ref("shopfloor_base.profile_demo_1") + cls.profile = profile1.sudo().copy() + cls.profile2 = ref("shopfloor_base.profile_demo_2") + menu_xid_pref = "shopfloor_base.shopfloor_menu_" + cls.menu_items = ref(menu_xid_pref + "demo_1") + # Isolate menu items + cls.menu_items.sudo().write({"profile_id": cls.profile.id}) + cls.env["shopfloor.menu"].search( + [("id", "not in", cls.menu_items.ids)] + ).sudo().write({"profile_id": profile1.id}) + return + + def test_menu_no_profile(self): + """Request /user/menu""" + service = self.get_service("user", profile=self.profile) + # Simulate the client asking the menu + response = service.dispatch("menu") + menus = self.menu_items + self.assert_response( + response, + data={"menus": [self._data_for_menu_item(menu) for menu in menus]}, + ) + + def test_menu_by_profile(self): + """Request /user/menu w/ a specific profile""" + # Simulate the client asking the menu + menus = self.menu_items.sudo() + menu = menus[0] + menu.profile_id = self.profile + (menus - menu).profile_id = self.profile2 + + service = self.get_service("user", profile=self.profile) + response = service.dispatch("menu") + self.assert_response( + response, + data={"menus": [self._data_for_menu_item(menu)]}, + ) + + def test_user_info(self): + """Request /user/user_info""" + service = self.get_service("user", profile=self.profile) + response = service.dispatch("user_info") + self.assert_response( + response, + data={ + "user_info": { + "id": self.env.user.id, + "name": self.env.user.name, + "lang": self.env.user.lang.replace("_", "-"), + } + }, + ) diff --git a/shopfloor_base/utils.py b/shopfloor_base/utils.py new file mode 100644 index 0000000000..24871b3dd4 --- /dev/null +++ b/shopfloor_base/utils.py @@ -0,0 +1,67 @@ +# Copyright 2021 Camptocamp SA (http://www.camptocamp.com) +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl.html). +# @author Simone Orsi +import os +from functools import wraps + +from odoo.modules.module import get_manifest +from odoo.tools.config import config as odoo_config + + +def ensure_model(model_name): + """Decorator to ensure data method is called w/ the right recordset.""" + + def _ensure_model(func): + @wraps(func) + def wrapped(*args, **kwargs): + # 1st arg is `self` + record = args[1] + if record is not None: + assert ( + record._name == model_name + ), f"Expected model: {model_name}. Got: {record._name}" + return func(*args, **kwargs) + + return wrapped + + return _ensure_model + + +APP_VERSIONS = {} + + +def get_version(module_name, module_path=None): + """Return module version straight from manifest.""" + global APP_VERSIONS + if APP_VERSIONS.get(module_name): + return APP_VERSIONS[module_name] + try: + info = get_manifest(module_name, mod_path=module_path) + APP_VERSIONS[module_name] = info["version"] + return APP_VERSIONS[module_name] + except Exception: + return "dev" + + +APP_VERSION = get_version("shopfloor_mobile_base") + + +def _get_running_env(): + """Retrieve current system environment. + + Expected key `RUNNING_ENV` is compliant w/ `server_environment` naming + but is not depending on it. + + Additionally, as specific key for Shopfloor is supported. + + You don't need `server_environment` module to have this feature. + """ + for key in ("SHOPFLOOR_RUNNING_ENV", "RUNNING_ENV"): + if os.getenv(key): + return os.getenv(key) + if odoo_config.options.get(key.lower()): + return odoo_config.get(key.lower()) + return "prod" + + +RUNNING_ENV = _get_running_env() diff --git a/shopfloor_base/views/menus.xml b/shopfloor_base/views/menus.xml new file mode 100644 index 0000000000..174af907ce --- /dev/null +++ b/shopfloor_base/views/menus.xml @@ -0,0 +1,35 @@ + + + + + + + + + + diff --git a/shopfloor_base/views/shopfloor_app.xml b/shopfloor_base/views/shopfloor_app.xml new file mode 100644 index 0000000000..9abccec0f1 --- /dev/null +++ b/shopfloor_base/views/shopfloor_app.xml @@ -0,0 +1,138 @@ + + + + shopfloor app tree + shopfloor.app + + + + + + + + + + + shopfloor app form + shopfloor.app + +
+ +
+
+ + +