diff --git a/project_task_create_portal/README.rst b/project_task_create_portal/README.rst new file mode 100644 index 0000000000..0a2658e1e4 --- /dev/null +++ b/project_task_create_portal/README.rst @@ -0,0 +1,124 @@ +================================== +Project Portal Own Task Management +================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:f6fee54148af1a81d39ccb70dc0f942f5a66150694fcebf82c733be3177601b0 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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_create_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_create_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 extends the Project application by enabling portal users to create new tasks and edit their own tasks directly from the Odoo portal. + + +## Features + +- New field on project: **Portal Task Creation Stage** +- Portal users can create tasks in the pre-defined stages for allowed projects +- Extended portal UI: + - **“Create Task”** button visible for allowed project + - Simplified task creation form (Name, Description, Deadline) + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +## To configure the project for task creation from the portal + +- Go to the Project App +- On a project kanban card, press the Settings button +- Go to the "Settings" tab + +- Set the **Portal Task Creation Stage** to a stage (from the project’s stages) from which portal users will be allowed to add tasks. +- Set the **Hide assigned user** to hide the assigned user of the task on the portal. + +Usage +===== + +## To create a task from the portal + + +- Go to the portal, navigate to Projects +- Select an available project +- Press the “New Task” button +- In the form view, fill in: + - Name (required) + - Description (required) + - Deadline (optional) +- Press the Save button + +## To edit a task from the portal + +Note: the user can edit only their own tasks created + +- Go to the portal, navigate to Projects +- Select an available project +- Select a task in the stage permitted for task creation and task editing +- Press the "Edit" button +- In the form view, edit the: + - Name + - Description + - Deadline +- Press the Save button + + +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 +======= + +Contributors +~~~~~~~~~~~~ + +- [XXP](https://xxp-odoo.com): + - Maksim Shurupov + - Mike Lapin + +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_create_portal/__init__.py b/project_task_create_portal/__init__.py new file mode 100644 index 0000000000..91c5580fed --- /dev/null +++ b/project_task_create_portal/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/project_task_create_portal/__manifest__.py b/project_task_create_portal/__manifest__.py new file mode 100644 index 0000000000..19fc84780c --- /dev/null +++ b/project_task_create_portal/__manifest__.py @@ -0,0 +1,33 @@ +{ + "name": "Project Portal Own Task Management", + "version": "16.0.1.0.0", + "summary": "Allow portal users to create and edit their own tasks" + "from the portal only in a project's pre-configured task stage.", + "author": "Odoo Community Association (OCA)", + "website": "https://github.com/OCA/project", + "license": "AGPL-3", + "category": "Project", + "depends": [ + "web_editor", + "project", + "portal", + ], + "data": [ + "security/ir.model.access.csv", + "security/project_task_security.xml", + "views/portal_template.xml", + "views/project_project_views.xml", + ], + "demo": [ + "demo/project_task_create_portal_demo.xml", + ], + "assets": { + "web.assets_frontend": [ + "project_task_create_portal/static/src/js/portal.js", + ], + "web_editor.assets_wysiwyg": { + "project_task_create_portal/static/src/xml/portal_wysiwyg.xml", + }, + }, + "installable": True, +} diff --git a/project_task_create_portal/controllers/__init__.py b/project_task_create_portal/controllers/__init__.py new file mode 100644 index 0000000000..8c3feb6f56 --- /dev/null +++ b/project_task_create_portal/controllers/__init__.py @@ -0,0 +1 @@ +from . import portal diff --git a/project_task_create_portal/controllers/portal.py b/project_task_create_portal/controllers/portal.py new file mode 100644 index 0000000000..935c169e16 --- /dev/null +++ b/project_task_create_portal/controllers/portal.py @@ -0,0 +1,199 @@ +from odoo import _, http +from odoo.exceptions import AccessError, MissingError +from odoo.fields import Date +from odoo.http import request + +from odoo.addons.project.controllers.portal import ProjectCustomerPortal + + +class ProjectCustomerNewPortal(ProjectCustomerPortal): + @property + def mandatory_task_fields(self) -> list: + """Mandatory task fields for portal user""" + return ["name", "description"] + + @property + def optional_task_fields(self) -> list: + """Optional task fields for portal user""" + return ["date_deadline"] + + def _validate_task_fields(self, data, task_creation=False) -> tuple: + """Validate task values submitted from the portal form.""" + error, error_message = dict(), [] + + # Validation + for field_name in self.mandatory_task_fields: + if not data.get(field_name): + error[field_name] = "missing" + + # Deadline validation + date_deadline = data.get("date_deadline") + if date_deadline: + date = Date.to_date(data.get("date_deadline")) + if date and date < Date.today(): + error["date_deadline"] = "invalid" + error_message.append("Deadline is in the past") + + return error, error_message + + def _prepare_task_values(self, data) -> dict: + """ + Prepare task values + :param dict data: post values + :return: prepared task values + """ + values = {key: data[key] for key in self.mandatory_task_fields} + values.update( + { + key: data[key] + for key in self.optional_task_fields + if key in data and data.get(key) + } + ) + return values + + def _task_action_page_view_values(self, project) -> dict: + """ + Prepare values for task action page view + :param project: project.project + :return: dict + """ + values = self._prepare_portal_layout_values() + values.update( + { + "project": project, + "error": {}, + "error_message": [], + } + ) + return values + + @http.route( + ["/my/projects//task/new"], + type="http", + auth="user", + website=True, + ) + def portal_project_create_task(self, project_id=None, **post) -> http.Response: + """ + Create a task in a project + :param int project_id: project + :param dict post: post values + """ + project = request.env["project.project"].sudo().browse(project_id).exists() + if not project: + raise MissingError(_("Project not found!")) + if not project.is_portal_task_creation_allowed(): + raise AccessError(_("You are not allowed to create tasks in this project.")) + values = self._task_action_page_view_values(project) + if post and request.httprequest.method == "POST": + error, error_message = self._validate_task_fields(post) + if not error: + values = self._prepare_task_values(post) + values["project_id"] = project.id + task = request.env["project.task"].create(values) + return request.redirect(f"/my/projects/{project_id}/task/{task.id}") + values.update({"error": error, "error_message": error_message, **post}) + values.update({"page_name": "task_creation", "button": _("Create")}) + return request.render( + "project_task_create_portal.portal_project_task_new", values + ) + + @http.route( + ["/my/projects//task//edit"], + type="http", + auth="user", + website=True, + ) + def portal_project_edit_task(self, project_id=None, task_id=None, **post): + """ + Edit a task in a project + :param int project_id: project.project record id + :param int task_id: project.task record id + :param dict post: post values + """ + task = request.env["project.task"].search( + [("id", "=", task_id), ("project_id", "=", project_id)] + ) + if not task: + raise MissingError(_("Task not found!")) + if not task.check_portal_edit_access(): + raise AccessError(_("You are not allowed to edit this task.")) + values = self._task_action_page_view_values(task.project_id) + values.update( + { + "task": task, + "name": task.name, + "description": task.description, + "date_deadline": task.date_deadline, + "button": _("Save"), + } + ) + if post and request.httprequest.method == "POST": + error, error_message = self._validate_task_fields(post) + if not error: + task.write(self._prepare_task_values(post)) + return request.redirect(f"/my/projects/{project_id}/task/{task_id}") + values.update({"error": error, "error_message": error_message, **post}) + values.update({"page_name": "task_edit"}) + return request.render( + "project_task_create_portal.portal_project_task_new", values + ) + + def _project_get_page_view_values( + self, + project, + access_token, + page=1, + date_begin=None, + date_end=None, + sortby=None, + search=None, + search_in="content", + groupby=None, + **kwargs, + ): + """Add portal task creation availability to project page values.""" + values = super()._project_get_page_view_values( + project, + access_token, + page, + date_begin, + date_end, + sortby, + search, + search_in, + groupby, + **kwargs, + ) + # Access to visible create task button + values[ + "searchbar_create_task" + ] = project.sudo().is_portal_task_creation_allowed() + return values + + def _task_get_searchbar_groupby(self, milestones_allowed): + """Add task creator to the available portal grouping options.""" + values = super()._task_get_searchbar_groupby(milestones_allowed) + values.update( + create_uid={"input": "create_uid", "label": _("Created by"), "order": 12} + ) + return dict(sorted(values.items(), key=lambda item: item[1]["order"])) + + def _task_get_searchbar_sortings(self, milestones_allowed): + """Add task creator to the available portal sorting options.""" + values = super()._task_get_searchbar_sortings(milestones_allowed) + values.update( + create_uid={ + "label": _("Created by"), + "order": "create_uid desc", + "sequence": 12, + } + ) + return values + + def _task_get_groupby_mapping(self): + """Map the portal creator grouping option to ``create_uid``.""" + result = super()._task_get_groupby_mapping() + result.update(create_uid="create_uid") + return result diff --git a/project_task_create_portal/demo/project_task_create_portal_demo.xml b/project_task_create_portal/demo/project_task_create_portal_demo.xml new file mode 100644 index 0000000000..d99f5d30d8 --- /dev/null +++ b/project_task_create_portal/demo/project_task_create_portal_demo.xml @@ -0,0 +1,58 @@ + + + + Portal Inbox + 1 + + + + In Progress + 2 + + + + Portal Task Management Demo + portal + + + + + + + + Portal Editable Demo Task + This task can be edited from the portal. + + + + + + + + + Portal Read-only Demo Task + This task remains visible but cannot be edited from the portal. + + + + + + + diff --git a/project_task_create_portal/models/__init__.py b/project_task_create_portal/models/__init__.py new file mode 100644 index 0000000000..2128188969 --- /dev/null +++ b/project_task_create_portal/models/__init__.py @@ -0,0 +1,2 @@ +from . import project_project +from . import project_task diff --git a/project_task_create_portal/models/project_project.py b/project_task_create_portal/models/project_project.py new file mode 100644 index 0000000000..18f6c937ec --- /dev/null +++ b/project_task_create_portal/models/project_project.py @@ -0,0 +1,44 @@ +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ProjectProject(models.Model): + _inherit = "project.project" + + portal_stage_id = fields.Many2one( + "project.task.type", + help="Stage from which portal users will be allowed to create and edit tasks.", + domain="[('project_ids', 'in', [id])]", + ) + portal_user_ids = fields.Many2many( + "res.users", + relation="portal_project_allowed_user_rel", + column1="project_id", + column2="user_id", + domain=lambda self: [ + ("groups_id", "in", self.env.ref("base.group_portal").ids) + ], + ) + portal_hide_assigned_users = fields.Boolean( + string="Hide Assigned User", + help="If enabled, the portal assigned users will not be displayed in the project.", + ) + + @api.constrains("portal_stage_id") + def _check_portal_stage_id(self): + """Ensure the portal task creation stage belongs to this project.""" + for project in self: + stage = project.portal_stage_id + if stage and stage not in project.type_ids: + raise ValidationError( + _("The Portal Task Creation Stage must belong to this project.") + ) + + def is_portal_task_creation_allowed(self): + """Check if portal task creation is allowed for this project.""" + self.ensure_one() + return ( + bool(self.portal_stage_id) + and self.env.context.get("uid", self.env.user.id) + in self.portal_user_ids.ids + ) diff --git a/project_task_create_portal/models/project_task.py b/project_task_create_portal/models/project_task.py new file mode 100644 index 0000000000..72149863e9 --- /dev/null +++ b/project_task_create_portal/models/project_task.py @@ -0,0 +1,100 @@ +from odoo import _, api, fields, models +from odoo.exceptions import AccessError + + +class ProjectTask(models.Model): + _inherit = "project.task" + + portal_stage_editable = fields.Boolean( + compute="_compute_portal_stage_editable", + store=True, + ) + + @api.depends("project_id.portal_stage_id", "stage_id") + def _compute_portal_stage_editable(self): + """Compute whether each task is in its project's portal stage.""" + for task in self: + task.portal_stage_editable = bool( + task.project_id.portal_stage_id + and task.stage_id == task.project_id.portal_stage_id + ) + + @api.model + def _check_portal_fields_access(self): + """Return fields that portal users may create or edit.""" + return ["name", "description", "date_deadline"] + + @property + def SELF_WRITABLE_FIELDS(self): + """Extend Odoo's portal-writable fields with this module's fields.""" + return super().SELF_WRITABLE_FIELDS | set(self._check_portal_fields_access()) + + def check_portal_edit_access(self): + """Check if the current user has portal access to edit this task.""" + # Portal users can only access tasks in projects with portal task creation enabled + self.ensure_one() + if not self.project_id.is_portal_task_creation_allowed(): + return False + + # Portal users can only access tasks in the allowed stage + if self.stage_id != self.project_id.portal_stage_id: + return False + + # Portal users can only edit their own tasks + if self.create_uid.id != self.env.context.get("uid", self.env.user.id): + return False + return True + + @api.model_create_multi + def create(self, vals_list): + """Override create to handle portal user restrictions.""" + if self.env.su or not self.env.user.has_group("base.group_portal"): + return super().create(vals_list) + + self.check_access_rights("create") + allowed_fields = set(self._check_portal_fields_access()) | {"project_id"} + records = self.sudo().browse() + for create_vals in vals_list: + vals = dict(create_vals) + vals.pop("user_ids", None) + forbidden_fields = set(vals) - allowed_fields + if forbidden_fields: + raise AccessError( + _("Portal users cannot set the following task fields: %s") + % ", ".join(sorted(forbidden_fields)) + ) + project = self.env["project.project"].sudo().browse(vals.get("project_id")) + if not project.exists() or not project.is_portal_task_creation_allowed(): + raise AccessError( + _("You are not allowed to create tasks in this project.") + ) + vals.pop("project_id") + vals.update( + stage_id=project.portal_stage_id.id, + partner_id=self.env.user.partner_id.id, + ) + task_model = self.with_context( + default_project_id=project.id, + default_user_ids=False, + ) + # Other task modules may add or write protected fields in create hooks. + # Keep their chain in sudo and explicitly validate the final record rule. + record = super(ProjectTask, task_model.sudo()).create([vals]) + record.with_user(self.env.user).check_access_rule("create") + records |= record + return records + + def write(self, vals): + """Override write to handle portal user restrictions.""" + if self.env.user.has_group("base.group_portal") and not self.env.su: + forbidden_fields = set(vals) - set(self._check_portal_fields_access()) + if forbidden_fields: + raise AccessError( + _("Portal users cannot edit the following task fields: %s") + % ", ".join(sorted(forbidden_fields)) + ) + if any(not task.check_portal_edit_access() for task in self): + raise AccessError( + _("You are not allowed to edit tasks in this project.") + ) + return super().write(vals) diff --git a/project_task_create_portal/readme/CONFIGURE.rst b/project_task_create_portal/readme/CONFIGURE.rst new file mode 100644 index 0000000000..ceb4acd851 --- /dev/null +++ b/project_task_create_portal/readme/CONFIGURE.rst @@ -0,0 +1,8 @@ +## To configure the project for task creation from the portal + +- Go to the Project App +- On a project kanban card, press the Settings button +- Go to the "Settings" tab + +- Set the **Portal Task Creation Stage** to a stage (from the project’s stages) from which portal users will be allowed to add tasks. +- Set the **Hide assigned user** to hide the assigned user of the task on the portal. diff --git a/project_task_create_portal/readme/CONTRIBUTORS.rst b/project_task_create_portal/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000000..72749639e9 --- /dev/null +++ b/project_task_create_portal/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +- [XXP](https://xxp-odoo.com): + - Maksim Shurupov + - Mike Lapin diff --git a/project_task_create_portal/readme/DESCRIPTION.rst b/project_task_create_portal/readme/DESCRIPTION.rst new file mode 100644 index 0000000000..f645e9a995 --- /dev/null +++ b/project_task_create_portal/readme/DESCRIPTION.rst @@ -0,0 +1,10 @@ +This module extends the Project application by enabling portal users to create new tasks and edit their own tasks directly from the Odoo portal. + + +## Features + +- New field on project: **Portal Task Creation Stage** +- Portal users can create tasks in the pre-defined stages for allowed projects +- Extended portal UI: + - **“Create Task”** button visible for allowed project + - Simplified task creation form (Name, Description, Deadline) diff --git a/project_task_create_portal/readme/USAGE.rst b/project_task_create_portal/readme/USAGE.rst new file mode 100644 index 0000000000..52028f5a61 --- /dev/null +++ b/project_task_create_portal/readme/USAGE.rst @@ -0,0 +1,26 @@ +## To create a task from the portal + + +- Go to the portal, navigate to Projects +- Select an available project +- Press the “New Task” button +- In the form view, fill in: + - Name (required) + - Description (required) + - Deadline (optional) +- Press the Save button + +## To edit a task from the portal + +Note: the user can edit only their own tasks created + +- Go to the portal, navigate to Projects +- Select an available project +- Select a task in the stage permitted for task creation and task editing +- Press the "Edit" button +- In the form view, edit the: + - Name + - Description + - Deadline +- Press the Save button + diff --git a/project_task_create_portal/security/ir.model.access.csv b/project_task_create_portal/security/ir.model.access.csv new file mode 100644 index 0000000000..587663e8f7 --- /dev/null +++ b/project_task_create_portal/security/ir.model.access.csv @@ -0,0 +1,2 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_project_task_portal_management,project.task portal management,project.model_project_task,base.group_portal,1,1,1,0 diff --git a/project_task_create_portal/security/project_task_security.xml b/project_task_create_portal/security/project_task_security.xml new file mode 100644 index 0000000000..b12b1ffebd --- /dev/null +++ b/project_task_create_portal/security/project_task_security.xml @@ -0,0 +1,19 @@ + + + + Portal: manage own tasks in the configured stage + + + [ + ('create_uid', '=', user.id), + ('project_id.portal_user_ids', 'in', [user.id]), + ('portal_stage_editable', '=', True), + ] + + + + + + + + diff --git a/project_task_create_portal/static/description/icon.png b/project_task_create_portal/static/description/icon.png new file mode 100644 index 0000000000..3a0328b516 Binary files /dev/null and b/project_task_create_portal/static/description/icon.png differ diff --git a/project_task_create_portal/static/description/index.html b/project_task_create_portal/static/description/index.html new file mode 100644 index 0000000000..e751014094 --- /dev/null +++ b/project_task_create_portal/static/description/index.html @@ -0,0 +1,34 @@ + + + + + Project Portal Task Creation + + +

