new module fsm_visit_confirmation

This commit is contained in:
Marc Durepos 2025-02-26 10:12:33 -05:00
parent 65fbcb9b33
commit f9f32aab34
29 changed files with 1218 additions and 88 deletions

View file

@ -1,2 +0,0 @@
from . import controllers
from . import models

View file

@ -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,
}

View file

@ -1 +0,0 @@
from . import portal

View file

@ -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/<int:task_id>",
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

View file

@ -1,47 +1,73 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<record id="request_fsm_task_date_confirmation" model="mail.template">
<field name="name">Field Service Visit Confirmation Request</field>
<record id="rating_project_request_email_template" model="mail.template">
<field name="name">Field Service Task: Rating Request</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="subject">Please confirm the date for your upcoming Durpro service visit</field>
<field name="email_from">{{ object.company_id.email_formatted }}</field>
<field name="body_html">
<table class="table table-striped">
<tbody>
<field name="subject">Please confirm your upcoming service visit with {{ object.company_id.name }}</field>
<field name="email_from">{{ user.email_formatted }}</field>
<field name="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
<field name="description">Sent to request a client rating/confirmation for a field service task</field>
<field name="body_html" type="html">
<div>
<div style="margin-bottom: 16px;">
Dear {{ object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name }},
</div>
<div style="margin-bottom: 16px;">
We would like to confirm the details of your upcoming service visit:
</div>
<table style="width:100%;border-spacing:0;border:1px solid #e7e7e7;">
<tr>
<td scope="row">Summary</td>
<td>{{ object.name }}</td>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">{{ object.name }}</td>
</tr>
<tr>
<td scope="row">Technician Arrival</td>
<td>{{ object.planned_date_begin }}</td>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">{{ object.planned_date_begin }}</td>
</tr>
<tr>
<td scope="row">Intervention address</td>
<td>
<span t-esc="object.partner_id.street"/><br/>
<span t-if="object.partner_id.street2" t-esc="object.partner_id.street2"/><br/>
<span t-esc="object.partner_id.city"/><br/>
<span t-esc="object.partner_id.state"/><br/>
<span t-esc="object.partner_id.zip"/>
</td>
</tr>
<tr>
<td scope="row">
Assigned Technician(s):
</td>
<td>
<t t-foreach="object.user_ids" t-as="technician">
<t t-esc="technician.name"/><br/>
</t>
</td>
</tr>
</tbody>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
{{ object.partner_id.street }}<br/>
{% if object.partner_id.street2 %}
{{ object.partner_id.street2 }}<br/>
{% endif %}
{{ object.partner_id.city }}, {{ object.partner_id.state_id.code }} {{ object.partner_id.zip }}
</td>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
<td style="padding:10px;">
{% for user in object.user_ids %}
{{ user.name }}{% if not loop.last %}<br/>
{% endif %}
{% endfor %}
</td>
</tr>
</table>
<p>To confirm this visit, <a href="{{ user.company_id.website }}/my/tasks/approve_booking/?task_id={{ object.id }}&amp;access_token={{ object.access_token}}">click here</a>.</p>
<p>If you would like to propose another time for this visit, please reply to this email.</p>
<p>Best regards,</p>
<p>The {{ object.company_id.name }} service management team.</p>
</field>
</record>
<div style="margin: 16px 0px 16px 0px;">
Please take a moment to confirm this visit by clicking one of the following options:
</div>
<table style="width:100%;border-spacing:0;">
<tr>
<td style="padding:10px;text-align:center;">
<a t-attf-href="/rate/{{ object._rating_get_access_token(partner=object.work_order_contacts and object.work_order_contacts[0] or object.partner_id) }}/5" style="background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Confirm Visit
</a>
</td>
<td style="padding:10px;text-align:center;">
<a t-attf-href="/rate/{{ object._rating_get_access_token(partner=object.work_order_contacts and object.work_order_contacts[0] or object.partner_id) }}/1" style="background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Request Changes
</a>
</td>
</tr>
</table>
<div style="margin-top: 16px;">
<p>Best regards,</p>
<p>The {{ object.company_id.name }} service management team</p>
</div>
</div>
</field>
<field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</odoo>

View file

@ -1 +0,0 @@
from . import project_task

View file

@ -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"

View file

@ -0,0 +1 @@
from . import test_fsm_visit_confirmation

View file

@ -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",
)

View file

@ -0,0 +1 @@
from . import models

View file

@ -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,
}

View file

@ -0,0 +1,2 @@
from . import project_task_template
from . import project_task

View file

@ -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,
)

View file

@ -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

View file

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_project_task_template_manager project.task.template.manager model_project_task_template project.group_project_manager 1 1 1 1
3 access_project_task_template_user project.task.template.user model_project_task_template project.group_project_user 1 0 0 0

View file

