From f9f32aab34d244116ac6f21f581f8cdfa32ba6d0 Mon Sep 17 00:00:00 2001 From: Marc Durepos Date: Wed, 26 Feb 2025 10:12:33 -0500 Subject: [PATCH] new module fsm_visit_confirmation --- fsm_visit_confirmation/__init__.py | 2 - fsm_visit_confirmation/__manifest__.py | 23 +- .../controllers/__init__.py | 1 - fsm_visit_confirmation/controllers/portal.py | 35 --- .../data/mail_templates.xml | 100 ++++--- fsm_visit_confirmation/models/__init__.py | 1 - fsm_visit_confirmation/models/project_task.py | 9 - fsm_visit_confirmation/tests/__init__.py | 1 + .../tests/test_fsm_visit_confirmation.py | 232 +++++++++++++++ project_task_template/__init__.py | 1 + project_task_template/__manifest__.py | 45 +++ project_task_template/models/__init__.py | 2 + project_task_template/models/project_task.py | 13 + .../models/project_task_template.py | 162 +++++++++++ .../security/ir.model.access.csv | 3 + project_task_template/tests/__init__.py | 1 + .../tests/test_project_task_template.py | 134 +++++++++ project_task_template/views/project_menus.xml | 9 + .../views/project_task_template_views.xml | 112 ++++++++ sale_project_task_template/__init__.py | 1 + sale_project_task_template/__manifest__.py | 34 +++ sale_project_task_template/models/__init__.py | 3 + .../models/product_template.py | 20 ++ .../models/project_task_template.py | 13 + .../models/sale_order_line.py | 49 ++++ sale_project_task_template/tests/__init__.py | 1 + .../tests/test_sale_project_task_template.py | 270 ++++++++++++++++++ .../views/product_views.xml | 16 ++ .../views/project_task_template_views.xml | 13 + 29 files changed, 1218 insertions(+), 88 deletions(-) delete mode 100644 fsm_visit_confirmation/controllers/__init__.py delete mode 100644 fsm_visit_confirmation/controllers/portal.py delete mode 100644 fsm_visit_confirmation/models/__init__.py delete mode 100644 fsm_visit_confirmation/models/project_task.py create mode 100644 fsm_visit_confirmation/tests/__init__.py create mode 100644 fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py create mode 100644 project_task_template/__init__.py create mode 100644 project_task_template/__manifest__.py create mode 100644 project_task_template/models/__init__.py create mode 100644 project_task_template/models/project_task.py create mode 100644 project_task_template/models/project_task_template.py create mode 100644 project_task_template/security/ir.model.access.csv create mode 100644 project_task_template/tests/__init__.py create mode 100644 project_task_template/tests/test_project_task_template.py create mode 100644 project_task_template/views/project_menus.xml create mode 100644 project_task_template/views/project_task_template_views.xml create mode 100644 sale_project_task_template/__init__.py create mode 100644 sale_project_task_template/__manifest__.py create mode 100644 sale_project_task_template/models/__init__.py create mode 100644 sale_project_task_template/models/product_template.py create mode 100644 sale_project_task_template/models/project_task_template.py create mode 100644 sale_project_task_template/models/sale_order_line.py create mode 100644 sale_project_task_template/tests/__init__.py create mode 100644 sale_project_task_template/tests/test_sale_project_task_template.py create mode 100644 sale_project_task_template/views/product_views.xml create mode 100644 sale_project_task_template/views/project_task_template_views.xml diff --git a/fsm_visit_confirmation/__init__.py b/fsm_visit_confirmation/__init__.py index 91c5580..e69de29 100644 --- a/fsm_visit_confirmation/__init__.py +++ b/fsm_visit_confirmation/__init__.py @@ -1,2 +0,0 @@ -from . import controllers -from . import models diff --git a/fsm_visit_confirmation/__manifest__.py b/fsm_visit_confirmation/__manifest__.py index 4125249..90071c5 100644 --- a/fsm_visit_confirmation/__manifest__.py +++ b/fsm_visit_confirmation/__manifest__.py @@ -20,14 +20,31 @@ { "name": "FSM Visit Confirmation", "version": "17.0.0.1.0", - "summary": "Have clients confirm tentatively booked visits", + "summary": "Enable client feedback workflow for field service tasks", + "description": """ + This module enhances the field service management workflow by leveraging Odoo's + built-in rating system for client task confirmations. Key features include: + + * Automated rating requests to work order contacts when tasks reach specific stages + * Configurable stages with rating email templates + * Client-facing rating interface with direct links in emails + * Support for task approval or change requests through ratings + * Integration with existing work order contacts from bemade_fsm + * Automatic task state updates based on client feedback + * Real-time feedback through website messages and notifications + * Chatter integration for client comments + + The module helps streamline communication between field service teams and clients + by providing a clear feedback workflow and maintaining a record of all client + interactions and approvals through Odoo's rating system. + """, "category": "Services/Field Service", "author": "Bemade Inc.", "website": "http://www.bemade.org", "license": "LGPL-3", - "depends": ["industry_fsm"], + "depends": ["industry_fsm", "bemade_fsm", "rating"], "data": ["data/mail_templates.xml"], - "assets": {}, "installable": True, "auto_install": False, + "application": False, } diff --git a/fsm_visit_confirmation/controllers/__init__.py b/fsm_visit_confirmation/controllers/__init__.py deleted file mode 100644 index 8c3feb6..0000000 --- a/fsm_visit_confirmation/controllers/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import portal diff --git a/fsm_visit_confirmation/controllers/portal.py b/fsm_visit_confirmation/controllers/portal.py deleted file mode 100644 index 7358871..0000000 --- a/fsm_visit_confirmation/controllers/portal.py +++ /dev/null @@ -1,35 +0,0 @@ -from odoo.addons.portal.controllers.portal import CustomerPortal -from odoo.http import request, route -from odoo.exceptions import AccessError, MissingError - - -class FsmCustomerPortal(CustomerPortal): - @route( - "/my/tasks/approve_booking/", - type="http", - auth="public", - website=True, - ) - def portal_approve_booking(self, task_id, access_token=None): - try: - visit_sudo = self._document_check_access( - "project.task", task_id, access_token=access_token - ) - except (AccessError, MissingError): - return request.redirect("/my") - - visit_sudo.action_approve_booking() - request.session["visit_confirmation_accepted"] = True - request.redirect(f"/my/tasks/{task_id}") - - def _task_get_page_view_values(self, task, access_token, **kwargs): - vals = super()._task_get_page_view_values(task, access_token, **kwargs) - if request.session.pop("visit_confirmation_accepted", False): - vals.update(visit_confirmation_accepted=True) - return vals - - def _prepare_home_portal_values(self, counters): - vals = super()._prepare_home_portal_values(counters) - if request.session.pop("visit_confirmation_accepted", False): - vals.update(visit_confirmation_accepted=True) - return vals diff --git a/fsm_visit_confirmation/data/mail_templates.xml b/fsm_visit_confirmation/data/mail_templates.xml index 6b08b46..b5202fe 100644 --- a/fsm_visit_confirmation/data/mail_templates.xml +++ b/fsm_visit_confirmation/data/mail_templates.xml @@ -1,47 +1,73 @@ - - Field Service Visit Confirmation Request + + Field Service Task: Rating Request - Please confirm the date for your upcoming Durpro service visit - {{ object.company_id.email_formatted }} - - - + Please confirm your upcoming service visit with {{ object.company_id.name }} + {{ user.email_formatted }} + {{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }} + Sent to request a client rating/confirmation for a field service task + +
+
+ Dear {{ object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name }}, +
+
+ We would like to confirm the details of your upcoming service visit: +
+
- - + + - - + + - - - - - - - - + + + + + + +
Summary{{ object.name }}Task Summary{{ object.name }}
Technician Arrival{{ object.planned_date_begin }}Technician Arrival{{ object.planned_date_begin }}
Intervention address -
-
-
-
- -
- Assigned Technician(s): - - -
-
-
Location + {{ object.partner_id.street }}
+ {% if object.partner_id.street2 %} + {{ object.partner_id.street2 }}
+ {% endif %} + {{ object.partner_id.city }}, {{ object.partner_id.state_id.code }} {{ object.partner_id.zip }} +
Assigned Technician(s) + {% for user in object.user_ids %} + {{ user.name }}{% if not loop.last %}
+{% endif %} + {% endfor %} +
-

