diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 00000000000..804d90498f4 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,2 @@ +# generated from manifests external_dependencies +markdown diff --git a/screen_help/README.rst b/screen_help/README.rst new file mode 100644 index 00000000000..a690c36c7ba --- /dev/null +++ b/screen_help/README.rst @@ -0,0 +1,182 @@ +.. image:: https://odoo-community.org/readme-banner-image + :target: https://odoo-community.org/get-involved?utm_source=readme + :alt: Odoo Community Association + +=========== +Screen Help +=========== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:b113369bd035b86629b2afd3508c259b9c418b7d9cdab283d61336f951214772 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |badge2| image:: https://img.shields.io/badge/license-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fknowledge-lightgray.png?logo=github + :target: https://github.com/OCA/knowledge/tree/19.0/screen_help + :alt: OCA/knowledge +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/knowledge-19-0/knowledge-19-0-screen_help + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/knowledge&target_branch=19.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +Contextual help on the screens of Odoo, written in Odoo or shipped by +modules as Markdown files. + +A book button in the control panel opens the help of the current screen +in a window detached from the Odoo tab, which can stay open beside Odoo. + +The help of a screen is made of sections, ordered by sequence, which +target: + +- a window action: shown on the screens of this action +- or a model: shown on every screen of this model, whatever the action + +**The module shipping the files doesn't need to depend on +``screen_help``.** + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +- Users of the *Help Editor* group (and administrators) can write the + help: *Settings > Technical > User Interface > Help Sections*, or the + *Edit* button of the help window. +- *Settings > General Settings > Help > Multilingual Help*: a section + can be written for a given language. A section without language is + shown to everybody. The help is not translated through the standard + translation mechanism. +- Dark mode: the help window follows the color scheme of the user + (``ir.http.color_scheme()``) when a module provides it, e.g. + ``web_dark_mode`` (OCA/web) or ``web_enterprise``; no dependency on + them. + +Usage +===== + +**Read the help**: click the book button of the control panel. The +button is shown when the screen has some help, or to the help editors. + +**Write the help in Odoo**: *Edit* button of the help window, then add a +section targeting the action or the model of the screen. + +|Help window beside Odoo| + +**Ship the help in a module**: add Markdown files in its ``help`` +directory: + +:: + + my_module/help/sale.order.md # every screen of the model + my_module/help/sale.action_quotations.md # one window action (xml id) + my_module/help/sale.action_quotations.fr_FR.md # in French only + +**You don't need to depends on screen_help** to provide a such files to +document your module, just add markdown like above + +An optional header orders the sections (sequence 10 by default). In the +application help, the screens are ordered by the lowest sequence of +their sections, then by the menu order: + +.. code:: markdown + + --- + sequence: 20 + --- + ## My help + +When ``screen_help`` is installed, the ``help`` directory of a module is +loaded at each installation / update of the module (and for all the +installed modules at the installation of ``screen_help``). Without +``screen_help``, it is ignored. + +- a section loaded from a file can't be edited in Odoo +- a file removed from the module removes its section at the next update +- a file whose model or action doesn't exist (module not installed) is + ignored +- the headings (``#``, ``##``, ``###``) make the table of contents of + the help window + +|Section loaded from a file| + +**Demo**: on a database with demo data, a tour documents each feature on +the screen that shows it, each section pointing to the next screen like +contacts, +`/odoo/action-base.action_partner_form `__ + +|Help window in dark mode| + +.. |Help window beside Odoo| image:: https://raw.githubusercontent.com/OCA/knowledge/19.0/screen_help/static/description/help1.png +.. |Section loaded from a file| image:: https://raw.githubusercontent.com/OCA/knowledge/19.0/screen_help/static/description/help3.png +.. |Help window in dark mode| image:: https://raw.githubusercontent.com/OCA/knowledge/19.0/screen_help/static/description/help2.png + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +------- + +* Akretion + +Contributors +------------ + +- `Akretion `__: + + - David Beal david.beal@akretion.com Assisted-by: Claude Opus 5.5 + +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-bealdav| image:: https://github.com/bealdav.png?size=40px + :target: https://github.com/bealdav + :alt: bealdav + +Current `maintainer `__: + +|maintainer-bealdav| + +This module is part of the `OCA/knowledge `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/screen_help/__init__.py b/screen_help/__init__.py new file mode 100644 index 00000000000..8412f7bbd79 --- /dev/null +++ b/screen_help/__init__.py @@ -0,0 +1,10 @@ +from . import controllers +from . import models + + +def post_init_hook(env): + # help files of the modules installed before screen_help + for module in env["ir.module.module"].search([("state", "=", "installed")]): + env["help.section"]._load_markdown(module.name) + # help of the actions left by help_popup, if any + env["help.section"]._import_help_popup() diff --git a/screen_help/__manifest__.py b/screen_help/__manifest__.py new file mode 100644 index 00000000000..8ea1a6de3a9 --- /dev/null +++ b/screen_help/__manifest__.py @@ -0,0 +1,32 @@ +{ + "name": "Screen Help", + "summary": "Contextual help on actions, editable in Odoo or shipped as Markdown", + "version": "19.0.1.0.0", + "development_status": "Alpha", + "category": "Knowledge Management", + "website": "https://github.com/OCA/knowledge", + "author": "Akretion, Odoo Community Association (OCA)", + "maintainers": ["bealdav"], + "license": "AGPL-3", + "depends": ["base_setup"], + "external_dependencies": { + "python": ["markdown"], + }, + "data": [ + "security/groups.xml", + "security/ir.model.access.csv", + "views/help_section.xml", + "views/res_config_settings.xml", + "views/help_page.xml", + ], + "demo": [ + "data/demo.xml", + "data/demo_section.xml", + ], + "post_init_hook": "post_init_hook", + "assets": { + "web.assets_backend": [ + "screen_help/static/src/**/*", + ], + }, +} diff --git a/screen_help/controllers/__init__.py b/screen_help/controllers/__init__.py new file mode 100644 index 00000000000..12a7e529b67 --- /dev/null +++ b/screen_help/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/screen_help/controllers/main.py b/screen_help/controllers/main.py new file mode 100644 index 00000000000..4a5d8a1b4cc --- /dev/null +++ b/screen_help/controllers/main.py @@ -0,0 +1,39 @@ +from odoo import http +from odoo.http import request + + +class ScreenHelp(http.Controller): + @http.route("/screen_help", type="http", auth="user") + def help(self, action_id=None, model=None, menu_id=None): + env = request.env + action = env["ir.actions.act_window"].browse(int(action_id or 0)).exists() + model = model or action.res_model + if action: + title, prefix = action.name, f"help_{action.id}" + else: + title, prefix = env["ir.model"]._get(model).name or model, "help" + body, toc = env["help.section"]._for(action, model)._html(prefix) + return request.render( + "screen_help.screen_help_page", + { + **env["ir.http"].webclient_rendering_context(), + "title": title, + "body": body, + "toc": toc, + "menu_id": int(menu_id) if menu_id else False, + "can_edit": env.user.has_group("screen_help.group_editor"), + "action_id": action.id, + "model": model, + }, + ) + + @http.route("/screen_help/app/", type="http", auth="user") + def app_help(self, menu_id): + env = request.env + menu = env["ir.ui.menu"].browse(menu_id).exists() + if not menu: + raise request.not_found() + return request.render( + "screen_help.app_help_page", + {**env["ir.http"].webclient_rendering_context(), "menu": menu}, + ) diff --git a/screen_help/data/demo.xml b/screen_help/data/demo.xml new file mode 100644 index 00000000000..a38e3c9859a --- /dev/null +++ b/screen_help/data/demo.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/screen_help/data/demo_section.xml b/screen_help/data/demo_section.xml new file mode 100644 index 00000000000..0d9169bb987 --- /dev/null +++ b/screen_help/data/demo_section.xml @@ -0,0 +1,21 @@ + + + + + +

