new module fsm_visit_confirmation
This commit is contained in:
parent
65fbcb9b33
commit
f9f32aab34
29 changed files with 1218 additions and 88 deletions
|
|
@ -1,2 +0,0 @@
|
|||
from . import controllers
|
||||
from . import models
|
||||
|
|
@ -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,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1 +0,0 @@
|
|||
from . import portal
|
||||
|
|
@ -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
|
||||
|
|
@ -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 }}&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>
|
||||
|
|
@ -1 +0,0 @@
|
|||
from . import project_task
|
||||
|
|
@ -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"
|
||||
1
fsm_visit_confirmation/tests/__init__.py
Normal file
1
fsm_visit_confirmation/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_fsm_visit_confirmation
|
||||
232
fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py
Normal file
232
fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py
Normal 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",
|
||||
)
|
||||
1
project_task_template/__init__.py
Normal file
1
project_task_template/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
45
project_task_template/__manifest__.py
Normal file
45
project_task_template/__manifest__.py
Normal 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,
|
||||
}
|
||||
2
project_task_template/models/__init__.py
Normal file
2
project_task_template/models/__init__.py
Normal file
|
|
@ -0,0 +1,2 @@
|
|||
from . import project_task_template
|
||||
from . import project_task
|
||||
13
project_task_template/models/project_task.py
Normal file
13
project_task_template/models/project_task.py
Normal 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,
|
||||
)
|
||||
162
project_task_template/models/project_task_template.py
Normal file
162
project_task_template/models/project_task_template.py
Normal 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
|
||||
3
project_task_template/security/ir.model.access.csv
Normal file
3
project_task_template/security/ir.model.access.csv
Normal 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
project_task_template/tests/__init__.py
Normal file
1
project_task_template/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_project_task_template
|
||||
134
project_task_template/tests/test_project_task_template.py
Normal file
134
project_task_template/tests/test_project_task_template.py
Normal 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)
|
||||
9
project_task_template/views/project_menus.xml
Normal file
9
project_task_template/views/project_menus.xml
Normal 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>
|
||||
112
project_task_template/views/project_task_template_views.xml
Normal file
112
project_task_template/views/project_task_template_views.xml
Normal 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>
|
||||
1
sale_project_task_template/__init__.py
Normal file
1
sale_project_task_template/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import models
|
||||
34
sale_project_task_template/__manifest__.py
Normal file
34
sale_project_task_template/__manifest__.py
Normal 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
|
||||
}
|
||||
3
sale_project_task_template/models/__init__.py
Normal file
3
sale_project_task_template/models/__init__.py
Normal file
|
|
@ -0,0 +1,3 @@
|
|||
from . import product_template
|
||||
from . import project_task_template
|
||||
from . import sale_order_line
|
||||
20
sale_project_task_template/models/product_template.py
Normal file
20
sale_project_task_template/models/product_template.py
Normal 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
|
||||
13
sale_project_task_template/models/project_task_template.py
Normal file
13
sale_project_task_template/models/project_task_template.py
Normal 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.",
|
||||
)
|
||||
49
sale_project_task_template/models/sale_order_line.py
Normal file
49
sale_project_task_template/models/sale_order_line.py
Normal 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
|
||||
1
sale_project_task_template/tests/__init__.py
Normal file
1
sale_project_task_template/tests/__init__.py
Normal file
|
|
@ -0,0 +1 @@
|
|||
from . import test_sale_project_task_template
|
||||
|
|
@ -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",
|
||||
)
|
||||
16
sale_project_task_template/views/product_views.xml
Normal file
16
sale_project_task_template/views/product_views.xml
Normal 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>
|
||||
|
|
@ -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>
|
||||
Loading…
Reference in a new issue