To confirm this visit, click here.

-

If you would like to propose another time for this visit, please reply to this email.

-

Best regards,

-

The {{ object.company_id.name }} service management team.

-
-
+
+ Please take a moment to confirm this visit by clicking one of the following options: +
+ + + + + +
+ + Confirm Visit + + + + Request Changes + +
+
+

Best regards,

+

The {{ object.company_id.name }} service management team

+
+ + + {{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }} + +
\ No newline at end of file diff --git a/fsm_visit_confirmation/models/__init__.py b/fsm_visit_confirmation/models/__init__.py deleted file mode 100644 index edf2d36..0000000 --- a/fsm_visit_confirmation/models/__init__.py +++ /dev/null @@ -1 +0,0 @@ -from . import project_task diff --git a/fsm_visit_confirmation/models/project_task.py b/fsm_visit_confirmation/models/project_task.py deleted file mode 100644 index 7cc9d1c..0000000 --- a/fsm_visit_confirmation/models/project_task.py +++ /dev/null @@ -1,9 +0,0 @@ -from odoo import models, fields, api - - -class Task(models.Model): - _inherit = "project.task" - - def action_approve_booking(self): - self.ensure_one() - self.state = "03_approved" diff --git a/fsm_visit_confirmation/tests/__init__.py b/fsm_visit_confirmation/tests/__init__.py new file mode 100644 index 0000000..f37e2a8 --- /dev/null +++ b/fsm_visit_confirmation/tests/__init__.py @@ -0,0 +1 @@ +from . import test_fsm_visit_confirmation diff --git a/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py b/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py new file mode 100644 index 0000000..496568f --- /dev/null +++ b/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py @@ -0,0 +1,232 @@ +# -*- coding: utf-8 -*- + +import logging +from datetime import datetime, timedelta +from odoo import http +from odoo.tests import HttpCase, tagged +from odoo.addons.rating.models import rating_data + +_logger = logging.getLogger(__name__) + + +@tagged("post_install", "-at_install") +class TestFSMVisitConfirmation(HttpCase): + + def setUp(self): + super().setUp() + + # Configure test company + self.env.company.write( + { + "email": "company@test.example.com", + "name": "Test Company", + } + ) + + # Create test users and partners + self.fsm_user = self.env["res.users"].create( + { + "name": "FSM User", + "login": "fsm_user", + "email": "fsm@example.com", + "groups_id": [ + ( + 6, + 0, + [ + self.env.ref("industry_fsm.group_fsm_user").id, + self.env.ref("project.group_project_rating").id, + ], + ) + ], + } + ) + # Set email on user's partner + self.fsm_user.partner_id.email = "fsm@example.com" + + self.customer = self.env["res.partner"].create( + { + "name": "Customer", + "email": "customer@example.com", + } + ) + + # Create a project + self.project = self.env["project.project"].create( + { + "name": "Test FSM Project", + "is_fsm": True, + "rating_active": True, + "rating_status": "stage", # Rating requests will be sent when tasks reach stages with rating templates + "company_id": self.env.user.company_id.id, + } + ) + + # Create stages for the project + self.stage_new = self.env["project.task.type"].create( + { + "name": "New", + "sequence": 0, + "project_ids": [(4, self.project.id)], + } + ) + + self.stage_needs_confirmation = self.env["project.task.type"].create( + { + "name": "Need Confirmation", + "sequence": 1, + "project_ids": [(4, self.project.id)], + "rating_template_id": self.env.ref( + "fsm_visit_confirmation.rating_project_request_email_template" + ).id, + "auto_validation_state": True, + } + ) + + # Create a task + self.task = self.env["project.task"].create( + { + "name": "Test Task", + "project_id": self.project.id, + "partner_id": self.customer.id, + "work_order_contacts": [(4, self.customer.id)], + "user_ids": [(4, self.fsm_user.id)], + "planned_date_begin": datetime.now() + timedelta(days=1), + "stage_id": self.stage_new.id, + } + ) + self.assertEqual( + self.task.stage_id, self.stage_new, "Task should start in New stage" + ) + + def test_01_task_rating_flow(self): + """Test the complete task rating flow""" + # Move task to confirmation stage + _logger.info("Project rating_active: %s", self.project.rating_active) + _logger.info( + "Stage rating_template_id: %s", + self.stage_needs_confirmation.rating_template_id, + ) + + # Verify project rating settings + self.assertTrue(self.project.rating_active, "Project rating should be active") + self.assertEqual( + self.project.rating_status, + "stage", + "Project rating status should be 'stage'", + ) + self.assertTrue( + self.stage_needs_confirmation.rating_template_id, + "Stage should have a rating template", + ) + + # Move task to confirmation stage + self.task.write({"stage_id": self.stage_needs_confirmation.id}) + + # # Force a small delay to allow email processing + # import time + # time.sleep(1) + + # Get the token directly from the task, passing the customer partner + rating = self.env["rating.rating"].search( + [ + ("res_id", "=", self.task.id), + ("res_model", "=", "project.task"), + ] + ) + self.assertTrue(rating, "Rating should be created") + self.assertEqual( + rating.partner_id, self.customer, "Rating should be for the customer" + ) + token = rating.access_token + self.assertTrue(token, "Rating token should exist") + _logger.info("Rating token: %s", token) + + # Simulate customer confirming the visit (rating = 5) + self.authenticate(None, None) + + # First open the rating page + url = f"/rate/{rating.access_token}/5" + response = self.url_open(url) + self.assertEqual( + response.status_code, 200, "Opening rating page should succeed" + ) + + # Then submit the feedback + url = f"/rate/{rating.access_token}/submit_feedback" + response = self.url_open( + url, + data={ + "rate": "5", + "feedback": "Great service!", + "csrf_token": http.Request.csrf_token(self), + }, + ) + self.assertEqual(response.status_code, 200, "Rating submission should succeed") + + # Now check that the rating was created and consumed + rating.invalidate_recordset() + self.assertTrue(rating.consumed, "Rating should be consumed") + self.assertEqual( + rating.partner_id, + self.customer, + "Rating should be from work order contact", + ) + self.assertEqual(rating.rating, 5, "Rating should be 5") + self.assertEqual(rating.feedback, "Great service!", "Feedback should be saved") + self.assertEqual(self.task.state, "03_approved", "Task should be approved") + + def test_02_task_rating_flow_request_changes(self): + """Test the complete task rating flow in the second test case.""" + # Move task to confirmation stage + self.task.write({"stage_id": self.stage_needs_confirmation.id}) + + # Check that a rating request was created + rating = self.env["rating.rating"].search( + [("res_id", "=", self.task.id), ("res_model", "=", "project.task")] + ) + self.assertTrue(rating, "Rating request should be created") + self.assertEqual( + rating.partner_id, + self.customer, + "Rating should be requested from work order contact", + ) + + # Simulate customer refusing the visit (rating = 1) + self.authenticate(None, None) + + # First open the rating page + url = f"/rate/{rating.access_token}/1" + response = self.url_open(url) + self.assertEqual( + response.status_code, 200, "Rating page should load successfully" + ) + + # Then submit the feedback + url = f"/rate/{rating.access_token}/submit_feedback" + response = self.url_open( + url, + data={ + "rate": "1", + "feedback": "Need some changes", + "csrf_token": http.Request.csrf_token(self), + }, + ) + self.assertEqual( + response.status_code, 200, "Feedback submission should succeed" + ) + + # Refresh the rating record + rating.invalidate_recordset() + self.assertTrue(rating.consumed, "Rating should be consumed") + self.assertEqual(rating.rating, 1, "Rating should be updated to 1") + self.assertEqual( + rating.feedback, + "Need some changes", + "Feedback should be saved", + ) + self.assertEqual( + self.task.state, + "02_changes_requested", + "Task should be in changes requested state", + ) diff --git a/project_task_template/__init__.py b/project_task_template/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/project_task_template/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/project_task_template/__manifest__.py b/project_task_template/__manifest__.py new file mode 100644 index 0000000..e59a21e --- /dev/null +++ b/project_task_template/__manifest__.py @@ -0,0 +1,45 @@ +{ + "name": "Project Task Templates", + "version": "17.0.1.0.0", + "category": "Project", + "summary": "Create and manage reusable task templates for projects", + "description": """ +Project Task Templates +====================== + +This module provides a framework for creating and managing reusable task templates +that can be used to quickly generate project tasks with predefined configurations. + +Key Features +------------ + +* Define reusable task templates with common fields (name, description, planned hours) +* Support for task hierarchies (templates can define parent tasks and subtasks) +* Templates can specify default assignees, tags, and other task properties +* Track template changes through the chatter +* Copy templates to create variations + +Technical Details +----------------- + +* Introduces project.task.template model that defines the structure of tasks to create +* Links generated tasks back to their originating templates +* Templates are structural only - they define what tasks to create but don't control runtime behavior +* Changes to templates do not affect tasks that were already created + """, + "author": "Bemade Inc", + "website": "https://www.bemade.org", + "depends": [ + "project", + "mail", # For chatter support + ], + "data": [ + "security/ir.model.access.csv", + "views/project_task_template_views.xml", + "views/project_menus.xml", + ], + "license": "LGPL-3", + "installable": True, + "application": False, + "auto_install": False, +} diff --git a/project_task_template/models/__init__.py b/project_task_template/models/__init__.py new file mode 100644 index 0000000..cc26bab --- /dev/null +++ b/project_task_template/models/__init__.py @@ -0,0 +1,2 @@ +from . import project_task_template +from . import project_task diff --git a/project_task_template/models/project_task.py b/project_task_template/models/project_task.py new file mode 100644 index 0000000..8f7e2ac --- /dev/null +++ b/project_task_template/models/project_task.py @@ -0,0 +1,13 @@ +from odoo import models, fields + + +class ProjectTask(models.Model): + _inherit = 'project.task' + + template_id = fields.Many2one( + comodel_name='project.task.template', + string='Task Template', + help='Template this task was created from', + readonly=True, + index=True, + ) diff --git a/project_task_template/models/project_task_template.py b/project_task_template/models/project_task_template.py new file mode 100644 index 0000000..8a0164b --- /dev/null +++ b/project_task_template/models/project_task_template.py @@ -0,0 +1,162 @@ +from odoo import models, fields, api, _ + + +class ProjectTaskTemplate(models.Model): + _name = "project.task.template" + _description = "Template for new project tasks" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "sequence, id" + + @api.model + def _current_company(self): + return self.env.company + + name = fields.Char( + string="Task Title", + required=True, + tracking=True, + ) + + description = fields.Html(tracking=True) + + assignee_ids = fields.Many2many( + comodel_name="res.users", + string="Default Assignees", + help="Employees assigned to tasks created from this template.", + tracking=1, + ) + + project_id = fields.Many2one( + comodel_name="project.project", + string="Default Project", + help="Default project for tasks created from this template.", + tracking=2, + ) + + tag_ids = fields.Many2many( + comodel_name="project.tags", + string="Default Tags", + help="Default tags for tasks created from this template.", + tracking=3, + ) + + parent_id = fields.Many2one( + comodel_name="project.task.template", + string="Parent Task Template", + ondelete="cascade", + tracking=4, + ) + + subtask_ids = fields.One2many( + comodel_name="project.task.template", + inverse_name="parent_id", + string="Subtask Templates", + copy=True, + ) + + sequence = fields.Integer(default=10) + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + index=True, + default=_current_company, + tracking=5, + ) + + allocated_hours = fields.Float( + "Initially Allocated Hours", + tracking=6, + ) + + active = fields.Boolean( + default=True, + tracking=7, + ) + + task_count = fields.Integer( + string="Tasks", + compute="_compute_task_count", + ) + + def _compute_task_count(self): + for template in self: + template.task_count = self.env["project.task"].search_count( + [("template_id", "=", template.id)] + ) + + def action_view_tasks(self): + """Open the tree view of tasks created from this template.""" + self.ensure_one() + action = self.env["ir.actions.actions"]._for_xml_id("project.action_view_task") + action["domain"] = [("template_id", "=", self.id)] + action["context"] = { + "default_template_id": self.id, + "create": False, # Don't allow creation from this view + } + return action + + def action_open_task(self): + return { + "view_mode": "form", + "res_model": "project.task.template", + "res_id": self.id, + "type": "ir.actions.act_window", + "context": self._context, + } + + def _prepare_new_task_values_from_self(self, project, name=False, parent_id=False): + """Prepare values for creating a new task from this template. + + :param project: project.project record the task should be added to + :param name: name for the new task (defaults to template name) + :param parent_id: parent task for the new task (none by default) + :return: dict of values for creating the task + """ + vals = { + "project_id": project.id, + "name": name or self.name, + "description": self.description, + "parent_id": parent_id, + "user_ids": self.assignee_ids.ids, + "tag_ids": self.tag_ids.ids, + "allocated_hours": self.allocated_hours, + "sequence": self.sequence, + "company_id": self.company_id.id, + "template_id": self.id, + } + return vals + + def create_task_from_self(self, project, name=False, parent_id=False): + """Create a project.task from this template and return it. + Can be called on a RecordSet of multiple templates. + + :param project: project.project record the task should be added to + :param name: name for the new task (defaults to template name) + :param parent_id: parent task for the new task (none by default) + :return: project.task record created from this template + """ + tasks = self.env["project.task"] + for rec in self: + vals = rec._prepare_new_task_values_from_self(project, name, parent_id) + task = rec.env["project.task"].create(vals) + rec.subtask_ids.create_task_from_self(project, name=False, parent_id=task.id) + tasks |= task + return tasks + + def copy(self, default=None): + """Support copying templates, ensuring proper duplication of the task hierarchy.""" + self.ensure_one() + if default is None: + default = {} + if "name" not in default: + default["name"] = self.name + _(" (copy)") + # Don't copy parent reference, this will be handled by recursion + default["parent_id"] = False + new = super().copy(default) + + # Copy subtasks, linking them to the new template + for subtask in self.subtask_ids: + subtask.copy({"parent_id": new.id}) + + return new diff --git a/project_task_template/security/ir.model.access.csv b/project_task_template/security/ir.model.access.csv new file mode 100644 index 0000000..49abc8b --- /dev/null +++ b/project_task_template/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_project_task_template_manager,project.task.template.manager,model_project_task_template,project.group_project_manager,1,1,1,1 +access_project_task_template_user,project.task.template.user,model_project_task_template,project.group_project_user,1,0,0,0 diff --git a/project_task_template/tests/__init__.py b/project_task_template/tests/__init__.py new file mode 100644 index 0000000..9a2bf22 --- /dev/null +++ b/project_task_template/tests/__init__.py @@ -0,0 +1 @@ +from . import test_project_task_template diff --git a/project_task_template/tests/test_project_task_template.py b/project_task_template/tests/test_project_task_template.py new file mode 100644 index 0000000..3499a98 --- /dev/null +++ b/project_task_template/tests/test_project_task_template.py @@ -0,0 +1,134 @@ +from odoo.tests.common import TransactionCase, tagged, Form +from odoo.exceptions import AccessError +from odoo.addons.mail.tests.common import MailCase + + +@tagged("post_install", "-at_install") +class TestProjectTaskTemplate(TransactionCase, MailCase): + + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.project_manager = cls.env["res.users"].create( + { + "name": "Project Manager", + "login": "pm_user", + "email": "pm@test.com", + "groups_id": [(4, cls.env.ref("project.group_project_manager").id)], + } + ) + cls.project_user = cls.env["res.users"].create( + { + "name": "Project User", + "login": "proj_user", + "email": "pu@test.com", + "groups_id": [(4, cls.env.ref("project.group_project_user").id)], + } + ) + cls.project = cls.env["project.project"].create({"name": "Test Project"}) + + def test_create_task_from_template_basic(self): + """Test creating a simple task from a template""" + template = self.env["project.task.template"].create( + { + "name": "Test Template", + "description": "Test Description", + "allocated_hours": 5.0, + } + ) + task = template.create_task_from_self(self.project) + + self.assertEqual(task.name, template.name) + self.assertEqual(task.description, template.description) + self.assertEqual(task.allocated_hours, template.allocated_hours) + self.assertEqual(task.template_id, template) + self.assertEqual(task.project_id, self.project) + + def test_create_task_hierarchy_from_template(self): + """Test creating a task with subtasks from a template hierarchy""" + parent_template = self.env["project.task.template"].create( + { + "name": "Parent Template", + "allocated_hours": 10.0, + } + ) + child_template = self.env["project.task.template"].create( + { + "name": "Child Template", + "allocated_hours": 5.0, + "parent_id": parent_template.id, + } + ) + + task = parent_template.create_task_from_self(self.project) + + self.assertEqual(len(task.child_ids), 1) + child_task = task.child_ids[0] + self.assertEqual(child_task.name, child_template.name) + self.assertEqual(child_task.template_id, child_template) + self.assertEqual(child_task.project_id, self.project) + + def test_template_access_rights(self): + """Test access rights for different user types""" + template = ( + self.env["project.task.template"] + .with_user(self.project_manager) + .create( + { + "name": "Manager Template", + } + ) + ) + + # Project user should be able to read but not modify + template.with_user(self.project_user).read(["name"]) + with self.assertRaises(AccessError): + template.with_user(self.project_user).write({"name": "New Name"}) + with self.assertRaises(AccessError): + self.env["project.task.template"].with_user(self.project_user).create( + {"name": "New Template"} + ) + with self.assertRaises(AccessError): + template.with_user(self.project_user).unlink() + + def test_template_copy(self): + """Test copying a template with subtasks""" + parent_template = self.env["project.task.template"].create( + { + "name": "Parent Template", + "allocated_hours": 10.0, + } + ) + self.env["project.task.template"].create( + { + "name": "Child Template", + "allocated_hours": 5.0, + "parent_id": parent_template.id, + } + ) + + copied = parent_template.copy() + + self.assertEqual(len(copied.subtask_ids), len(parent_template.subtask_ids)) + self.assertNotEqual(copied.id, parent_template.id) + self.assertIn("(copy)", copied.name) + self.assertEqual(copied.allocated_hours, parent_template.allocated_hours) + + def test_template_archive(self): + """Test archiving templates""" + template = self.env["project.task.template"].create( + { + "name": "Test Template", + } + ) + + template.active = False + self.assertFalse(template.active) + + # Should still be able to find it with inactive filter + archived_template = ( + self.env["project.task.template"] + .with_context(active_test=False) + .search([("id", "=", template.id)]) + ) + self.assertTrue(archived_template) diff --git a/project_task_template/views/project_menus.xml b/project_task_template/views/project_menus.xml new file mode 100644 index 0000000..eae7722 --- /dev/null +++ b/project_task_template/views/project_menus.xml @@ -0,0 +1,9 @@ + + + + + diff --git a/project_task_template/views/project_task_template_views.xml b/project_task_template/views/project_task_template_views.xml new file mode 100644 index 0000000..ae87e4f --- /dev/null +++ b/project_task_template/views/project_task_template_views.xml @@ -0,0 +1,112 @@ + + + + + project.task.template.search + project.task.template + + + + + + + + + + + + + + + + + + + + project.task.template.tree + project.task.template + + + + + + + + + + + + + + + + project.task.template.form + project.task.template + +
+
+ +
+ +
+ +
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + +
+
+ + + + Task Templates + project.task.template + tree,form + + +

