diff --git a/project_task_code_portal/README.rst b/project_task_code_portal/README.rst new file mode 100644 index 0000000000..621ef8e08a --- /dev/null +++ b/project_task_code_portal/README.rst @@ -0,0 +1,133 @@ +======================== +Project Task Code Portal +======================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:XXXXXXXXXXXXXXXXXXXXXXXX + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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-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%2Fproject-lightgray.png?logo=github + :target: https://github.com/OCA/project/tree/16.0/project_task_code_portal + :alt: OCA/project +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/project-16-0/project-16-0-project_task_code_portal + :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/project&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module implements task codes in the portal. It allows users to: + +- Use task codes instead of IDs in portal URLs. +- Search for tasks by their unique code. +- Display task codes in portal task views. + +**Table of contents** + +.. contents:: + :local: + +Use Cases / Context +=================== + +Business Need +------------- + +Task codes provide great flexibility for backend users. However portal +users still have to deal with task id's instead of task codes, which can +be misleading and create potential issues. + +Approach +-------- + +This module extends the standard project portal by allowing: + +- Searching for tasks by their unique code. +- Displaying the task code in both list and detail views. +- Generating reports that include the task code. + +Use Cases +--------- + +- Clients can directly access a task via a URL containing the task code. +- Support teams can quickly locate a task using its unique identifier. + +Configuration +============= + +No configuration is required. + +Usage +===== + +This module will replace the "ID" field with the "Code" in the following +portal views: + +- Task list (including the project task list) +- Task page +- Task search + +It will modify the portal URLs as follows: + +- **Before:** ``https://example.com/my/tasks/`` +- **After:** ``https://example.com/my/tasks/`` + +Changelog +========= + + + +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 +------- + +* Cetmix OÜ + +Contributors +------------ + +- `Cetmix `__: + + - Ivan Sokolov + - Anatol Mikheev + +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. + +This module is part of the `OCA/project `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/project_task_code_portal/__init__.py b/project_task_code_portal/__init__.py new file mode 100644 index 0000000000..5c2bd8c997 --- /dev/null +++ b/project_task_code_portal/__init__.py @@ -0,0 +1,4 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import controllers +from . import models diff --git a/project_task_code_portal/__manifest__.py b/project_task_code_portal/__manifest__.py new file mode 100644 index 0000000000..ffa0b5f10c --- /dev/null +++ b/project_task_code_portal/__manifest__.py @@ -0,0 +1,18 @@ +# Copyright 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). +{ + "name": "Project Task Code Portal", + "summary": "Use custom task code in customer portal", + "version": "16.0.1.0.0", + "development_status": "Beta", + "category": "Project", + "website": "https://github.com/OCA/project", + "author": "Cetmix OÜ, Odoo Community Association (OCA)", + "license": "AGPL-3", + "depends": [ + "project_task_code", + ], + "data": [ + "templates/portal_templates.xml", + ], +} diff --git a/project_task_code_portal/controllers/__init__.py b/project_task_code_portal/controllers/__init__.py new file mode 100644 index 0000000000..868318cd66 --- /dev/null +++ b/project_task_code_portal/controllers/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import portal diff --git a/project_task_code_portal/controllers/portal.py b/project_task_code_portal/controllers/portal.py new file mode 100644 index 0000000000..ff5bde4c05 --- /dev/null +++ b/project_task_code_portal/controllers/portal.py @@ -0,0 +1,91 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import _, http +from odoo.exceptions import AccessError, MissingError +from odoo.http import request + +from odoo.addons.project.controllers.portal import ProjectCustomerPortal + + +class PortalProjectTask(ProjectCustomerPortal): + def _task_get_searchbar_inputs(self, milestones_allowed): + inputs = super()._task_get_searchbar_inputs(milestones_allowed) + if "ref" in inputs and "label" in inputs["ref"]: + inputs["ref"]["label"] = _("Search in Task code") + return inputs + + def _task_get_search_domain(self, search_in, search): + domain = super()._task_get_search_domain(search_in, search) + if search_in in ("ref", "all"): + for i, item in enumerate(domain): + if isinstance(item, tuple) and item[0] == "id": + domain[i] = ("code", item[1], item[2]) + break + return domain + + def get_accessible_task_by_code(self, task_code, access_token): + task_id = ( + request.env["project.task"] + .sudo() + .search([("code", "=", task_code)], limit=1) + .id + ) + if not task_id: + raise MissingError(_("No task with this code.")) + task_sudo = self._document_check_access("project.task", task_id, access_token) + return task_sudo + + @http.route( + ["/my/tasks/"], type="http", auth="public", website=True + ) + def portal_my_task( + self, + task_code, + report_type=None, + access_token=None, + project_sharing=False, + **kw + ): + try: + task_sudo = self.get_accessible_task_by_code(task_code, access_token) + except (AccessError, MissingError): + return request.redirect("/my") + + if report_type in ("pdf", "html", "text"): + return self._show_task_report( + task_sudo, report_type, download=kw.get("download") + ) + + # ensure attachment are accessible with access token inside template + task_sudo.attachment_ids.generate_access_token() + if project_sharing is True: + # Then the user arrives to the stat button shown in form view of project.task + # and the portal user can see only 1 task + # so the history should be reset. + request.session["my_tasks_history"] = task_sudo.ids + values = self._task_get_page_view_values(task_sudo, access_token, **kw) + return request.render("project.portal_my_task", values) + + @http.route( + "/my/projects//task/", + type="http", + auth="public", + website=True, + ) + def portal_my_project_task( + self, project_id=None, task_code=None, access_token=None, **kw + ): + try: + project_sudo = self._document_check_access( + "project.project", project_id, access_token + ) + task_sudo = self.get_accessible_task_by_code(task_code, access_token) + except (AccessError, MissingError): + return request.redirect("/my") + task_sudo.attachment_ids.generate_access_token() + values = self._task_get_page_view_values( + task_sudo, access_token, project=project_sudo, **kw + ) + values["project"] = project_sudo + return request.render("project.portal_my_task", values) diff --git a/project_task_code_portal/models/__init__.py b/project_task_code_portal/models/__init__.py new file mode 100644 index 0000000000..4e791c80b1 --- /dev/null +++ b/project_task_code_portal/models/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +from . import project_task diff --git a/project_task_code_portal/models/project_task.py b/project_task_code_portal/models/project_task.py new file mode 100644 index 0000000000..b5162bf3ed --- /dev/null +++ b/project_task_code_portal/models/project_task.py @@ -0,0 +1,12 @@ +# Copyright (C) 2025 Cetmix OÜ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from odoo import models + + +class ProjectTask(models.Model): + _inherit = "project.task" + + @property + def SELF_READABLE_FIELDS(self): + return super().SELF_READABLE_FIELDS | {"code"} diff --git a/project_task_code_portal/readme/CONFIGURE.md b/project_task_code_portal/readme/CONFIGURE.md new file mode 100644 index 0000000000..718c4a6561 --- /dev/null +++ b/project_task_code_portal/readme/CONFIGURE.md @@ -0,0 +1 @@ +No configuration is required. diff --git a/project_task_code_portal/readme/CONTEXT.md b/project_task_code_portal/readme/CONTEXT.md new file mode 100644 index 0000000000..69b2d140f7 --- /dev/null +++ b/project_task_code_portal/readme/CONTEXT.md @@ -0,0 +1,12 @@ +## Business Need +Task codes provide great flexibility for backend users. However portal users still have to deal with task id's instead of task codes, which can be misleading and create potential issues. + +## Approach +This module extends the standard project portal by allowing: +- Searching for tasks by their unique code. +- Displaying the task code in both list and detail views. +- Generating reports that include the task code. + +## Use Cases +- Clients can directly access a task via a URL containing the task code. +- Support teams can quickly locate a task using its unique identifier. diff --git a/project_task_code_portal/readme/CONTRIBUTORS.md b/project_task_code_portal/readme/CONTRIBUTORS.md new file mode 100644 index 0000000000..af6b36315c --- /dev/null +++ b/project_task_code_portal/readme/CONTRIBUTORS.md @@ -0,0 +1,5 @@ +* [Cetmix](https://cetmix.com/): + + * Ivan Sokolov + * Anatol Mikheev + diff --git a/project_task_code_portal/readme/DESCRIPTION.md b/project_task_code_portal/readme/DESCRIPTION.md new file mode 100644 index 0000000000..61a2c2b3e4 --- /dev/null +++ b/project_task_code_portal/readme/DESCRIPTION.md @@ -0,0 +1,5 @@ +This module implements task codes in the portal. It allows users to: + +- Use task codes instead of IDs in portal URLs. +- Search for tasks by their unique code. +- Display task codes in portal task views. diff --git a/project_task_code_portal/readme/HISTORY.md b/project_task_code_portal/readme/HISTORY.md new file mode 100644 index 0000000000..8b13789179 --- /dev/null +++ b/project_task_code_portal/readme/HISTORY.md @@ -0,0 +1 @@ + diff --git a/project_task_code_portal/readme/USAGE.md b/project_task_code_portal/readme/USAGE.md new file mode 100644 index 0000000000..0dce3c492f --- /dev/null +++ b/project_task_code_portal/readme/USAGE.md @@ -0,0 +1,11 @@ +This module will replace the "ID" field with the "Code" in the following portal views: + +- Task list (including the project task list) +- Task page +- Task search + +It will modify the portal URLs as follows: + +- **Before:** `https://example.com/my/tasks/` +- **After:** `https://example.com/my/tasks/` + diff --git a/project_task_code_portal/readme/newsfragments/.gitkeep b/project_task_code_portal/readme/newsfragments/.gitkeep new file mode 100644 index 0000000000..e69de29bb2 diff --git a/project_task_code_portal/static/description/index.html b/project_task_code_portal/static/description/index.html new file mode 100644 index 0000000000..ebeb6c5831 --- /dev/null +++ b/project_task_code_portal/static/description/index.html @@ -0,0 +1,66 @@ + + + + + Project Task Code Portal + + + +