Help written in Odoo

+

Unlike the other sections of the demo, this one is not a file + of a module: it is written in Odoo, with the HTML editor.

+

Who can write the help

+
    +
  • the users of the Help Editor group + (the administrators are in it)
  • +
  • with the Edit button of this window, or + Settings > Technical > User Interface > + Help Sections
  • +
+

Next

+

Open Settings > Users & Companies > Companies.

+
+
+
diff --git a/screen_help/demo_help/base.action_partner_form.fr_FR.md b/screen_help/demo_help/base.action_partner_form.fr_FR.md new file mode 100644 index 00000000000..2a8d2b435a1 --- /dev/null +++ b/screen_help/demo_help/base.action_partner_form.fr_FR.md @@ -0,0 +1,14 @@ +--- +sequence: 15 +--- + +## Aide dans une langue + +Cette section n'est visible que des utilisateurs en français : elle vient du fichier +`base.action_partner_form.fr_FR.md`. Elle complète les sections sans langue, montrées à +tout le monde : ce n'est pas une traduction. + +Exemple : le **SIRET** est obligatoire pour les sociétés clientes. + +Pour choisir la langue d'une section écrite dans Odoo, activez le réglage _Multilingual +Help_ (_Paramètres > Paramètres généraux_, bloc _Help_). diff --git a/screen_help/demo_help/base.action_partner_form.md b/screen_help/demo_help/base.action_partner_form.md new file mode 100644 index 00000000000..9df23ef837b --- /dev/null +++ b/screen_help/demo_help/base.action_partner_form.md @@ -0,0 +1,25 @@ +## Help of a screen + +This help is the demo of _Screen Help_: each screen of the demo documents one of its +features. You opened it with the book button of the control panel, shown when the screen +has some help, or always to the help editors. + +### Help of an action + +This section targets the window action of the screen: a module ships it as the file +`help/base.action_partner_form.md`, named after the xml id of the action. + +| Element of the window | Use | +| --------------------- | ----------------------------------------------------- | +| Sidebar | table of contents, made from the headings of the help | +| _Print_ | prints the help of the screen | +| _Application Help_ | the help of all the screens of the application | +| _Edit_ | edits the help in the Odoo tab (help editors only) | + +The window stays open beside Odoo: keep it while you work. + +![Help window beside Odoo](/screen_help/static/description/help1.png) + +### Next + +The section below targets the model of the contacts, not this action. diff --git a/screen_help/demo_help/base.action_res_company_form.md b/screen_help/demo_help/base.action_res_company_form.md new file mode 100644 index 00000000000..1c3f75cd92d --- /dev/null +++ b/screen_help/demo_help/base.action_res_company_form.md @@ -0,0 +1,19 @@ +## Help of an application + +Click _Application Help_ at the top of this window: it shows the help of all the screens +of the current application (here _Settings_: the users and the companies), one chapter +per screen, ordered by the lowest sequence of their sections, then by the menu order. +The help of a model is shown once, with its first screen. + +### Look of the window + +- **Logo**: the logo of the company, at the top of the window +- **Print**: the help, without the buttons and the sidebar +- **Dark mode**: follows the color scheme of the user, when a module provides it + (`web_dark_mode` of OCA/web, or `web_enterprise`) + +![Help window in dark mode](/screen_help/static/description/help2.png) + +### Next + +Open _Settings > Technical > User Interface > Help Sections_. diff --git a/screen_help/demo_help/res.partner.md b/screen_help/demo_help/res.partner.md new file mode 100644 index 00000000000..ec422e9374f --- /dev/null +++ b/screen_help/demo_help/res.partner.md @@ -0,0 +1,28 @@ +--- +sequence: 20 +--- + +## Help of a model + +This section targets the model `res.partner`: it is shown on every screen of the +contacts, whatever the action. A module ships it as the file `help/res.partner.md`, +named after the model. + +### Order of the sections + +The sections are ordered by sequence (10 by default). The header at the top of the file +puts this one after the help of the action: + +```markdown +--- +sequence: 20 +--- +``` + +### Table of contents + +The headings `#`, `##` and `###` make the table of contents of the sidebar. + +### Next + +Open _Settings > Users & Companies > Users_: a section written in Odoo. diff --git a/screen_help/demo_help/screen_help.help_section_action.md b/screen_help/demo_help/screen_help.help_section_action.md new file mode 100644 index 00000000000..3783ad27ac9 --- /dev/null +++ b/screen_help/demo_help/screen_help.help_section_action.md @@ -0,0 +1,29 @@ +--- +sequence: 5 +--- + +## Help shipped by a module + +The sections with a _File_ come from the Markdown files of a module, in its `help` +directory: + +``` +my_module/help/res.partner.md # every screen of the model +my_module/help/base.action_partner_form.md # one window action (xml id) +my_module/help/base.action_partner_form.fr_FR.md # French users only +``` + +### Life cycle + +- loaded at each installation or update of the module, which doesn't need to depend on + _Screen Help_ +- read-only here, only the sequence can be changed: edit the file instead +- a file removed from the module removes its section at the next update +- removed when the module is uninstalled +- ignored when its model or action doesn't exist (module not installed) + +![Section loaded from a file](/screen_help/static/description/help3.png) + +### Demo + +Start the tour of the features on the contacts: `/odoo/action-base.action_partner_form`. diff --git a/screen_help/models/__init__.py b/screen_help/models/__init__.py new file mode 100644 index 00000000000..cc91624c6f0 --- /dev/null +++ b/screen_help/models/__init__.py @@ -0,0 +1,5 @@ +from . import help_section +from . import ir_actions +from . import ir_model_data +from . import ir_ui_menu +from . import res_config_settings diff --git a/screen_help/models/help_section.py b/screen_help/models/help_section.py new file mode 100644 index 00000000000..59e9cd91ac6 --- /dev/null +++ b/screen_help/models/help_section.py @@ -0,0 +1,255 @@ +import hashlib +import logging +import re +from pathlib import Path + +from lxml import html as lhtml +from markupsafe import Markup + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError +from odoo.fields import Domain +from odoo.modules.module import get_module_path +from odoo.tools import plaintext2html +from odoo.tools.sql import column_exists + +_logger = logging.getLogger(__name__) + +try: + import markdown +except ImportError: + _logger.debug("markdown not installed") + +LANG_RE = re.compile(r"^[a-z]{2,3}_[A-Z]{2}(@\w+)?$") +FRONT_MATTER_RE = re.compile(r"\A---\s*\n(.*?)\n---\s*\n", re.DOTALL) + + +class HelpSection(models.Model): + """Part of the help of a screen: written in Odoo or shipped by a module + as Markdown files (see `_load_markdown`). + + A section targets either a window action, or a model: then it is shown on + every screen of this model, whatever the action.""" + + _name = "help.section" + _description = "Help Section" + _order = "sequence, id" + + action_id = fields.Many2one("ir.actions.act_window", ondelete="cascade", index=True) + model_id = fields.Many2one( + "ir.model", + ondelete="cascade", + help="Shown on every screen of this model, whatever the action.", + ) + model = fields.Char( + string="Model Name", related="model_id.model", store=True, index=True + ) + sequence = fields.Integer(default=10) + module = fields.Char( + readonly=True, help="Module shipping this section: removed with it." + ) + lang = fields.Selection( + selection="_get_langs", help="Empty: shown whatever the user language." + ) + content = fields.Html() + file = fields.Char( + readonly=True, help="Markdown file of the module the section comes from." + ) + checksum = fields.Char( + readonly=True, help="Checksum of the Markdown file the section comes from." + ) + + _file_fields = ("action_id", "model_id", "lang", "content") + + def _compute_display_name(self): + for rec in self: + if rec.action_id: + main = rec.action_id.name + else: + main = rec.model_id.name + rec.display_name = f"{main} - {rec.module}" + + @api.constrains("action_id", "model_id") + def _check_target(self): + for rec in self: + if bool(rec.action_id) == bool(rec.model_id): + raise ValidationError( + self.env._("A help section targets either an action or a model.") + ) + + def write(self, vals): + # install_mode: help files and xml data files of the modules + if ( + not self.env.context.get("install_mode") + and set(vals) & set(self._file_fields) + and any(self.mapped("file")) + ): + raise UserError( + self.env._( + "This help comes from a file of a module: edit the file " + "instead, its content would overwrite your changes." + ) + ) + return super().write(vals) + + @api.model + def _get_langs(self): + return self.env["res.lang"].get_installed() + + @api.model + def _for(self, action=None, model=None): + """Sections shown on a screen: those of its action and of its model, + in the user language""" + targets = [] + if action: + targets.append(Domain("action_id", "=", action.id)) + if model or action: + targets.append(Domain("model", "=", model or action.res_model)) + if not targets: + return self.browse() + return self.search( + Domain("lang", "in", (False, self.env.lang)) + & Domain("content", "!=", False) + & Domain.OR(targets) + ) + + def _html(self, prefix): + """Html of the sections, with an anchor on their headings, + and its table of contents: [(level, anchor, title)]""" + content = "".join(self.mapped("content")) + if not content: + return Markup(), [] + root = lhtml.fragment_fromstring(content, create_parent="div") + toc = [] + for index, heading in enumerate(root.iter("h1", "h2", "h3")): + anchor = f"{prefix}_{index}" + heading.set("id", anchor) + toc.append((int(heading.tag[1]), anchor, heading.text_content().strip())) + return Markup(lhtml.tostring(root, encoding="unicode")), toc + + @api.model + def get_help(self, action_id, model=None): + """Called by the help buttons of the control panel""" + action = self.env["ir.actions.act_window"].browse(action_id or []).exists() + return { + "has_help": bool(self._for(action, model)), + "can_edit": self.env.user.has_group("screen_help.group_editor"), + } + + @api.model + def _load_markdown(self, module, folder="help"): + """Load the Markdown files of the directory of a module: + + - [.].md: help of every screen of the model, + e.g. help/sale.order.md + - [.].md: help of a window action, + e.g. help/sale.action_quotations.fr_FR.md + + Optional header at the top of the file, to order the sections: + --- + sequence: 20 + --- + + Called for each installed / updated module (see ir.model.data): + the module doesn't need to depend on screen_help. + These sections can't be edited in Odoo: their file is the reference. + The sections are removed with the module.""" + module_path = get_module_path(module, display_warning=False) + path = module_path and Path(module_path, folder) + if not path or not path.is_dir(): + return self.browse() + langs = dict(self._get_langs()) + records = [] + for file in sorted(path.glob("*.md")): + name, lang = file.stem, False + prefix, __, suffix = name.rpartition(".") + if LANG_RE.match(suffix): + name, lang = prefix, suffix + if lang and lang not in langs: + continue + values = { + "module": module, + "lang": lang, + "file": str(file.relative_to(module_path)), + } + if name in self.env: + values["model_id"] = self.env["ir.model"]._get_id(name) + else: + action = self.env.ref(name, raise_if_not_found=False) + if action is None or action._name != "ir.actions.act_window": + _logger.warning("%s: unknown model or window action", file) + continue + values["action_id"] = action.id + text = file.read_text() + checksum = hashlib.sha1(text.encode()).hexdigest() + xml_id = "{}.help_{}".format(module, file.stem.replace(".", "_")) + current = self.env.ref(xml_id, raise_if_not_found=False) + if current is None or current.checksum != checksum: + header, text = self._front_matter(text) + values.update(content=self._md_to_html(text), checksum=checksum) + if header.get("sequence", "").lstrip("-").isdigit(): + values["sequence"] = int(header["sequence"]) + records.append({"xml_id": xml_id, "values": values}) + return self.with_context( + install_mode=True, install_module=module + )._load_records(records, update=True) + + @api.model + def _import_help_popup(self): + """Sections from the help of the actions of help_popup (OCA/web 8.0), + whose columns are left in the database: `enduser_help` (html) and + `advanced_help` (text, often html). + + Called when screen_help is installed, or updated from help_popup + renamed to screen_help. The actions which already have a section + written in Odoo (or imported) are skipped; the columns are kept.""" + cr = self.env.cr + columns = [ + col + for col in ("enduser_help", "advanced_help") + if column_exists(cr, "ir_act_window", col) + ] + if not columns: + return self.browse() + where = " OR ".join(f"COALESCE({col}, '') != ''" for col in columns) + query = f"SELECT id, {', '.join(columns)} FROM ir_act_window WHERE {where}" + done = set(self.search([("file", "=", False)]).action_id.ids) + cr.execute(query) # pylint: disable=sql-injection + vals_list = [] + for row in cr.dictfetchall(): + if row["id"] in done: + continue + if row.get("enduser_help"): + vals_list.append( + {"action_id": row["id"], "content": row["enduser_help"]} + ) + if row.get("advanced_help"): + text = row["advanced_help"] + body = Markup(text) if "<" in text else plaintext2html(text) + title = self.env._("Advanced Help") + vals_list.append( + { + "action_id": row["id"], + "sequence": 20, + "content": Markup("