Project Portal Task Creation

+

Allow portal users to create and edit their own tasks from the portal only in a project's pre-configured task stage.

+ +

Features

+
    +
  • New field on project: Portal Task Creation Stage
  • +
  • Portal users can create tasks in the pre-defined stages for allowed projects
  • +
  • Extended portal UI with "Create Task" button for allowed projects
  • +
  • Simplified task creation form (Name, Description, Deadline)
  • +
  • Portal users can edit only their own tasks
  • +
+ +

Configuration

+

Go to Project App, open a project and click Settings, then set the Portal Task Creation Stage field.

+ +

Usage

+

Portal users can create tasks in allowed projects and edit only their own tasks. Tasks outside the configured stage cannot be created or modified by portal users.

+ + + + + + + + + + diff --git a/project_task_create_portal/static/src/js/portal.js b/project_task_create_portal/static/src/js/portal.js new file mode 100644 index 0000000000..fe04014a1a --- /dev/null +++ b/project_task_create_portal/static/src/js/portal.js @@ -0,0 +1,53 @@ +odoo.define("portal.PortalProjectTaskCreate", function (require) { + "use strict"; + + const publicWidget = require("web.public.widget"); + const wysiwygLoader = require("web_editor.loader"); + + publicWidget.registry.ProjectPortalTaskCreate = publicWidget.Widget.extend({ + selector: ".portal_task_crud", + + start: function () { + var def = this._super.apply(this, arguments); + + var toolbar = [ + ["style", ["style"]], + ["font", ["bold", "italic", "underline", "clear"]], + ["para", ["ul", "ol", "paragraph"]], + ["table", ["table"]], + ["insert", ["link", "picture"]], + ["history", ["undo", "redo"]], + ]; + + var $textarea = this.$("textarea.o_wysiwyg_loader"); + var loadProm = wysiwygLoader + .loadFromTextarea(this, $textarea[0], { + toolbar: toolbar, + height: 350, + disableResizeImage: true, + }) + .then((wysiwyg) => { + wysiwyg.toolbar.$el.find("#link, #media").remove(); + this.$el + .find(".note-editable") + .find("img.float-start") + .removeClass("float-start"); + this._wysiwyg = wysiwyg; + this._wysiwyg.$editable.addClass("bg-white p-2"); + }); + + return Promise.all([def, loadProm]); + }, + + /** + * @private + */ + _onSubmitClick: function () { + if (this._wysiwyg) { + this._wysiwyg.save(); + } + }, + }); + + return publicWidget.registry.ProjectPortalTaskCreate; +}); diff --git a/project_task_create_portal/static/src/xml/portal_wysiwyg.xml b/project_task_create_portal/static/src/xml/portal_wysiwyg.xml new file mode 100644 index 0000000000..c1768747f1 --- /dev/null +++ b/project_task_create_portal/static/src/xml/portal_wysiwyg.xml @@ -0,0 +1,18 @@ + + + + + + + + +
+
+
+
+ + + diff --git a/project_task_create_portal/tests/__init__.py b/project_task_create_portal/tests/__init__.py new file mode 100644 index 0000000000..a97e455d66 --- /dev/null +++ b/project_task_create_portal/tests/__init__.py @@ -0,0 +1,5 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from . import test_project_portal_task_creation +from . import test_portal_controller diff --git a/project_task_create_portal/tests/test_portal_controller.py b/project_task_create_portal/tests/test_portal_controller.py new file mode 100644 index 0000000000..1d5b6b11eb --- /dev/null +++ b/project_task_create_portal/tests/test_portal_controller.py @@ -0,0 +1,387 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from datetime import date, timedelta +from unittest.mock import patch + +from odoo.fields import Date +from odoo.http import Request +from odoo.tests import tagged + +from odoo.addons.base.tests.common import HttpCaseWithUserPortal +from odoo.addons.project.tests.test_access_rights import TestProjectPortalCommon + +# Import controller +from odoo.addons.project_task_create_portal.controllers.portal import ( + ProjectCustomerNewPortal, +) + + +@tagged("post_install", "-at_install") +class TestPortalController(TestProjectPortalCommon, HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super(TestPortalController, cls).setUpClass() + + # Create test project + cls.project = cls.env["project.project"].create( + { + "name": "Test Project", + "description": "Test project for portal task creation", + "privacy_visibility": "portal", + "message_partner_ids": [(4, cls.user_portal.partner_id.id)], + } + ) + + # Create test stages + cls.stage_backlog = cls.env["project.task.type"].create( + { + "name": "Backlog", + "project_ids": [(6, 0, [cls.project.id])], + } + ) + cls.user_portal.password = cls.user_portal.login + + # Set portal task creation stage + cls.project.portal_stage_id = cls.stage_backlog + + # Set portal allowed users + cls.project.portal_user_ids = cls.user_portal + + cls.controller = ProjectCustomerNewPortal() + + def test_validate_task_fields_all_valid(self): + """Test _validate_task_fields with all valid fields.""" + data = { + "name": "Test Task", + "description": "Test Description", + "date_deadline": Date.to_string(date.today() + timedelta(days=1)), + } + error, error_message = self.controller._validate_task_fields(data) + + self.assertFalse(error) + self.assertFalse(error_message) + + def test_validate_task_fields_missing_name(self): + """Test _validate_task_fields with missing name.""" + data = { + "description": "Test Description", + } + error, error_message = self.controller._validate_task_fields(data) + + self.assertIn("name", error) + self.assertEqual(error["name"], "missing") + + def test_validate_task_fields_missing_description(self): + """Test _validate_task_fields with missing description.""" + data = { + "name": "Test Task", + } + error, error_message = self.controller._validate_task_fields(data) + + self.assertIn("description", error) + self.assertEqual(error["description"], "missing") + + def test_validate_task_fields_deadline_in_past(self): + """Test _validate_task_fields with deadline in the past.""" + data = { + "name": "Test Task", + "description": "Test Description", + "date_deadline": Date.to_string(date.today() - timedelta(days=1)), + } + error, error_message = self.controller._validate_task_fields(data) + + self.assertIn("date_deadline", error) + self.assertEqual(error["date_deadline"], "invalid") + self.assertIn("Deadline is in the past", error_message) + + def test_validate_task_fields_no_deadline(self): + """Test _validate_task_fields without deadline (optional field).""" + data = { + "name": "Test Task", + "description": "Test Description", + } + error, error_message = self.controller._validate_task_fields(data) + + self.assertFalse(error) + self.assertFalse(error_message) + + def test_prepare_task_values_mandatory_only(self): + """Test _prepare_task_values with mandatory fields only.""" + data = { + "name": "Test Task", + "description": "Test Description", + } + values = self.controller._prepare_task_values(data) + + self.assertEqual(values["name"], "Test Task") + self.assertEqual(values["description"], "Test Description") + self.assertNotIn("date_deadline", values) + + def test_prepare_task_values_with_deadline(self): + """Test _prepare_task_values with deadline.""" + deadline = Date.to_string(date.today() + timedelta(days=1)) + data = { + "name": "Test Task", + "description": "Test Description", + "date_deadline": deadline, + } + values = self.controller._prepare_task_values(data) + + self.assertEqual(values["name"], "Test Task") + self.assertEqual(values["description"], "Test Description") + self.assertEqual(values["date_deadline"], deadline) + + def test_prepare_task_values_ignores_extra_fields(self): + """Test _prepare_task_values ignores fields not in allowed list.""" + data = { + "name": "Test Task", + "description": "Test Description", + "priority": "1", + "user_ids": [(6, 0, [1])], + } + values = self.controller._prepare_task_values(data) + + self.assertEqual(values["name"], "Test Task") + self.assertEqual(values["description"], "Test Description") + + def test_task_action_page_view_values(self): + """Test _task_action_page_view_values returns correct structure.""" + with patch.object( + self.controller, + "_prepare_portal_layout_values", + return_value={"base": "values"}, + ): + values = self.controller._task_action_page_view_values(self.project) + + self.assertIn("project", values) + self.assertEqual(values["project"], self.project) + self.assertIn("error", values) + self.assertEqual(values["error"], {}) + self.assertIn("error_message", values) + self.assertEqual(values["error_message"], []) + self.assertIn("base", values) + + def test_portal_project_create_task_get_request(self): + """Test portal_project_create_task with GET request.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + response = self.url_open( + f"/my/projects/{self.project.id}/task/new", + ) + + self.assertEqual(response.status_code, 200) + self.assertIn(b"Test Project", response.content) + + def test_portal_project_create_task_post_valid(self): + """Test portal_project_create_task with valid POST request.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + post_data = { + "name": "Test Task from Portal", + "description": "Test Description from Portal", + "date_deadline": Date.to_string(date.today() + timedelta(days=1)), + "csrf_token": Request.csrf_token(self), + } + + response = self.url_open( + f"/my/projects/{self.project.id}/task/new", + data=post_data, + ) + + # Should redirect to task view + self.assertEqual(response.status_code, 200) + + # Check task was created + task = ( + self.env["project.task"] + .sudo() + .search([("name", "=", "Test Task from Portal")]) + ) + self.assertTrue(task) + self.assertEqual(task.project_id, self.project) + self.assertEqual(task.stage_id, self.stage_backlog) + self.assertEqual(task.create_uid, self.user_portal) + + def test_portal_project_create_task_post_invalid(self): + """Test portal_project_create_task with invalid POST request.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + post_data = { + "name": "", # Missing name + "description": "Test Description", + "csrf_token": Request.csrf_token(self), + } + + response = self.url_open( + f"/my/projects/{self.project.id}/task/new", + data=post_data, + ) + + # Should stay on the form with errors + self.assertEqual(response.status_code, 200) + self.assertIn(b"Test Project", response.content) + + def test_portal_project_create_task_project_not_found(self): + """Test portal_project_create_task with non-existent project.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + response = self.url_open("/my/projects/99999/task/new") + self.assertEqual(response.status_code, 400) + self.assertIn("Project not found!", response.text) + + def test_portal_project_create_task_not_allowed(self): + """Test portal_project_create_task when not allowed.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + # Remove portal stage + self.project.portal_stage_id = False + + response = self.url_open(f"/my/projects/{self.project.id}/task/new") + self.assertIn( + "You are not allowed to create tasks in this project.", response.text + ) + self.assertEqual(response.status_code, 403) + + def test_portal_project_edit_task_get_request(self): + """Test portal_project_edit_task with GET request.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + # Create task + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Test Description", + "project_id": self.project.id, + } + ) + ) + + response = self.url_open(f"/my/projects/{self.project.id}/task/{task.id}/edit") + + self.assertEqual(response.status_code, 200) + + def test_portal_project_edit_task_post_valid(self): + """Test portal_project_edit_task with valid POST request.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + # Create task + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Test Description", + "project_id": self.project.id, + } + ) + ) + + post_data = { + "name": "Updated Portal Task", + "description": "Updated Description", + "date_deadline": Date.to_string(date.today() + timedelta(days=1)), + "csrf_token": Request.csrf_token(self), + } + + response = self.url_open( + f"/my/projects/{self.project.id}/task/{task.id}/edit", + data=post_data, + ) + + # Should redirect to task view + self.assertEqual(response.status_code, 200) + + # Check task was updated + task = self.env["project.task"].sudo().browse(task.id) + self.assertEqual(task.name, "Updated Portal Task") + self.assertIn("Updated Description", task.description) + + def test_portal_project_edit_task_post_invalid(self): + """Test portal_project_edit_task with invalid POST request.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + # Create task + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Test Description", + "project_id": self.project.id, + } + ) + ) + + post_data = { + "name": "", # Missing name + "description": "Test Description", + "csrf_token": Request.csrf_token(self), + } + + response = self.url_open( + f"/my/projects/{self.project.id}/task/{task.id}/edit", + data=post_data, + ) + + # Should stay on the form with errors + self.assertEqual(response.status_code, 200) + + def test_portal_project_edit_task_not_found(self): + """Test portal_project_edit_task with non-existent task.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + response = self.url_open(f"/my/projects/{self.project.id}/task/99999/edit") + self.assertEqual(response.status_code, 400) + self.assertIn("Task not found!", response.text) + + def test_portal_project_edit_task_not_allowed(self): + """Test portal_project_edit_task when not allowed.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + # Create task + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Test Description", + "project_id": self.project.id, + } + ) + ) + + # Remove portal stage + self.project.portal_stage_id = False + + response = self.url_open(f"/my/projects/{self.project.id}/task/{task.id}/edit") + self.assertEqual(response.status_code, 403) + self.assertIn("You are not allowed to edit this task.", response.text) + + def test_portal_project_edit_task_wrong_project(self): + """Test portal_project_edit_task with task from different project.""" + self.authenticate(self.user_portal.login, self.user_portal.login) + + # Create another project + other_project = self.env["project.project"].create( + { + "name": "Other Project", + } + ) + + # Create task in other project + task = self.env["project.task"].create( + { + "name": "Other Task", + "description": "Test Description", + "project_id": other_project.id, + } + ) + + response = self.url_open(f"/my/projects/{self.project.id}/task/{task.id}/edit") + self.assertEqual(response.status_code, 400) diff --git a/project_task_create_portal/tests/test_project_portal_task_creation.py b/project_task_create_portal/tests/test_project_portal_task_creation.py new file mode 100644 index 0000000000..fc14d2cd55 --- /dev/null +++ b/project_task_create_portal/tests/test_project_portal_task_creation.py @@ -0,0 +1,355 @@ +# Copyright 2024 Odoo Community Association (OCA) +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html). + +from odoo.exceptions import AccessError, ValidationError +from odoo.tests import tagged + +from odoo.addons.base.tests.common import HttpCaseWithUserPortal +from odoo.addons.project.tests.test_access_rights import TestProjectPortalCommon + + +@tagged("post_install", "-at_install") +class TestProjectPortalTaskCreation(TestProjectPortalCommon, HttpCaseWithUserPortal): + @classmethod + def setUpClass(cls): + super(TestProjectPortalTaskCreation, cls).setUpClass() + + # Create test project + cls.project = cls.env["project.project"].create( + { + "name": "Test Project", + "description": "Test project for portal task creation", + "privacy_visibility": "portal", + "message_partner_ids": [(4, cls.user_portal.partner_id.id)], + } + ) + + # Create test stages + cls.stage_backlog = cls.env["project.task.type"].create( + { + "name": "Backlog", + "project_ids": [(6, 0, [cls.project.id])], + } + ) + + cls.stage_in_progress = cls.env["project.task.type"].create( + { + "name": "In Progress", + "project_ids": [(6, 0, [cls.project.id])], + } + ) + + # Set portal task creation stage + cls.project.portal_stage_id = cls.stage_backlog + + # Set portal allowed users + cls.project.portal_user_ids = cls.user_portal + + def test_project_portal_task_creation_stage_constraint(self): + """Test that portal task creation stage must belong to the project.""" + # Create another project + other_project = self.env["project.project"].create( + { + "name": "Other Project", + } + ) + + # Create stage for other project + other_stage = self.env["project.task.type"].create( + { + "name": "Other Stage", + "project_ids": [(6, 0, [other_project.id])], + } + ) + + # Try to set stage from other project + with self.assertRaises(ValidationError): + self.project.portal_stage_id = other_stage + + def test_project_portal_task_creation_allowed(self): + """Test project portal task creation allowed check.""" + # Project with portal stage should allow creation + user_project = self.project.with_user(self.user_portal).sudo() + self.assertTrue(user_project.is_portal_task_creation_allowed()) + + # Project without portal stage should not allow creation + self.project.portal_stage_id = False + self.assertFalse(user_project.is_portal_task_creation_allowed()) + + self.project.portal_stage_id = self.stage_backlog + self.project.portal_user_ids = False + self.assertFalse(user_project.is_portal_task_creation_allowed()) + + def test_task_portal_creation(self): + """Test task creation by portal user.""" + # Create task as portal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + # Check that task was created with correct settings + self.assertEqual(task.name, "Portal Task") + self.assertEqual(task.project_id, self.project) + self.assertEqual(task.stage_id, self.stage_backlog) + self.assertEqual(task.create_uid, self.user_portal) + + def test_task_portal_creation_without_allowed_project(self): + """Test task creation by portal user in project without portal stage.""" + # Remove portal stage from project + self.project.portal_stage_id = False + + # Try to create task as portal user + with self.assertRaises(AccessError): + self.env["project.task"].with_user(self.user_portal).create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + + def test_task_portal_edit_own_task(self): + """Test portal user editing their own task.""" + # Create task as portal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + # Edit task as portal user + task.with_user(self.user_portal).write( + { + "name": "Updated Portal Task", + "description": "Updated description", + } + ) + + # Check that task was updated + self.assertEqual(task.name, "Updated Portal Task") + self.assertIn("Updated description", task.description) + + def test_task_portal_edit_rejects_forbidden_fields(self): + """Test that portal users can edit only fields exposed by the form.""" + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + for field_name, value in ( + ("stage_id", self.stage_in_progress.id), + ("project_id", False), + ("user_ids", [(6, 0, [self.env.ref("base.user_admin").id])]), + ): + with self.subTest(field_name=field_name), self.assertRaises(AccessError): + task.with_user(self.user_portal).write({field_name: value}) + + def test_task_portal_record_rule_rejects_other_users_task(self): + """Test the portal record rule restricts writes to tasks owned by the user.""" + task = self.env["project.task"].create( + { + "name": "Internal Task", + "description": "Task created by an internal user", + "project_id": self.project.id, + "stage_id": self.stage_backlog.id, + } + ) + + with self.assertRaises(AccessError): + task.with_user(self.user_portal).check_access_rule("write") + + def test_task_portal_edit_other_user_task(self): + """Test portal user trying to edit another user's task.""" + # Create task as internal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Internal Task", + "description": "Task created by internal user", + "project_id": self.project.id, + } + ) + ) + + self.project.portal_user_ids = False + + # Try to edit task as portal user + with self.assertRaises(AccessError): + task.with_user(self.user_portal).write( + { + "name": "Hacked Task", + } + ) + + def test_task_portal_edit_wrong_stage(self): + """Test portal user trying to edit task in wrong stage.""" + # Create task as portal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + # Move task to different stage + task.sudo().stage_id = self.stage_in_progress + + # Try to edit task as portal user + with self.assertRaises(AccessError): + task.with_user(self.user_portal).write( + { + "name": "Updated Task", + } + ) + + def test_check_portal_fields_access(self): + """Test _check_portal_fields_access method returns correct fields.""" + Task = self.env["project.task"] + allowed_fields = Task._check_portal_fields_access() + + # Check that the method returns expected fields + self.assertIsInstance(allowed_fields, list) + self.assertIn("name", allowed_fields) + self.assertIn("description", allowed_fields) + self.assertIn("date_deadline", allowed_fields) + self.assertEqual(len(allowed_fields), 3) + + def test_check_portal_edit_access_allowed(self): + """Test check_portal_edit_access when access is allowed.""" + # Create task as portal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + # Check that portal user has edit access to their own task + self.assertTrue(task.with_user(self.user_portal).check_portal_edit_access()) + + def test_check_portal_edit_access_denied_no_portal_stage(self): + """Test check_portal_edit_access denied when project has no portal stage.""" + # Create task as portal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + # Remove portal stage + self.project.portal_stage_id = False + + # Check that portal user has no edit access + self.assertFalse(task.with_user(self.user_portal).check_portal_edit_access()) + + def test_check_portal_edit_access_denied_wrong_stage(self): + """Test check_portal_edit_access denied when task is in wrong stage.""" + # Create task as portal user + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + } + ) + ) + + # Move task to different stage + task.sudo().stage_id = self.stage_in_progress + + # Check that portal user has no edit access + self.assertFalse(task.with_user(self.user_portal).check_portal_edit_access()) + + def test_check_portal_edit_access_denied_not_creator(self): + """Test check_portal_edit_access denied when user is not the creator.""" + # Create task as admin + task = self.env["project.task"].create( + { + "name": "Admin Task", + "description": "Task created by admin", + "project_id": self.project.id, + "stage_id": self.stage_backlog.id, + } + ) + + # Check that portal user has no edit access to admin's task + self.assertFalse( + task.with_user(self.user_portal).sudo().check_portal_edit_access() + ) + + def test_create_task_as_portal_clears_assignees(self): + """Test that creating a task as portal user clears user_ids.""" + admin_user = self.env.ref("base.user_admin") + task = ( + self.env["project.task"] + .with_user(self.user_portal) + .create( + { + "name": "Portal Task", + "description": "Task created by portal user", + "project_id": self.project.id, + "user_ids": [(6, 0, [admin_user.id])], + } + ) + ) + + self.assertFalse(task.user_ids) + + def test_create_task_as_internal_user_keeps_assignees(self): + """Test that creating a task as internal user keeps user_ids.""" + admin_user = self.env.ref("base.user_admin") + # Create task as internal user with user_ids + task = self.env["project.task"].create( + { + "name": "Admin Task", + "description": "Task created by admin", + "project_id": self.project.id, + "user_ids": [(6, 0, [admin_user.id])], + } + ) + + # Check that user_ids is preserved + self.assertTrue(task.user_ids) + self.assertIn(admin_user, task.user_ids) diff --git a/project_task_create_portal/views/portal_template.xml b/project_task_create_portal/views/portal_template.xml new file mode 100644 index 0000000000..2f44c9277e --- /dev/null +++ b/project_task_create_portal/views/portal_template.xml @@ -0,0 +1,216 @@ + + + + + + + +