+ Create your first task template! +

+

+ Task templates help you quickly create pre-configured tasks with predefined properties. +

+
+
+
diff --git a/sale_project_task_template/__init__.py b/sale_project_task_template/__init__.py new file mode 100644 index 0000000..0650744 --- /dev/null +++ b/sale_project_task_template/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/sale_project_task_template/__manifest__.py b/sale_project_task_template/__manifest__.py new file mode 100644 index 0000000..a6dff45 --- /dev/null +++ b/sale_project_task_template/__manifest__.py @@ -0,0 +1,34 @@ +{ + "name": "Sales Project Task Templates", + "version": "17.0.1.0.0", + "summary": "Link products to task templates for automatic task creation from sales", + "description": """ +This module extends project task templates to work with sales orders: + +Features: +* Link task templates to service products +* Automatically create tasks from templates when confirming sales orders +* Support for customer-specific task templates +* Inherit customer from template or sale order +* Maintain proper task-sale relationships for billing + +Technical Details: +* Extends project.task.template to add customer support +* Integrates with sale_project for proper task creation and linking +* Inherits partner handling logic from sale_project +""", + "author": "Durpro", + "website": "https://durpro.com", + "category": "Services/Project", + "depends": [ + "project_task_template", + "sale_project", + ], + "data": [ + "views/product_views.xml", + "views/project_task_template_views.xml", + ], + "license": "LGPL-3", + "installable": True, + "auto_install": True, # Auto-install if project_task_template and sale_project are installed +} diff --git a/sale_project_task_template/models/__init__.py b/sale_project_task_template/models/__init__.py new file mode 100644 index 0000000..4caa852 --- /dev/null +++ b/sale_project_task_template/models/__init__.py @@ -0,0 +1,3 @@ +from . import product_template +from . import project_task_template +from . import sale_order_line diff --git a/sale_project_task_template/models/product_template.py b/sale_project_task_template/models/product_template.py new file mode 100644 index 0000000..bd0251d --- /dev/null +++ b/sale_project_task_template/models/product_template.py @@ -0,0 +1,20 @@ +from odoo import fields, models + + +class ProductTemplate(models.Model): + _inherit = "product.template" + + task_template_id = fields.Many2one( + comodel_name="project.task.template", + string="Service Task Template", + help="Task template that will be used to generate project tasks when " + "this service product is sold.", + domain="[('parent_id', '=', False)]", # Only allow top-level templates + ) + + def _compute_service_policy(self): + # Ensure task templates are only used with project tracked services + super()._compute_service_policy() + for product in self: + if product.task_template_id and not product.service_tracking: + product.task_template_id = False diff --git a/sale_project_task_template/models/project_task_template.py b/sale_project_task_template/models/project_task_template.py new file mode 100644 index 0000000..0197cc9 --- /dev/null +++ b/sale_project_task_template/models/project_task_template.py @@ -0,0 +1,13 @@ +from odoo import fields, models + + +class ProjectTaskTemplate(models.Model): + _inherit = "project.task.template" + + partner_id = fields.Many2one( + "res.partner", + string="Customer", + tracking=True, + help="Customer this task template is intended for. When creating tasks from a sale order, " + "this will default to the sale order's customer if not set.", + ) diff --git a/sale_project_task_template/models/sale_order_line.py b/sale_project_task_template/models/sale_order_line.py new file mode 100644 index 0000000..eb4ca28 --- /dev/null +++ b/sale_project_task_template/models/sale_order_line.py @@ -0,0 +1,49 @@ +from odoo import _, api, models + + +class SaleOrderLine(models.Model): + _inherit = "sale.order.line" + + def _timesheet_create_task(self, project): + """Override to use template if available, otherwise fall back to standard creation.""" + if not self.product_id.task_template_id: + return super()._timesheet_create_task(project) + + template = self.product_id.task_template_id + task = template.create_task_from_self( + project=project, + name=f"{self.order_id.name}: {template.name}", + ) + # If template has a partner, keep it, otherwise use SO partner + if not task.partner_id: + task.partner_id = self.order_id.partner_id.id + self.write({"task_id": task.id}) + # Post message on task + task_msg = _( + "This task has been created from: %s (%s)", + self.order_id._get_html_link(), + self.product_id.name, + ) + task.message_post(body=task_msg) + return task + + def _timesheet_create_task_prepare_values(self, project): + """Override to use template values if available.""" + values = super()._timesheet_create_task_prepare_values(project) + if self.product_id.task_template_id: + template = self.product_id.task_template_id + values.update( + { + "description": template.description + or values.get("description", ""), + "allocated_hours": template.allocated_hours + or values.get("allocated_hours", 0.0), + "user_ids": ( + [(6, 0, template.assignee_ids.ids)] + if template.assignee_ids + else values.get("user_ids", False) + ), + "partner_id": template.partner_id.id if template.partner_id else values.get("partner_id", False), + } + ) + return values diff --git a/sale_project_task_template/tests/__init__.py b/sale_project_task_template/tests/__init__.py new file mode 100644 index 0000000..e8e0590 --- /dev/null +++ b/sale_project_task_template/tests/__init__.py @@ -0,0 +1 @@ +from . import test_sale_project_task_template diff --git a/sale_project_task_template/tests/test_sale_project_task_template.py b/sale_project_task_template/tests/test_sale_project_task_template.py new file mode 100644 index 0000000..a3021e1 --- /dev/null +++ b/sale_project_task_template/tests/test_sale_project_task_template.py @@ -0,0 +1,270 @@ +from odoo import _ +from odoo.addons.sale_project.tests.common import TestSaleProjectCommon +from odoo.tests import tagged, new_test_user + + +@tagged("post_install", "-at_install") +class TestSaleProjectTaskTemplate(TestSaleProjectCommon): + @classmethod + def setUpClass(cls): + super().setUpClass() + + # Create users + user_group_project_user = cls.env.ref("project.group_project_user") + cls.user_projectuser = new_test_user( + cls.env, + login="Armande", + name="Armande Project", + email="armande.project@test.example.com", + groups="base.group_user,project.group_project_user", + ) + + # Create task template + cls.task_template = cls.env["project.task.template"].create( + { + "name": "Test Template", + "description": "Test Description", + "allocated_hours": 8, + "assignee_ids": [(4, cls.user_projectuser.id)], + } + ) + + # Create service product with template + cls.product_template = cls.env["product.product"].create( + { + "name": "Service with Template", + "type": "service", + "service_type": "manual", # Valid values: manual, milestones + "service_tracking": "task_global_project", + "project_id": cls.project_global.id, + "task_template_id": cls.task_template.id, + } + ) + + # Create sale order + cls.sale_order = cls.env["sale.order"].create( + { + "partner_id": cls.partner_a.id, + "order_line": [ + ( + 0, + 0, + { + "name": cls.product_template.name, + "product_id": cls.product_template.id, + "product_uom_qty": 1, + "product_uom": cls.product_template.uom_id.id, + "price_unit": 15, + }, + ) + ], + } + ) + + def test_create_task_from_template(self): + """Test task creation from template when confirming SO.""" + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner_a.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_template.id, + "product_uom_qty": 1, + }, + ) + ], + } + ) + sale_order.action_confirm() + + # Check main task + task = self.env["project.task"].search( + [("sale_line_id", "=", sale_order.order_line.id)] + ) + self.assertTrue(task, "Task should be created from template") + self.assertEqual(task.name, f"{sale_order.name}: {self.task_template.name}") + self.assertEqual(task.description, self.task_template.description) + self.assertEqual(task.allocated_hours, self.task_template.allocated_hours) + self.assertEqual(task.user_ids, self.task_template.assignee_ids) + self.assertEqual(task.project_id, self.project_global) + self.assertEqual(task.sale_line_id, sale_order.order_line) + + # Check subtask + subtask = self.env["project.task"].search([("parent_id", "=", task.id)]) + self.assertTrue(subtask, "Subtask should be created from template") + self.assertEqual(subtask.name, self.task_template.name) + self.assertEqual(subtask.description, self.task_template.description) + self.assertEqual(subtask.allocated_hours, self.task_template.allocated_hours) + self.assertEqual(subtask.user_ids, self.task_template.assignee_ids) + self.assertEqual(subtask.project_id, self.project_global) + # Subtask should inherit sale_line_id as they share the same partner + self.assertEqual(subtask.sale_line_id, task.sale_line_id) + + def test_template_partner_inheritance(self): + """Test that task inherits partner from template if set.""" + # Create a different partner for the template + template_partner = self.env["res.partner"].create({"name": "Template Partner"}) + self.task_template.write({"partner_id": template_partner.id}) + + # Create sale order with main partner + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner_a.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_template.id, + "product_uom_qty": 1, + }, + ) + ], + } + ) + + # Confirm sale order to create task + sale_order.action_confirm() + task = self.env["project.task"].search( + [("sale_line_id", "=", sale_order.order_line.id)] + ) + self.assertTrue(task, "Task should be created from sale order line") + self.assertEqual( + task.partner_id, + template_partner, + "Task should inherit partner from template when set", + ) + + # Create another template without partner + template_no_partner = self.env["project.task.template"].create( + { + "name": "Template Without Partner", + "description": "Test Description", + "allocated_hours": 8, + "assignee_ids": [(4, self.user_projectuser.id)], + } + ) + product_no_partner = self.env["product.product"].create( + { + "name": "Service with Template No Partner", + "type": "service", + "service_type": "manual", + "service_tracking": "task_global_project", + "project_id": self.project_global.id, + "task_template_id": template_no_partner.id, + } + ) + + # Add another line with template without partner + sale_order.write({ + "order_line": [(0, 0, { + "product_id": product_no_partner.id, + "product_uom_qty": 1, + })] + }) + sale_order.action_confirm() + + task_no_template_partner = self.env["project.task"].search( + [("sale_line_id", "=", sale_order.order_line[-1].id)] + ) + self.assertEqual( + task_no_template_partner.partner_id, + self.partner_a, + "Task should inherit partner from SO when template has no partner", + ) + + def test_standard_task_creation_without_template(self): + """Test that standard task creation works when no template is set.""" + # Create a service product without template + product = self.env["product.product"].create( + { + "name": "Service without Template", + "type": "service", + "service_type": "manual", # Valid values: manual, milestones + "service_tracking": "task_global_project", + "project_id": self.project_global.id, + } + ) + + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner_a.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": product.id, + "product_uom_qty": 1, + "name": "Service Line", + }, + ) + ], + } + ) + sale_order.action_confirm() + + # Check that task is created using standard method + task = self.env["project.task"].search( + [("sale_line_id", "=", sale_order.order_line.id)] + ) + self.assertTrue(task, "Task should be created using standard method") + # Verify standard task creation values + self.assertEqual(task.name, f"{sale_order.name} - Service Line") + self.assertEqual(task.project_id, self.project_global) + self.assertEqual(task.partner_id, self.partner_a) + self.assertEqual(task.sale_line_id, sale_order.order_line) + + def test_subtask_different_partner(self): + """Test subtask behavior when partners differ.""" + # Create a different partner + other_partner = self.env["res.partner"].create({"name": "Other Partner"}) + + # Create sale order with main partner + sale_order = self.env["sale.order"].create( + { + "partner_id": self.partner_a.id, + "order_line": [ + ( + 0, + 0, + { + "product_id": self.product_template.id, + "product_uom_qty": 1, + }, + ) + ], + } + ) + + # Confirm sale order to create task + sale_order.action_confirm() + task = self.env["project.task"].search( + [("sale_line_id", "=", sale_order.order_line.id)] + ) + self.assertTrue(task, "Task should be created from sale order line") + self.assertEqual( + task.partner_id, self.partner_a, "Task should have SO customer as partner" + ) + + # Create subtask with different partner + subtask = self.env["project.task"].create( + { + "name": "Subtask", + "parent_id": task.id, + "partner_id": other_partner.id, + "project_id": task.project_id.id, + } + ) + + # Check that subtask doesn't inherit sale_line_id since partner is different + self.assertNotEqual( + subtask.partner_id, task.partner_id, "Subtask should have different partner" + ) + self.assertFalse( + subtask.sale_line_id, + "Subtask with different partner should not get sale_line_id", + ) diff --git a/sale_project_task_template/views/product_views.xml b/sale_project_task_template/views/product_views.xml new file mode 100644 index 0000000..83175e2 --- /dev/null +++ b/sale_project_task_template/views/product_views.xml @@ -0,0 +1,16 @@ + + + + product.template.form.inherit.sale.project.task.template + product.template + + + + + + + + diff --git a/sale_project_task_template/views/project_task_template_views.xml b/sale_project_task_template/views/project_task_template_views.xml new file mode 100644 index 0000000..b1e336e --- /dev/null +++ b/sale_project_task_template/views/project_task_template_views.xml @@ -0,0 +1,13 @@ + + + + project.task.template.form.inherit.sale + project.task.template + + + + + + + +