@ -0,0 +1 @@
from . import test_project_task_template

View file

@ -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)

View file

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Add Task Templates menu under Project Configuration -->
<menuitem id="menu_project_task_template"
name="Task Templates"
parent="project.menu_project_config"
action="action_project_task_template"
sequence="100"/>
</odoo>

View file

@ -0,0 +1,112 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Search View -->
<record id="project_task_template.view_project_task_template_search" model="ir.ui.view">
<field name="name">project.task.template.search</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<search string="Task Templates">
<field name="name"/>
<field name="project_id"/>
<field name="assignee_ids" widget="many2many_tags"/>
<field name="tag_ids" widget="many2many_tags"/>
<field name="company_id" groups="base.group_multi_company"/>
<separator/>
<filter string="Archived" name="inactive" domain="[('active', '=', False)]"/>
<group expand="0" string="Group By">
<filter string="Project" name="group_by_project" domain="[]" context="{'group_by': 'project_id'}"/>
<filter string="Company" name="group_by_company" domain="[]" context="{'group_by': 'company_id'}" groups="base.group_multi_company"/>
</group>
</search>
</field>
</record>
<!-- List View -->
<record id="project_task_template.view_project_task_template_tree" model="ir.ui.view">
<field name="name">project.task.template.tree</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<tree string="Task Templates">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="project_id"/>
<field name="assignee_ids" widget="many2many_tags"/>
<field name="tag_ids" widget="many2many_tags"/>
<field name="allocated_hours" widget="float_time"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<!-- Form View -->
<record id="project_task_template.view_project_task_template_form" model="ir.ui.view">
<field name="name">project.task.template.form</field>
<field name="model">project.task.template</field>
<field name="arch" type="xml">
<form string="Task Template">
<header>
<field name="active" widget="boolean_button" options='{"terminology": "archive"}'/>
</header>
<sheet>
<div class="oe_button_box" name="button_box">
<button class="oe_stat_button" type="object" name="action_view_tasks"
icon="fa-tasks">
<field string="Tasks" name="task_count" widget="statinfo"/>
</button>
</div>
<div class="oe_title">
<h1>
<field name="name" placeholder="Task Title"/>
</h1>
</div>
<group>
<group>
<field name="project_id"/>
<field name="parent_id"/>
<field name="allocated_hours" widget="float_time"/>
<field name="sequence"/>
</group>
<group>
<field name="assignee_ids" widget="many2many_tags"/>
<field name="tag_ids" widget="many2many_tags"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<notebook>
<page string="Description" name="description">
<field name="description" placeholder="Add a description..."/>
</page>
<page string="Subtasks" name="subtasks">
<field name="subtask_ids" context="{'default_parent_id': active_id}">
<tree editable="bottom">
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="allocated_hours" widget="float_time"/>
<field name="assignee_ids" widget="many2many_tags"/>
<field name="tag_ids" widget="many2many_tags"/>
</tree>
</field>
</page>
</notebook>
</sheet>
<chatter/>
</form>
</field>
</record>
<!-- Action -->
<record id="project_task_template.action_project_task_template" model="ir.actions.act_window">
<field name="name">Task Templates</field>
<field name="res_model">project.task.template</field>
<field name="view_mode">tree,form</field>
<field name="search_view_id" ref="project_task_template.view_project_task_template_search"/>
<field name="help" type="html">
<p class="o_view_nocontent_smiling_face">
Create your first task template!
</p>
<p>
Task templates help you quickly create pre-configured tasks with predefined properties.
</p>
</field>
</record>
</odoo>

View file

@ -0,0 +1 @@
from . import models

View file

@ -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
}

View file

@ -0,0 +1,3 @@
from . import product_template
from . import project_task_template
from . import sale_order_line

View file

@ -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

View file

@ -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.",
)

View file

@ -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

View file

@ -0,0 +1 @@
from . import test_sale_project_task_template

View file

@ -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",
)

View file

@ -0,0 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="product_template_form_view" model="ir.ui.view">
<field name="name">product.template.form.inherit.sale.project.task.template</field>
<field name="model">product.template</field>
<field name="inherit_id" ref="sale.product_template_form_view"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='service_tracking']" position="after">
<field name="task_template_id"
invisible="type != 'service' or service_tracking not in ['task_global_project', 'task_in_project']"
options="{'no_create': True}"
context="{'default_project_id': project_id}"/>
</xpath>
</field>
</record>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_task_template_form_inherit_sale" model="ir.ui.view">
<field name="name">project.task.template.form.inherit.sale</field>
<field name="model">project.task.template</field>
<field name="inherit_id" ref="project_task_template.view_project_task_template_form"/>
<field name="arch" type="xml">
<field name="company_id" position="before">
<field name="partner_id"/>
</field>
</field>
</record>
</odoo>