Project Task Code Portal

+

+ This module implements task codes in the portal by replacing internal task IDs with human-readable codes. + It enhances the customer portal by providing clear task references and improving overall usability. +

+ +

Key Features

+
    +
  • Use task codes instead of IDs in portal URLs
  • +
  • Search for tasks by their unique code in portal filters
  • +
  • Display task codes in both task list and detailed views
  • +
  • Maintains backward compatibility with existing ID-based routes
  • +
  • Includes comprehensive documentation for configuration and usage
  • +
+ +

Usage

+

+ Once installed and configured, the module modifies portal URLs as follows: +

+
    +
  • Before: https://example.com/my/tasks/<task_id>
  • +
  • After: https://example.com/my/tasks/<task_code>
  • +
+

+ Portal users can now easily share and reference tasks using meaningful codes rather than numerical IDs. +

+ +

Installation & Configuration

+

+ To install the module, copy it into your Odoo addons directory, ensure that all dependencies are met (e.g., project_task_code and portal), and restart the Odoo server. + Detailed configuration instructions are provided in the module documentation. +

+ +

Context

+

+ This module addresses the need for client-friendly task references in the portal. It was developed to overcome the limitations of using non-intuitive task IDs by introducing unique, human-readable task codes. + This results in improved communication, easier task tracking, and enhanced navigation for end users. +