%s

%s") % (title, body), + } + ) + _logger.info("help_popup: %s help sections imported", len(vals_list)) + return self.create(vals_list) + + @api.model + def _front_matter(self, text): + """Split a Markdown file into its header ({key: value}) and its content""" + match = FRONT_MATTER_RE.match(text) + if not match: + return {}, text + header = {} + for line in match.group(1).splitlines(): + key, sep, value = line.partition(":") + if sep: + header[key.strip()] = value.strip() + return header, text[match.end() :] + + @api.model + def _md_to_html(self, text): + return markdown.markdown(text, extensions=["tables", "fenced_code"]) diff --git a/screen_help/models/ir_actions.py b/screen_help/models/ir_actions.py new file mode 100644 index 00000000000..6885931b3f0 --- /dev/null +++ b/screen_help/models/ir_actions.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class IrActionsActWindow(models.Model): + _inherit = "ir.actions.act_window" + + help_section_ids = fields.One2many("help.section", "action_id") + + def _help_html(self): + """Help of the action (sections of the action and of its model), + with its table of contents (see help.section._html)""" + self.ensure_one() + return self.env["help.section"]._for(self)._html(f"help_{self.id}") diff --git a/screen_help/models/ir_model_data.py b/screen_help/models/ir_model_data.py new file mode 100644 index 00000000000..37bc6688c75 --- /dev/null +++ b/screen_help/models/ir_model_data.py @@ -0,0 +1,13 @@ +from odoo import api, models + + +class IrModelData(models.Model): + _inherit = "ir.model.data" + + @api.model + def _process_end(self, modules): + # load the help files of the installed / updated modules before the + # records of these modules not loaded again are removed + for module in modules: + self.env["help.section"]._load_markdown(module) + return super()._process_end(modules) diff --git a/screen_help/models/ir_ui_menu.py b/screen_help/models/ir_ui_menu.py new file mode 100644 index 00000000000..758739b1672 --- /dev/null +++ b/screen_help/models/ir_ui_menu.py @@ -0,0 +1,40 @@ +from odoo import models + + +class IrUiMenu(models.Model): + _inherit = "ir.ui.menu" + + def _help_actions(self): + """Window actions with help under these menus, in the menu order""" + actions = self.env["ir.actions.act_window"] + for menu in self: + if menu.action and menu.action._name == "ir.actions.act_window": + actions |= menu.action + actions |= menu.child_id._help_actions() + return actions.filtered(lambda a: self.env["help.section"]._for(a)) + + def _help_html(self): + """Help of the actions under the menu: [(action, html)], and its table + of contents: one entry per action, followed by its headings. + The actions are ordered by the lowest sequence of their sections, + then by the menu order. + The help of a model is shown once, with its first action.""" + self.ensure_one() + Section = self.env["help.section"] + actions = sorted( + self._help_actions(), + key=lambda a: min(Section._for(a).mapped("sequence")), + ) + helps, toc, models_done = [], [], set() + for action in actions: + sections = Section._for(action) + if action.res_model in models_done: + sections = sections.filtered("action_id") + if not sections: + continue + models_done.add(action.res_model) + html, action_toc = sections._html(f"help_{action.id}") + helps.append((action, html)) + toc.append((1, f"help_{action.id}", action.name)) + toc += action_toc + return helps, toc diff --git a/screen_help/models/res_config_settings.py b/screen_help/models/res_config_settings.py new file mode 100644 index 00000000000..848c1022dd0 --- /dev/null +++ b/screen_help/models/res_config_settings.py @@ -0,0 +1,11 @@ +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + group_help_multilang = fields.Boolean( + string="Multilingual Help", + implied_group="screen_help.group_multilang", + help="Help sections can be written for a given language.", + ) diff --git a/screen_help/pyproject.toml b/screen_help/pyproject.toml new file mode 100644 index 00000000000..4231d0cccb3 --- /dev/null +++ b/screen_help/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/screen_help/readme/CONFIGURE.md b/screen_help/readme/CONFIGURE.md new file mode 100644 index 00000000000..c39b48ce7dc --- /dev/null +++ b/screen_help/readme/CONFIGURE.md @@ -0,0 +1,10 @@ +- Users of the *Help Editor* group (and administrators) can write the help: + *Settings > Technical > User Interface > Help Sections*, or the *Edit* + button of the help window. +- *Settings > General Settings > Help > Multilingual Help*: a section can be + written for a given language. A section without language is shown to + everybody. The help is not translated through the standard translation + mechanism. +- Dark mode: the help window follows the color scheme of the user + (`ir.http.color_scheme()`) when a module provides it, e.g. `web_dark_mode` + (OCA/web) or `web_enterprise`; no dependency on them. diff --git a/screen_help/readme/CONTRIBUTORS.md b/screen_help/readme/CONTRIBUTORS.md new file mode 100644 index 00000000000..86ad8efad31 --- /dev/null +++ b/screen_help/readme/CONTRIBUTORS.md @@ -0,0 +1,2 @@ +- [Akretion](https://www.akretion.com): + - David Beal diff --git a/screen_help/readme/DESCRIPTION.md b/screen_help/readme/DESCRIPTION.md new file mode 100644 index 00000000000..7f42dc65e87 --- /dev/null +++ b/screen_help/readme/DESCRIPTION.md @@ -0,0 +1,13 @@ +Contextual help on the screens of Odoo, written in Odoo or shipped by modules +as Markdown files. + +A book button in the control panel opens the help of the +current screen in a window detached from the Odoo tab, which can stay open +beside Odoo. + +The help of a screen is made of sections, ordered by sequence, which target: + +- a window action: shown on the screens of this action +- or a model: shown on every screen of this model, whatever the action + +**The module shipping the files doesn't need to depend on `screen_help`.** diff --git a/screen_help/readme/USAGE.md b/screen_help/readme/USAGE.md new file mode 100644 index 00000000000..b58ddb2c806 --- /dev/null +++ b/screen_help/readme/USAGE.md @@ -0,0 +1,46 @@ +**Read the help**: click the book button of the control panel. The button is +shown when the screen has some help, or to the help editors. + +**Write the help in Odoo**: *Edit* button of the help window, then add a +section targeting the action or the model of the screen. + +![Help window beside Odoo](../static/description/help1.png) + +**Ship the help in a module**: add Markdown files in its `help` directory: + +``` +my_module/help/sale.order.md # every screen of the model +my_module/help/sale.action_quotations.md # one window action (xml id) +my_module/help/sale.action_quotations.fr_FR.md # in French only +``` + +**You don't need to depends on screen_help** to provide a such files to document your module, + just add markdown like above + +An optional header orders the sections (sequence 10 by default). In the +application help, the screens are ordered by the lowest sequence of their +sections, then by the menu order: + +```markdown +--- +sequence: 20 +--- +## My help +``` + +When `screen_help` is installed, the `help` directory of a module is loaded at +each installation / update of the module (and for all the installed modules at +the installation of `screen_help`). Without `screen_help`, it is ignored. + +- a section loaded from a file can't be edited in Odoo +- a file removed from the module removes its section at the next update +- a file whose model or action doesn't exist (module not installed) is ignored +- the headings (`#`, `##`, `###`) make the table of contents of the help window + +![Section loaded from a file](../static/description/help3.png) + +**Demo**: on a database with demo data, a tour documents each feature on the +screen that shows it, each section pointing to the next screen + like contacts, [/odoo/action-base.action_partner_form](/odoo/action-base.action_partner_form) + +![Help window in dark mode](../static/description/help2.png) diff --git a/screen_help/security/groups.xml b/screen_help/security/groups.xml new file mode 100644 index 00000000000..32ce56c829a --- /dev/null +++ b/screen_help/security/groups.xml @@ -0,0 +1,15 @@ + + + + Help Editor + + + + + + + + + Multilingual Help + + diff --git a/screen_help/security/ir.model.access.csv b/screen_help/security/ir.model.access.csv new file mode 100644 index 00000000000..f62d75906c6 --- /dev/null +++ b/screen_help/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_help_section_user,help.section user,model_help_section,base.group_user,1,0,0,0 +access_help_section_editor,help.section editor,model_help_section,group_editor,1,1,1,1 diff --git a/screen_help/static/description/help1.png b/screen_help/static/description/help1.png new file mode 100644 index 00000000000..8a111cd6a12 Binary files /dev/null and b/screen_help/static/description/help1.png differ diff --git a/screen_help/static/description/help2.png b/screen_help/static/description/help2.png new file mode 100644 index 00000000000..0c0c2229902 Binary files /dev/null and b/screen_help/static/description/help2.png differ diff --git a/screen_help/static/description/help3.png b/screen_help/static/description/help3.png new file mode 100644 index 00000000000..3e8cddc88ef Binary files /dev/null and b/screen_help/static/description/help3.png differ diff --git a/screen_help/static/description/icon.png b/screen_help/static/description/icon.png new file mode 100644 index 00000000000..f6c998cbec6 Binary files /dev/null and b/screen_help/static/description/icon.png differ diff --git a/screen_help/static/description/index.html b/screen_help/static/description/index.html new file mode 100644 index 00000000000..a74e71d2a60 --- /dev/null +++ b/screen_help/static/description/index.html @@ -0,0 +1,513 @@ + + + + + +README.rst + + + +
+ + + +Odoo Community Association + +
+

Screen Help

+ +

Alpha License: AGPL-3 OCA/knowledge Translate me on Weblate Try me on Runboat

+

Contextual help on the screens of Odoo, written in Odoo or shipped by +modules as Markdown files.

+

A book button in the control panel opens the help of the current screen +in a window detached from the Odoo tab, which can stay open beside Odoo.

+

The help of a screen is made of sections, ordered by sequence, which +target:

+
    +
  • a window action: shown on the screens of this action
  • +
  • or a model: shown on every screen of this model, whatever the action
  • +
+

The module shipping the files doesn’t need to depend on +``screen_help``.

+
+

Important

+

This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

+
+

Table of contents

+ +
+

Configuration

+
    +
  • Users of the Help Editor group (and administrators) can write the +help: Settings > Technical > User Interface > Help Sections, or the +Edit button of the help window.
  • +
  • Settings > General Settings > Help > Multilingual Help: a section +can be written for a given language. A section without language is +shown to everybody. The help is not translated through the standard +translation mechanism.
  • +
  • Dark mode: the help window follows the color scheme of the user +(ir.http.color_scheme()) when a module provides it, e.g. +web_dark_mode (OCA/web) or web_enterprise; no dependency on +them.
  • +
+
+
+

Usage

+

Read the help: click the book button of the control panel. The +button is shown when the screen has some help, or to the help editors.

+

Write the help in Odoo: Edit button of the help window, then add a +section targeting the action or the model of the screen.

+

Help window beside Odoo

+

Ship the help in a module: add Markdown files in its help +directory:

+
+my_module/help/sale.order.md                     # every screen of the model
+my_module/help/sale.action_quotations.md         # one window action (xml id)
+my_module/help/sale.action_quotations.fr_FR.md   # in French only
+
+

You don’t need to depends on screen_help to provide a such files to +document your module, just add markdown like above

+

An optional header orders the sections (sequence 10 by default). In the +application help, the screens are ordered by the lowest sequence of +their sections, then by the menu order:

+
+---
+sequence: 20
+---
+## My help
+
+

When screen_help is installed, the help directory of a module is +loaded at each installation / update of the module (and for all the +installed modules at the installation of screen_help). Without +screen_help, it is ignored.

+
    +
  • a section loaded from a file can’t be edited in Odoo
  • +
  • a file removed from the module removes its section at the next update
  • +
  • a file whose model or action doesn’t exist (module not installed) is +ignored
  • +
  • the headings (#, ##, ###) make the table of contents of +the help window
  • +
+

Section loaded from a file

+

Demo: on a database with demo data, a tour documents each feature on +the screen that shows it, each section pointing to the next screen like +contacts, +/odoo/action-base.action_partner_form

+

Help window in dark mode

+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • Akretion
  • +
+
+
+

Contributors

+ +
+
+

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 maintainer:

+

bealdav

+

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

+

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

+
+
+
+
+ + diff --git a/screen_help/static/src/components/control_panel.esm.js b/screen_help/static/src/components/control_panel.esm.js new file mode 100644 index 00000000000..064c20cafee --- /dev/null +++ b/screen_help/static/src/components/control_panel.esm.js @@ -0,0 +1,47 @@ +import {onWillStart, useState} from "@odoo/owl"; +import {ControlPanel} from "@web/search/control_panel/control_panel"; +import {browser} from "@web/core/browser/browser"; +import {patch} from "@web/core/utils/patch"; +import {useService} from "@web/core/utils/hooks"; + +patch(ControlPanel.prototype, { + setup() { + super.setup(...arguments); + this.menuService = useService("menu"); + this.help = useState({content: false, canEdit: false}); + onWillStart(async () => { + if (this.env.isSmall || (!this.helpActionId && !this.helpModel)) { + return; + } + const help = await this.orm.call("help.section", "get_help", [ + this.helpActionId, + this.helpModel, + ]); + this.help.content = help.has_help; + this.help.canEdit = help.can_edit; + }); + }, + + get helpActionId() { + return this.env.config.actionId || false; + }, + + get helpModel() { + return this.env.searchModel?.resModel || false; + }, + + onClickHelp() { + const params = new URLSearchParams({ + action_id: this.helpActionId || "", + model: this.helpModel || "", + menu_id: this.menuService.getCurrentApp()?.id || "", + }); + // Same window name: the help window is reused + const win = browser.open( + `/screen_help?${params}`, + "screen_help", + "popup,width=900,height=750" + ); + win?.focus(); + }, +}); diff --git a/screen_help/static/src/components/control_panel.xml b/screen_help/static/src/components/control_panel.xml new file mode 100644 index 00000000000..f913799e31e --- /dev/null +++ b/screen_help/static/src/components/control_panel.xml @@ -0,0 +1,19 @@ + + + + + + + + diff --git a/screen_help/static/src/components/help_service.esm.js b/screen_help/static/src/components/help_service.esm.js new file mode 100644 index 00000000000..17a2f40bc55 --- /dev/null +++ b/screen_help/static/src/components/help_service.esm.js @@ -0,0 +1,31 @@ +import {_t} from "@web/core/l10n/translation"; +import {registry} from "@web/core/registry"; + +// Edit button of the detached help window: opens the sections in this tab +export const screenHelpService = { + dependencies: ["action"], + start(env, {action}) { + window.addEventListener("message", (ev) => { + if ( + ev.origin !== window.location.origin || + ev.data?.type !== "screen_help_edit" + ) { + return; + } + const {actionId, model} = ev.data; + action.doAction({ + type: "ir.actions.act_window", + name: _t("Help Sections"), + res_model: "help.section", + views: [ + [false, "list"], + [false, "form"], + ], + domain: ["|", ["action_id", "=", actionId], ["model", "=", model]], + context: {default_action_id: actionId}, + }); + }); + }, +}; + +registry.category("services").add("screen_help", screenHelpService); diff --git a/screen_help/tests/__init__.py b/screen_help/tests/__init__.py new file mode 100644 index 00000000000..1b0aab3d2cd --- /dev/null +++ b/screen_help/tests/__init__.py @@ -0,0 +1 @@ +from . import test_help diff --git a/screen_help/tests/test_help.py b/screen_help/tests/test_help.py new file mode 100644 index 00000000000..3cb62a68210 --- /dev/null +++ b/screen_help/tests/test_help.py @@ -0,0 +1,136 @@ +from unittest.mock import patch + +from odoo import Command as cmd +from odoo.exceptions import UserError +from odoo.tests import HttpCase, TransactionCase, tagged + + +@tagged("post_install", "-at_install") +class TestHelp(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.env["res.lang"]._activate_lang("fr_FR") + cls.Section = cls.env["help.section"] + cls.action = cls.env.ref("base.action_partner_form") + # demo data + cls.Section.search([]).unlink() + + def _content(self, lang): + return self.action.with_context(lang=lang)._help_html()[0] + + def test_markdown(self): + self.Section._load_markdown("screen_help", "demo_help") + sections = self.action.help_section_ids + self.assertEqual(len(sections), 2) + self.assertEqual(set(sections.mapped("module")), {"screen_help"}) + self.assertIn("", self._content("en_US")) + self.assertNotIn("SIRET", self._content("en_US")) + self.assertIn("SIRET", self._content("fr_FR")) + # a section of a file can't be edited in Odoo, only reordered + section = sections.filtered(lambda s: not s.lang) + self.assertEqual(section.file, "demo_help/base.action_partner_form.md") + with self.assertRaises(UserError): + section.content = "

Edited

" + section.sequence = 5 + # reloaded when the file changes + section.checksum = "changed" + self.Section._load_markdown("screen_help", "demo_help") + self.assertIn("
", section.content) + + def test_help_popup(self): + # columns left by help_popup in the database + self.env.cr.execute( + "ALTER TABLE ir_act_window ADD COLUMN IF NOT EXISTS enduser_help text, " + "ADD COLUMN IF NOT EXISTS advanced_help text" + ) + self.env.cr.execute( + "UPDATE ir_act_window SET enduser_help = '

For users

', " + "advanced_help = 'For developers' WHERE id = %s", + [self.action.id], + ) + sections = self.Section._import_help_popup() + self.assertEqual(sections.action_id, self.action) + self.assertIn("For users", sections[0].content) + self.assertIn("Advanced Help", sections[1].content) + self.assertIn("For developers", sections[1].content) + # not imported twice + self.assertFalse(self.Section._import_help_popup()) + + def test_front_matter(self): + header, text = self.Section._front_matter("---\nsequence: 20\n---\n## Title\n") + self.assertEqual(header, {"sequence": "20"}) + self.assertEqual(text, "## Title\n") + self.assertEqual(self.Section._front_matter("## Title\n"), ({}, "## Title\n")) + + def test_menu(self): + menu = self.env["ir.ui.menu"].create( + { + "name": "Help Root", + "child_id": [ + cmd.create( + { + "name": "Partners", + "action": f"{self.action._name},{self.action.id}", + } + ), + ], + } + ) + self.assertFalse(menu._help_actions()) + self.Section.create({"action_id": self.action.id, "content": "

Hi

"}) + self.assertEqual(menu._help_actions(), self.action) + html = self.env["ir.ui.view"]._render_template( + "screen_help.app_help_page", {"menu": menu} + ) + self.assertIn("Hi", html) + # sidebar: the action, then its headings + self.assertEqual( + menu._help_html()[1], [(1, f"help_{self.action.id}", self.action.name)] + ) + # a section of the model is shown on its actions, and alone + model = self.env["ir.model"]._get("res.partner") + self.Section.create({"model_id": model.id, "content": "

Model

"}) + self.assertIn("Model", self.action._help_html()[0]) + self.assertTrue(self.Section.get_help(False, "res.partner")["has_help"]) + # application help: the model help is shown once, whatever its actions + action2 = self.action.copy() + menu.child_id[0].copy({"action": f"{action2._name},{action2.id}"}) + helps = menu._help_html()[0] + self.assertEqual([a for a, __ in helps], [self.action]) + + +@tagged("post_install", "-at_install") +class TestPopup(HttpCase): + def test_window(self): + action = self.env.ref("base.action_partner_form") + self.env["help.section"].search([]).unlink() # demo data + self.env["help.section"].create( + {"action_id": action.id, "content": "

Usage

Hi

"} + ) + menu = self.env.ref("base.menu_administration") + self.authenticate("admin", "admin") + res = self.url_open(f"/screen_help?action_id={action.id}&menu_id={menu.id}") + self.assertEqual(res.status_code, 200) + self.assertIn("

Hi

", res.text) + # table of contents + self.assertIn(f'

Usage

', res.text) + self.assertIn(f'href="#help_{action.id}_0"', res.text) + self.assertIn(f"/screen_help/app/{menu.id}", res.text) + self.assertEqual(res.text.count(""), 1) + self.assertIn("data:image/", res.text) # company logo + self.assertIn("screen_help_edit", res.text) # edit button (admin) + # screen without action: help of the model only + model = self.env["ir.model"]._get("res.partner") + self.env["help.section"].create( + {"model_id": model.id, "content": "

Model

"} + ) + res = self.url_open("/screen_help?model=res.partner") + self.assertIn("

Model

", res.text) + self.assertNotIn("

Hi

", res.text) + self.assertNotIn("#1f2228", res.text) + # dark color scheme (web_enterprise: preference of the user) + IrHttp = type(self.env["ir.http"]) + with patch.object(IrHttp, "color_scheme", lambda self: "dark", create=True): + res = self.url_open(f"/screen_help/app/{menu.id}") + self.assertIn("#1f2228", res.text) diff --git a/screen_help/views/help_page.xml b/screen_help/views/help_page.xml new file mode 100644 index 00000000000..5121592b1fd --- /dev/null +++ b/screen_help/views/help_page.xml @@ -0,0 +1,183 @@ + + + + + + + + + + + diff --git a/screen_help/views/help_section.xml b/screen_help/views/help_section.xml new file mode 100644 index 00000000000..3e1ee87f11e --- /dev/null +++ b/screen_help/views/help_section.xml @@ -0,0 +1,101 @@ + + + help.section + +
+ +
+ This help comes from the file + + + of the module + + : + edit the file, it is reloaded at the next update of the module. +
+ + + + + + + + + + + + + + + +
+
+ + + help.section + + + + + + + + + + + + + help.section + + + + + + + + + + + + + + + + Help Sections + help.section + list,form + + + +
diff --git a/screen_help/views/res_config_settings.xml b/screen_help/views/res_config_settings.xml new file mode 100644 index 00000000000..1d3a042d712 --- /dev/null +++ b/screen_help/views/res_config_settings.xml @@ -0,0 +1,19 @@ + + + + res.config.settings + + + + + + + + + + + +