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",
|
"name": "FSM Visit Confirmation",
|
||||||
"version": "17.0.0.1.0",
|
"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",
|
"category": "Services/Field Service",
|
||||||
"author": "Bemade Inc.",
|
"author": "Bemade Inc.",
|
||||||
"website": "http://www.bemade.org",
|
"website": "http://www.bemade.org",
|
||||||
"license": "LGPL-3",
|
"license": "LGPL-3",
|
||||||
"depends": ["industry_fsm"],
|
"depends": ["industry_fsm", "bemade_fsm", "rating"],
|
||||||
"data": ["data/mail_templates.xml"],
|
"data": ["data/mail_templates.xml"],
|
||||||
"assets": {},
|
|
||||||
"installable": True,
|
"installable": True,
|
||||||
"auto_install": False,
|
"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" ?>
|
<?xml version="1.0" encoding="UTF-8" ?>
|
||||||
<odoo>
|
<odoo>
|
||||||
<record id="request_fsm_task_date_confirmation" model="mail.template">
|
<record id="rating_project_request_email_template" model="mail.template">
|
||||||
<field name="name">Field Service Visit Confirmation Request</field>
|
<field name="name">Field Service Task: Rating Request</field>
|
||||||
<field name="model_id" ref="project.model_project_task"/>
|
<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="subject">Please confirm your upcoming service visit with {{ object.company_id.name }}</field>
|
||||||
<field name="email_from">{{ object.company_id.email_formatted }}</field>
|
<field name="email_from">{{ user.email_formatted }}</field>
|
||||||
<field name="body_html">
|
<field name="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
|
||||||
<table class="table table-striped">
|
<field name="description">Sent to request a client rating/confirmation for a field service task</field>
|
||||||
<tbody>
|
<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>
|
<tr>
|
||||||
<td scope="row">Summary</td>
|
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
|
||||||
<td>{{ object.name }}</td>
|
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">{{ object.name }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td scope="row">Technician Arrival</td>
|
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
|
||||||
<td>{{ object.planned_date_begin }}</td>
|
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">{{ object.planned_date_begin }}</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td scope="row">Intervention address</td>
|
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
|
||||||
<td>
|
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
|
||||||
<span t-esc="object.partner_id.street"/><br/>
|
{{ object.partner_id.street }}<br/>
|
||||||
<span t-if="object.partner_id.street2" t-esc="object.partner_id.street2"/><br/>
|
{% if object.partner_id.street2 %}
|
||||||
<span t-esc="object.partner_id.city"/><br/>
|
{{ object.partner_id.street2 }}<br/>
|
||||||
<span t-esc="object.partner_id.state"/><br/>
|
{% endif %}
|
||||||
<span t-esc="object.partner_id.zip"/>
|
{{ object.partner_id.city }}, {{ object.partner_id.state_id.code }} {{ object.partner_id.zip }}
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td scope="row">
|
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
|
||||||
Assigned Technician(s):
|
<td style="padding:10px;">
|
||||||
</td>
|
{% for user in object.user_ids %}
|
||||||
<td>
|
{{ user.name }}{% if not loop.last %}<br/>
|
||||||
<t t-foreach="object.user_ids" t-as="technician">
|
{% endif %}
|
||||||
<t t-esc="technician.name"/><br/>
|
{% endfor %}
|
||||||
</t>
|
</td>
|
||||||
</td>
|
</tr>
|
||||||
</tr>
|
|
||||||
</tbody>
|
|
||||||
</table>
|
</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>
|
<div style="margin: 16px 0px 16px 0px;">
|
||||||
<p>If you would like to propose another time for this visit, please reply to this email.</p>
|
Please take a moment to confirm this visit by clicking one of the following options:
|
||||||
<p>Best regards,</p>
|
</div>
|
||||||
<p>The {{ object.company_id.name }} service management team.</p>
|
<table style="width:100%;border-spacing:0;">
|
||||||
</field>
|
<tr>
|
||||||
</record>
|
<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>
|
</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