+ + diff --git a/project_task_code_portal/templates/portal_templates.xml b/project_task_code_portal/templates/portal_templates.xml new file mode 100644 index 0000000000..8e839a4ceb --- /dev/null +++ b/project_task_code_portal/templates/portal_templates.xml @@ -0,0 +1,27 @@ + + + + + + + diff --git a/project_task_code_portal/tests/__init__.py b/project_task_code_portal/tests/__init__.py new file mode 100644 index 0000000000..73f9db30ec --- /dev/null +++ b/project_task_code_portal/tests/__init__.py @@ -0,0 +1,3 @@ +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl). + +from . import test_portal diff --git a/project_task_code_portal/tests/test_portal.py b/project_task_code_portal/tests/test_portal.py new file mode 100644 index 0000000000..85a1766675 --- /dev/null +++ b/project_task_code_portal/tests/test_portal.py @@ -0,0 +1,279 @@ +# Copyright 2025 Cetmix OÜ +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). + +import re + +from lxml import html + +from odoo import Command, tools +from odoo.tests import tagged + +from odoo.addons.base.tests.common import HttpCaseWithUserPortal +from odoo.addons.project.tests.test_access_rights import TestProjectPortalCommon + + +@tagged("-at_install", "post_install") +class TestPortalTaskCode(TestProjectPortalCommon, HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super(TestPortalTaskCode, cls).setUpClass() + cls.task_1.project_id.privacy_visibility = "portal" + task_wizard = cls.env["portal.share"].create( + { + "res_model": "project.task", + "res_id": cls.task_1.id, + "partner_ids": [ + Command.link(cls.partner_portal.id), + ], + } + ) + task_wizard.action_send_mail() + + cls.host = "127.0.0.1" + cls.port = tools.config["http_port"] + cls.base_url = "http://%s:%d/my/tasks/" % (cls.host, cls.port) + cls.url_task_code_pattern = "/my/tasks/{}?" + + def test_portal_tasks_list_access(self): + self.authenticate("portal", "portal") + response = self.url_open(self.base_url) + content = response.content + tree = html.fromstring(content) + spans = tree.xpath( + "//td[contains(@class, 'text-start') and contains(., '#')]//span" + ) + list_tasks_code = [s.text for s in spans] + self.assertIn(self.task_1.code, list_tasks_code) + link = tree.xpath(f"//td[a/span[contains(text(), '{self.task_1.name}')]]//a")[ + 0 + ].attrib["href"] + self.assertEqual(link, self.url_task_code_pattern.format(self.task_1.code)) + + def test_portal_task_access(self): + self.authenticate("portal", "portal") + response = self.url_open(self.base_url + self.task_1.code) + content = response.content + tree = html.fromstring(content) + spans = tree.xpath( + "//small[contains(@class, 'text-muted') and contains(@class, 'd-md-inline')]//span" + ) + list_tasks_code = [s.text for s in spans] + self.assertIn(self.task_1.code, list_tasks_code) + + def test_portal_task_not_found(self): + self.authenticate("portal", "portal") + response = self.url_open(self.base_url + "NoCode") + home_url = "http://%s:%d/my" % (self.host, self.port) + self.assertEqual(response.url, home_url) + + def test_portal_task_search_link_format(self): + self.authenticate("portal", "portal") + task_code = self.task_1.code + query_params = f"?search_in=ref&search={task_code}" + response = self.url_open(self.base_url[:-1] + query_params) + content = response.content + tree = html.fromstring(content) + spans = tree.xpath( + "//td[contains(@class, 'text-start') and contains(., '#')]//span" + ) + list_tasks_code = [s.text for s in spans] + self.assertIn(task_code, list_tasks_code) + link = tree.xpath(f"//td[a/span[contains(text(), '{self.task_1.name}')]]//a")[ + 0 + ].attrib["href"] + self.assertEqual( + link, + self.url_task_code_pattern.format(self.task_1.code)[:-1] + query_params, + ) + + def test_portal_task_report(self): + """Test task report generation through portal.""" + self.authenticate("portal", "portal") + # Check if hr_timesheet module is installed + hr_timesheet_installed = bool( + self.env["ir.module.module"].search( + [("name", "=", "hr_timesheet"), ("state", "=", "installed")] + ) + ) + response = self.url_open(self.base_url + self.task_1.code + "?report_type=html") + if hr_timesheet_installed: + # If hr_timesheet is installed, expect successful response + # _show_task_report is overridden by hr_timesheet to generate timesheet reports + self.assertEqual(response.status_code, 200) + self.assertIn("text/html", response.headers.get("Content-Type", "")) + else: + # If hr_timesheet is not installed, expect error response + # _show_task_report raises MissingError("There is nothing to report.") + # This method is to be overriden to report timesheets if the module is installed + self.assertEqual(response.status_code, 400) + content = response.content + tree = html.fromstring(content) + error_elements = tree.xpath( + "//pre[contains(text(), 'There is nothing to report.')]" + ) + self.assertTrue( + error_elements, + "Error message 'There is nothing to report.' not found in response", + ) + + def test_portal_task_project_sharing(self): + """Test project sharing functionality.""" + self.authenticate("portal", "portal") + + # First, access multiple tasks to build up history + other_task = self.env["project.task"].create( + { + "name": "Other Task", + "project_id": self.task_1.project_id.id, + } + ) + self.url_open(f"{self.base_url}{other_task.code}") + + # Share the project with portal user + project_share_wizard = self.env["project.share.wizard"].create( + { + "access_mode": "edit", + "res_model": "project.project", + "res_id": self.task_1.project_id.id, + "partner_ids": [Command.link(self.partner_portal.id)], + } + ) + project_share_wizard.action_send_mail() + + # Get the sharing link from the most recent mail message + message = self.env["mail.message"].search( + [ + ("partner_ids", "in", self.partner_portal.id), + ("model", "=", "project.project"), + ("res_id", "=", self.task_1.project_id.id), + ], + order="id DESC", + limit=1, + ) + + share_link = str(message.body.split('href="')[1].split('">')[0]) + match = re.search( + r"access_token=([^&]+)&pid=([^&]+)&hash=([^&]*)", share_link + ) + access_token, pid, _hash = match.groups() + + # Access the task with project sharing context + url = f"{self.base_url}{self.task_1.code}" + + # Get initial response to extract CSRF token + initial_response = self.url_open(url) + content = initial_response.text + csrf_token = re.search(r'csrf_token: "([^"]+)"', content).group(1) + + # Make the POST request with CSRF token and project_sharing=True + response = self.url_open( + url=url, + data={ + "csrf_token": csrf_token, + "access_token": access_token, + "project_sharing": True, + "pid": pid, + "hash": _hash, + }, + ) + + self.assertEqual(response.status_code, 200) + + # Now check if the navigation links for previous/next task are not present + # This would indicate that the history was reset to only contain the current task + content = response.content + tree = html.fromstring(content) + + # Check for absence of navigation links (prev/next) + # which would be present if history had multiple tasks + prev_links = tree.xpath("//a[contains(@class, 'o_portal_pager_previous')]") + next_links = tree.xpath("//a[contains(@class, 'o_portal_pager_next')]") + + # If history was reset to only current task, there should be no prev/next links + self.assertFalse( + prev_links, "Previous task link should not be present if history was reset" + ) + self.assertFalse( + next_links, "Next task link should not be present if history was reset" + ) + + +@tagged("-at_install", "post_install") +class TestPortalProjectTaskCode(TestProjectPortalCommon, HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super(TestPortalProjectTaskCode, cls).setUpClass() + cls.project_pigs.privacy_visibility = "portal" + task_wizard = cls.env["portal.share"].create( + { + "res_model": "project.project", + "res_id": cls.project_pigs.id, + "partner_ids": [ + Command.link(cls.partner_portal.id), + ], + } + ) + task_wizard.action_send_mail() + + cls.host = "127.0.0.1" + cls.port = tools.config["http_port"] + cls.base_my_url = f"http://{cls.host}:{cls.port}/my" + cls.base_projects_url = f"{cls.base_my_url}/projects" + + def test_portal_project_tasks_list_access(self): + self.authenticate("portal", "portal") + project_id = self.task_1.project_id.id + url = f"{self.base_projects_url}/{project_id}" + response = self.url_open(url) + content = response.content + tree = html.fromstring(content) + spans = tree.xpath( + "//td[contains(@class, 'text-start') and contains(., '#')]//span" + ) + list_tasks_code = [s.text for s in spans] + self.assertIn(self.task_1.code, list_tasks_code) + link = tree.xpath(f"//td[a/span[contains(text(), '{self.task_1.name}')]]//a")[ + 0 + ].attrib["href"] + expected_link = f"/my/projects/{project_id}/task/{self.task_1.code}?" + self.assertEqual(link, expected_link) + + def test_portal_my_project_task_ok(self): + self.authenticate("portal", "portal") + project_id = self.task_1.project_id.id + task_code = self.task_1.code + url = f"{self.base_projects_url}/{project_id}/task/{task_code}" + response = self.url_open(url) + content = response.content + tree = html.fromstring(content) + spans = tree.xpath( + "//small[contains(@class, 'text-muted') and contains(@class, 'd-md-inline')]//span" + ) + list_tasks_code = [s.text for s in spans] + self.assertIn(self.task_1.code, list_tasks_code) + + def test_portal_my_project_task_not_found(self): + self.authenticate("portal", "portal") + project_id = self.task_1.project_id.id + url = f"{self.base_projects_url}/{project_id}/task/NotExistentCode" + response = self.url_open(url) + self.assertEqual(response.url, self.base_my_url) + + def test_portal_my_project_task_no_access(self): + other_project = self.env["project.project"].create( + { + "name": "Closed project", + "privacy_visibility": "followers", + } + ) + task = self.env["project.task"].create( + { + "name": "Hidden task", + "project_id": other_project.id, + "code": "HIDDEN-CODE", + } + ) + self.authenticate("portal", "portal") + url = f"{self.base_projects_url}/{other_project.id}/task/{task.code}" + response = self.url_open(url) + self.assertEqual(response.url, self.base_my_url) diff --git a/setup/project_task_code_portal/odoo/addons/project_task_code_portal b/setup/project_task_code_portal/odoo/addons/project_task_code_portal new file mode 120000 index 0000000000..96ccf5b01f --- /dev/null +++ b/setup/project_task_code_portal/odoo/addons/project_task_code_portal @@ -0,0 +1 @@ +../../../../project_task_code_portal \ No newline at end of file diff --git a/setup/project_task_code_portal/setup.py b/setup/project_task_code_portal/setup.py new file mode 100644 index 0000000000..28c57bb640 --- /dev/null +++ b/setup/project_task_code_portal/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +)