[MIG] fsm_visit_confirmation to 18.0

This commit is contained in:
Marc Durepos 2025-09-09 14:38:36 -04:00
parent 5793351bbe
commit ca493863cb
14 changed files with 1284 additions and 0 deletions

View file

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

View file

@ -0,0 +1,54 @@
#
# Bemade Inc.
#
# Copyright (C) 2023-June Bemade Inc. (<https://www.bemade.org>).
# Author: Marc Durepos (Contact : marc@bemade.org)
#
# This program is under the terms of the GNU Lesser General Public License,
# version 3.
#
# For full license details, see https://www.gnu.org/licenses/lgpl-3.0.en.html.
#
# THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
# IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
# FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT.
# IN NO EVENT SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM,
# DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE,
# ARISING FROM, OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
# DEALINGS IN THE SOFTWARE.
#
{
"name": "FSM Visit Confirmation",
"version": "18.0.0.1.0",
"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", "bemade_fsm", "rating", "http_routing"],
"data": [
"data/mail_templates.xml",
"views/project_portal_project_task_templates.xml",
"views/project_task_type_views.xml",
],
"installable": True,
"auto_install": False,
"application": False,
}

View file

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

View file

@ -0,0 +1,270 @@
# -*- coding: utf-8 -*-
import logging
import werkzeug
from odoo import http, _
from odoo.http import request
from odoo.addons.base.models.ir_qweb import keep_query
from odoo.addons.portal.controllers.portal import CustomerPortal
from odoo.exceptions import AccessError, MissingError
_logger = logging.getLogger(__name__)
class CustomerPortalExtended(CustomerPortal):
"""Extend the customer portal controller to handle FSM visit confirmations."""
@http.route("/my/task/<int:task_id>", type="http", auth="public", website=True)
def portal_my_task(self, task_id, access_token=None, **kw):
"""Override to allow access with token for public users."""
try:
task = request.env["project.task"].browse(task_id)
task.check_access_rights("read")
task.check_access_rights("write")
task.check_access_rule("read")
task.check_access_rule("write")
except AccessError:
task = None
values = self._get_portal_values(task, access_token=access_token, **kw)
if not task:
task = values.get("task", False)
if not task:
return self._render_error_page(
_("Task not found or invalid token"), task=None
)
# Pass the task to the original method
result = super(CustomerPortalExtended, self).portal_my_task(
task_id, access_token=access_token, **kw
)
# If the result is a Response object (like a redirect), return it directly
if isinstance(result, werkzeug.wrappers.Response):
return result
# Otherwise, it's a rendered template, so we need to update the qcontext
if hasattr(result, "qcontext"):
result.qcontext.update(values)
return result
@http.route(
"/my/fsm_confirmation/<string:action>",
type="http",
auth="public",
website=True,
)
def fsm_confirmation_action(self, action, **kwargs):
"""Custom landing page for FSM visit confirmations.
Args:
action: Either 'approve' or 'change'
Kwargs:
access_token: The access token for the task
"""
values = self._get_portal_values(**kwargs)
task = values.get("task", False)
if not task:
return self._render_error_page(
_("Task not found or invalid token"), task=None
)
# Store the language in kwargs for use in redirects
lang = values.get("lang")
if action not in ("approve", "change"):
raise werkzeug.exceptions.BadRequest(_("Invalid action"))
# If it's an approval, update the task state directly
if action == "approve":
try:
# Update task state to approved
task.write({"state": "03_approved"})
# Add a message to the chatter
task.message_post(
body=_("Visit approved by customer"),
message_type="comment",
subtype_xmlid="mail.mt_note",
)
# Always redirect to the task portal page with success message
redirect_url = "/my/task/%s?%s" % (
task.id,
keep_query(
"access_token",
visit_confirmation_status="approved",
lang=lang,
),
)
return request.redirect(redirect_url)
except Exception as e:
_logger.exception(_("Error approving task: %s"), e)
return self._render_error_page(
_("Error approving task: %s") % str(e), task=task
)
# For change requests, redirect to the task portal page with change request form
redirect_url = "/my/task/%s?%s" % (
task.id,
keep_query(
"access_token",
visit_confirmation_status="change",
lang=lang,
),
)
return request.redirect(redirect_url)
@http.route(
"/my/fsm_confirmation/submit_change",
type="http",
auth="public",
methods=["post"],
website=True,
csrf=True,
)
def fsm_confirmation_submit_change(self, **kwargs):
"""Handle the submission of change request form."""
values = self._get_portal_values(**kwargs)
task = values.get("task", False)
lang = values.get("lang")
feedback = kwargs.get("feedback", "")
if not task:
return self._render_error_page(
_("Task not found or invalid token"), task=None
)
if not feedback:
return self._render_error_page(
_("No feedback provided. Please try again!"), task=task
)
try:
# Update task state to changes requested
task.write({"state": "02_changes_requested"})
# Add the feedback as a message to the chatter
task.message_post(
body=_("Changes requested by customer: %s") % feedback,
message_type="comment",
subtype_xmlid="mail.mt_comment",
)
# Always redirect to the task portal page with success message
redirect_url = "/my/task/%s?%s" % (
task.id,
keep_query(
access_token=kwargs.get("access_token"),
visit_confirmation_status="change_submitted",
lang=lang,
),
)
return request.redirect(redirect_url)
except Exception as e:
_logger.exception(_("Error submitting change request: %s"), e)
return self._render_error_page(
_("Error submitting change request: %s") % str(e), task=task
)
def _get_task_by_token(self, token):
"""Get a task by its access token."""
# First try to find the task directly by access token
task = (
request.env["project.task"]
.sudo()
.search([("access_token", "=", token)], limit=1)
)
if task:
return task
# If not found, try to find it through the rating token
# This is for backward compatibility with existing tokens in emails
rating = (
request.env["rating.rating"]
.sudo()
.search([("access_token", "=", token)], limit=1)
)
if rating and rating.res_model == "project.task":
task = request.env["project.task"].sudo().browse(rating.res_id)
if task.exists():
return task
return None
def _render_error_page(self, error_message, task=None, **kwargs):
"""Render an error page."""
values = {
"status_code": "400",
"status_message": error_message,
"lang": self._get_lang(task=task, lang=kwargs.get("lang")),
}
return request.render("http_routing.http_error", values)
def _get_portal_values(self, task=None, **kwargs):
"""Get common values for portal templates.
Args:
task: The task record if already loaded
**kwargs: Keyword arguments from the request
Returns:
Dictionary with values for template rendering
"""
values = {"page_name": "task"}
if not task:
access_token = kwargs.get("access_token")
if access_token:
task = self._get_task_by_token(access_token)
if task:
values["task"] = task
# Set language and update values dictionary
values["lang"] = self._get_lang(task=task, lang=kwargs.get("lang"))
# Add any additional parameters from kwargs that might be needed in templates
for key in ["visit_confirmation_status", "error", "warning", "success"]:
if kwargs.get(key):
values[key] = kwargs.get(key)
return values
def _get_lang(self, task=None, lang=None):
"""Helper method to set the language in the request environment.
Args:
task: The task record (optional)
lang: Language code (e.g., 'en_US', 'fr_FR') (optional)
Returns:
The language code that was set, or None if no language was set
"""
# If lang is not provided, try to get it from the task
if not lang and task:
lang_partner = (
task.work_order_contacts
and task.work_order_contacts[0]
or task.partner_id
)
if lang_partner and lang_partner.lang:
lang = lang_partner.lang
if not lang:
return None
# Get the language record
lang_code = lang.replace("_", "-")
lang_id = (
request.env["res.lang"]
.sudo()
.search(["|", ("code", "=", lang_code), ("code", "=", lang)], limit=1)
)
if lang_id:
# Set the context language
request.env = request.env(
context=dict(request.env.context, lang=lang_id.code)
)
return lang_id.code
return None

View file

@ -0,0 +1,93 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="fsm_visit_confirmation_email_template" model="mail.template">
<field name="name">FSM Visit Confirmation</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="subject">Service Visit Confirmation: {{ object.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">Send a visit confirmation email to the customer</field>
<field name="body_html" type="html">
<div>
<div style="margin-bottom: 16px;">
Dear <t t-out="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 style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-out="object.name"/>
</td>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-set="partner" t-value="object.work_order_contacts and object.work_order_contacts[0] or object.partner_id"/>
<t t-set="partner_tz" t-value="user.tz or object.company_id.partner_id.tz or 'America/Toronto'"/>
<span t-field="object.planned_date_begin" t-options="{'widget': 'datetime', 'timezone': partner_tz}"/>
<div style="font-size: 12px; color: #666;">
(Timezone: <t t-out="partner_tz"/>
)
</div>
</td>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-out="object.partner_id.street"/>
<br/>
<t t-if="object.partner_id.street2">
<t t-out="object.partner_id.street2"/>
<br/>
</t>
<t t-out="object.partner_id.city"/>
, <t t-out="object.partner_id.state_id.code"/>
<t t-out="object.partner_id.zip"/>
</td>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
<td style="padding:10px;">
<t t-foreach="object.user_ids" t-as="user">
<t t-out="user.name"/>
<t t-if="not user_last">
<br/>
</t>
</t>
</td>
</tr>
</table>
<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;">
<t t-set="base_url" t-value="object.get_base_url()"/>
<t t-set="lang" t-value="object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'"/>
<a t-att-href="base_url + '/my/fsm_confirmation/approve?' + keep_query(lang=lang,access_token=object.access_token)" style="background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Approve Visit
</a>
</td>
<td style="padding:10px;text-align:center;">
<a t-att-href="base_url + '/my/fsm_confirmation/change?' + keep_query(lang=lang,access_token=object.access_token)" 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 <t t-out="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="False"/>
</record>
</odoo>

View file

@ -0,0 +1,299 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fsm_visit_confirmation
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 17.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
"Last-Translator: Marc Durepos <marc@bemade.org>\n"
"Language-Team: French\n"
"Language: fr\n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: 8bit\n"
"Plural-Forms: nplurals=2; plural=(n > 1);\n"
#. module: fsm_visit_confirmation
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid ""
"<div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
",\n"
" </div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" We would like to confirm the details of your upcoming service visit:\n"
" </div>\n"
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.name\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
" <div style=\"font-size: 12px; color: #666;\">\n"
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
")\n"
" </div>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.partner_id.street\"></t>\n"
" <br>\n"
" <t t-if=\"object.partner_id.street2\">\n"
" <t t-out=\"object.partner_id.street2\"></t>\n"
" <br>\n"
" </t>\n"
" <t t-out=\"object.partner_id.city\"></t>\n"
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
" <t t-out=\"object.partner_id.zip\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
" <t t-out=\"user.name\"></t>\n"
" <t t-if=\"not user_last\">\n"
" <br>\n"
" </t>\n"
" </t>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
" Please take a moment to confirm this visit by clicking one of the following options:\n"
" </div>\n"
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Approve Visit\n"
" </a>\n"
" </td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Request Changes\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin-top: 16px;\">\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
" service management team</p>\n"
" </div>\n"
" </div>\n"
" "
msgstr ""
"<div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Cher/chère<t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
",\n"
" </div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Nous aimerions confirmer les détails de votre visite de service à venir.\n"
" </div>\n"
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Visite</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.name\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Début</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
" <div style=\"font-size: 12px; color: #666;\">\n"
" (Fuseau horaire: <t t-out=\"partner_tz\"></t>\n"
")\n"
" </div>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Lieu</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.partner_id.street\"></t>\n"
" <br>\n"
" <t t-if=\"object.partner_id.street2\">\n"
" <t t-out=\"object.partner_id.street2\"></t>\n"
" <br>\n"
" </t>\n"
" <t t-out=\"object.partner_id.city\"></t>\n"
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
" <t t-out=\"object.partner_id.zip\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Technicien(s) assigné(s)</td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
" <t t-out=\"user.name\"></t>\n"
" <t t-if=\"not user_last\">\n"
" <br>\n"
" </t>\n"
" </t>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
" Veuillez prendre un moment pour confirmer cette visite en cliquant sur une des options suivantes :\n"
" </div>\n"
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Confirmer la visite\n"
" </a>\n"
" </td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Demander des changements\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin-top: 16px;\">\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Cordialement,</p>\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">L'équipe de gestion des services de <t t-out=\"object.company_id.name\"></t></p>\n"
" </div>\n"
" </div>\n"
" "
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-check-circle me-2\"/>\n"
" <strong>Success!</strong> Thank you for approving this service visit."
msgstr ""
"<i class=\"fa fa-check-circle me-2\"/>\n"
" <strong>Succès!</strong> Merci d'avoir approuvé cette visite de service."
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
" Request Changes to Service Visit"
msgstr ""
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
" Demander des changements à la visite de service"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-info-circle me-2\"/>\n"
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
msgstr ""
"<i class=\"fa fa-info-circle me-2\"/>\n"
" <strong>Merci!</strong> Votre demande de changement a été soumise. Notre équipe examinerait cette demande rapidement."
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Changes requested by customer: %s"
msgstr "Changements demandés par client: %s"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error approving task: %s"
msgstr "Erreur lors de l'approbation de la tâche: %s"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error submitting change request: %s"
msgstr "Erreur lors de la soumission de la demande de changement: %s"
#. module: fsm_visit_confirmation
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "FSM Visit Confirmation"
msgstr "Confirmation de visite de service"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Invalid action"
msgstr "Action invalide"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "No feedback provided. Please try again!"
msgstr "Aucun commentaire fourni. Veuillez réessayer!"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Please explain what changes you need..."
msgstr "Veuillez expliquer les modifications nécessaires..."
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"Please provide details about the changes you would like to make to this "
"service visit:"
msgstr ""
"Veuillez fournir des informations sur les modifications que vous souhaitez "
"apporter à cette visite de service :"
#. module: fsm_visit_confirmation
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Send a visit confirmation email to the customer"
msgstr "Envoyer un email de confirmation de visite au client"
#. module: fsm_visit_confirmation
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Service Visit Confirmation: {{ object.name }}"
msgstr "Confirmation de visite de service: {{ object.name }}"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Submit Request"
msgstr "Soumettre la demande"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Task not found or invalid token"
msgstr "Tâche introuvable ou jeton invalide"
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Visit approved by customer"
msgstr "Visite approuvée par le client"
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Your Comments:"
msgstr "Vos commentaires :"

View file

@ -0,0 +1,211 @@
# Translation of Odoo Server.
# This file contains the translation of the following modules:
# * fsm_visit_confirmation
#
msgid ""
msgstr ""
"Project-Id-Version: Odoo Server 17.0+e\n"
"Report-Msgid-Bugs-To: \n"
"POT-Creation-Date: 2025-02-27 00:31+0000\n"
"PO-Revision-Date: 2025-02-27 00:31+0000\n"
"Last-Translator: \n"
"Language-Team: \n"
"MIME-Version: 1.0\n"
"Content-Type: text/plain; charset=UTF-8\n"
"Content-Transfer-Encoding: \n"
"Plural-Forms: \n"
#. module: fsm_visit_confirmation
#: model:mail.template,body_html:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid ""
"<div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" Dear <t t-out=\"object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name\"></t>\n"
",\n"
" </div>\n"
" <div style=\"margin-bottom: 16px;\">\n"
" We would like to confirm the details of your upcoming service visit:\n"
" </div>\n"
" <table style=\"border-style:solid;box-sizing:border-box;border-left-color:#e7e7e7;border-bottom-color:#e7e7e7;border-right-color:#e7e7e7;border-top-color:#e7e7e7;border-left-width:1px;border-bottom-width:1px;border-right-width:1px;border-top-width:1px;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;border:1pxsolid#e7e7e7\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Task Summary</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.name\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Technician Arrival</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-set=\"partner\" t-value=\"object.work_order_contacts and object.work_order_contacts[0] or object.partner_id\"></t>\n"
" <t t-set=\"partner_tz\" t-value=\"user.tz or object.company_id.partner_id.tz or 'America/Toronto'\"></t>\n"
" <span t-field=\"object.planned_date_begin\" t-options=\"{'widget': 'datetime', 'timezone': partner_tz}\"></span>\n"
" <div style=\"font-size: 12px; color: #666;\">\n"
" (Timezone: <t t-out=\"partner_tz\"></t>\n"
")\n"
" </div>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa;border-bottom:1pxsolid#e7e7e7\">Location</td>\n"
" <td style=\"border-style:solid;box-sizing:border-box;border-bottom-color:#e7e7e7;border-left-width:0px;border-bottom-width:1px;border-right-width:0px;border-top-width:0px;padding:10px;border-bottom:1pxsolid#e7e7e7\">\n"
" <t t-out=\"object.partner_id.street\"></t>\n"
" <br>\n"
" <t t-if=\"object.partner_id.street2\">\n"
" <t t-out=\"object.partner_id.street2\"></t>\n"
" <br>\n"
" </t>\n"
" <t t-out=\"object.partner_id.city\"></t>\n"
", <t t-out=\"object.partner_id.state_id.code\"></t>\n"
" <t t-out=\"object.partner_id.zip\"></t>\n"
" </td>\n"
" </tr>\n"
" <tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;background-color:#f8f9fa\">Assigned Technician(s)</td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px\">\n"
" <t t-foreach=\"object.user_ids\" t-as=\"user\">\n"
" <t t-out=\"user.name\"></t>\n"
" <t t-if=\"not user_last\">\n"
" <br>\n"
" </t>\n"
" </t>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin: 16px 0px 16px 0px;\">\n"
" Please take a moment to confirm this visit by clicking one of the following options:\n"
" </div>\n"
" <table style=\"box-sizing:border-box;-webkit-border-vertical-spacing:0px;-webkit-border-horizontal-spacing:0px;border-collapse:collapse;caption-side:bottom;width:100%;border-spacing:0;\" width=\"100%\">\n"
" <tbody style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\"><tr style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px\">\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <t t-set=\"base_url\" t-value=\"object.get_base_url()\"></t>\n"
" <t t-set=\"lang\" t-value=\"object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang or 'en_US'\"></t>\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/approve?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Approve Visit\n"
" </a>\n"
" </td>\n"
" <td style=\"border-style:none;box-sizing:border-box;border-left-width:0px;border-bottom-width:0px;border-right-width:0px;border-top-width:0px;padding:10px;text-align:center\">\n"
" <a t-att-href=\"base_url + '/my/fsm_confirmation/change?access_token=' + object.access_token + '&amp;lang=' + lang\" style=\"box-sizing:border-box;background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;\">\n"
" Request Changes\n"
" </a>\n"
" </td>\n"
" </tr>\n"
" </tbody></table>\n"
" <div style=\"margin-top: 16px;\">\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">Best regards,</p>\n"
" <p style=\"margin:0px 0 16px 0;box-sizing:border-box;\">The <t t-out=\"object.company_id.name\"></t>\n"
" service management team</p>\n"
" </div>\n"
" </div>\n"
" "
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-check-circle me-2\"/>\n"
" <strong>Success!</strong> Thank you for approving this service visit."
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-exclamation-triangle me-2\"/>\n"
" Request Changes to Service Visit"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"<i class=\"fa fa-info-circle me-2\"/>\n"
" <strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly."
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Changes requested by customer: %s"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error approving task: %s"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Error submitting change request: %s"
msgstr ""
#. module: fsm_visit_confirmation
#: model:mail.template,name:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "FSM Visit Confirmation"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Invalid action"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "No feedback provided. Please try again!"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Please explain what changes you need..."
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid ""
"Please provide details about the changes you would like to make to this "
"service visit:"
msgstr ""
#. module: fsm_visit_confirmation
#: model:mail.template,description:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Send a visit confirmation email to the customer"
msgstr ""
#. module: fsm_visit_confirmation
#: model:mail.template,subject:fsm_visit_confirmation.fsm_visit_confirmation_email_template
msgid "Service Visit Confirmation: {{ object.name }}"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Submit Request"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Task not found or invalid token"
msgstr ""
#. module: fsm_visit_confirmation
#. odoo-python
#: code:addons/fsm_visit_confirmation/controllers/main.py:0
#, python-format
msgid "Visit approved by customer"
msgstr ""
#. module: fsm_visit_confirmation
#: model_terms:ir.ui.view,arch_db:fsm_visit_confirmation.portal_my_task
msgid "Your Comments:"
msgstr ""

View file

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

View file

@ -0,0 +1,31 @@
from odoo import models
class ProjectTask(models.Model):
_inherit = "project.task"
def write(self, vals):
# Store old stage for comparison
old_stages = {task.id: task.stage_id for task in self}
# Execute original write method
result = super().write(vals)
# If stage changed and new stage has an approval template, send the email
if "stage_id" in vals:
# Only send email for top-level tasks
for task in self.filtered(
lambda task: task.stage_id != old_stages[task.id]
and task.stage_id.approval_template_id
and not task.parent_id
):
task._portal_ensure_token()
task.stage_id.approval_template_id.send_mail(
task.id,
force_send=True,
email_values={
"email_to": task.partner_id.email if task.partner_id else False
},
)
return result

View file

@ -0,0 +1,11 @@
from odoo import fields, models
class ProjectTaskType(models.Model):
_inherit = 'project.task.type'
approval_template_id = fields.Many2one(
'mail.template',
string='Approval Template',
help='Template to use for approval emails when a task reaches this stage.'
)

View file

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

View file

@ -0,0 +1,249 @@
# -*- coding: utf-8 -*-
import logging
from datetime import datetime, timedelta
from odoo import http
from odoo.tests import tagged
from odoo.tests.common import HttpCase
_logger = logging.getLogger(__name__)
@tagged("post_install", "-at_install")
class TestFSMVisitConfirmation(HttpCase):
@classmethod
def http_port(cls):
"""Override http_port to handle Odoo 18.0 PreforkServer changes"""
import odoo.tools.config
import odoo.service.server
try:
if odoo.service.server.server is not None:
server = odoo.service.server.server
# Try to get port from httpd first (older versions)
if hasattr(server, 'httpd'):
httpd = getattr(server, 'httpd')
if hasattr(httpd, 'server_port'):
return getattr(httpd, 'server_port')
# Fallback to server.port (newer versions like PreforkServer)
if hasattr(server, 'port'):
return getattr(server, 'port')
except (AttributeError, TypeError):
pass
# Final fallback to config
return odoo.tools.config['http_port']
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,
],
)
],
}
)
# Set email on user's partner
self.fsm_user.partner_id.email = "fsm@example.com"
self.fsm_user.partner_id.lang = "en_US" # Set language explicitly
self.customer = self.env["res.partner"].create(
{
"name": "Customer",
"email": "customer@example.com",
"lang": "en_US", # Set language explicitly
}
)
# Create a project
self.project = self.env["project.project"].create(
{
"name": "Test FSM Project",
"is_fsm": True,
"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)],
}
)
self.stage_approved = self.env["project.task.type"].create(
{
"name": "Approved",
"sequence": 2,
"project_ids": [(4, self.project.id)],
}
)
self.stage_changes_requested = self.env["project.task.type"].create(
{
"name": "Changes Requested",
"sequence": 3,
"project_ids": [(4, self.project.id)],
}
)
# 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,
"state": "01_in_progress", # Using valid state value
}
)
self.assertEqual(
self.task.stage_id, self.stage_new, "Task should start in New stage"
)
# Ensure the task has an access token
if not self.task.access_token:
self.task._portal_ensure_token()
self.token = self.task.access_token
self.assertTrue(self.token, "Task should have an access token")
def test_01_task_approve_flow(self):
"""Test the complete task approval flow"""
from odoo.addons.fsm_visit_confirmation.controllers.main import CustomerPortalExtended
from odoo.addons.website.tools import MockRequest
from unittest.mock import patch
# Create controller instance
controller = CustomerPortalExtended()
# Patch HttpCase.http_port to use our safe implementation
with patch('odoo.tests.common.HttpCase.http_port', self.http_port):
with MockRequest(self.env):
# Test the approval action through the controller
response = controller.fsm_confirmation_action(
"approve", access_token=self.token
)
# Verify we got a redirect response (werkzeug Response object)
self.assertTrue(hasattr(response, 'status_code'), "Should return a response object")
# Check that the task state was updated by the controller
self.task.invalidate_recordset()
self.assertEqual(
self.task.state, "03_approved", "Task state should be updated to approved"
)
# Check that a message was posted by the controller
messages = self.env["mail.message"].search(
[
("model", "=", "project.task"),
("res_id", "=", self.task.id),
("body", "ilike", "Visit approved by customer"),
]
)
self.assertTrue(messages, "A message should be posted on the task")
def test_03_stage_approved_sends_email(self):
"""Test that moving a task to the approved stage sends an email"""
# Set the approval template on the approved stage
template = self.env.ref(
"fsm_visit_confirmation.fsm_visit_confirmation_email_template"
)
self.stage_approved.approval_template_id = template
# Count existing mail messages before the action
initial_mail_count = self.env["mail.mail"].search_count([])
self.task.write({"stage_id": self.stage_approved.id})
# Check that a new mail was created
final_mail_count = self.env["mail.mail"].search_count([])
self.assertEqual(
final_mail_count - initial_mail_count,
1,
"Moving task to approved stage should send an email",
)
# Find the new mail and verify recipient
new_mail = self.env["mail.mail"].search([], order="id desc", limit=1)
self.assertEqual(new_mail.email_to, self.customer.email)
def test_02_task_request_changes_flow(self):
"""Test the complete task request changes flow"""
from odoo.addons.fsm_visit_confirmation.controllers.main import CustomerPortalExtended
from odoo.addons.website.tools import MockRequest
from unittest.mock import patch
# Create controller instance
controller = CustomerPortalExtended()
feedback = "Need some changes"
# Patch HttpCase.http_port to use our safe implementation
with patch('odoo.tests.common.HttpCase.http_port', self.http_port):
with MockRequest(self.env):
# First test the change action (should redirect to form)
response = controller.fsm_confirmation_action("change", access_token=self.token)
# Verify we got a redirect response (werkzeug Response object)
self.assertTrue(hasattr(response, 'status_code'), "Should return a response object")
# Now test the submit change action
response = controller.fsm_confirmation_submit_change(
access_token=self.token, feedback=feedback
)
# Verify we got a redirect response (werkzeug Response object)
self.assertTrue(hasattr(response, 'status_code'), "Should return a response object")
# Check that the task state was updated by the controller
self.task.invalidate_recordset()
self.assertEqual(
self.task.state,
"02_changes_requested",
"Task state should be updated to changes requested",
)
# Check that a message was posted with the feedback by the controller
messages = self.env["mail.message"].search(
[
("model", "=", "project.task"),
("res_id", "=", self.task.id),
("body", "ilike", "Need some changes"),
]
)
self.assertTrue(
messages, "A message with feedback should be posted on the task"
)

View file

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="portal_my_task" inherit_id="project.portal_my_task">
<!-- Add success message when visit is approved -->
<xpath expr="//div[@id='task_content']" position="inside">
<div t-if="request.params.get('visit_confirmation_status') == 'approved'" class="alert alert-success" role="alert">
<i class="fa fa-check-circle me-2"></i>
<strong>Success!</strong> Thank you for approving this service visit.
</div>
<div t-if="request.params.get('visit_confirmation_status') == 'change_submitted'" class="alert alert-info" role="alert">
<i class="fa fa-info-circle me-2"></i>
<strong>Thank you!</strong> Your change request has been submitted. Our team will review it shortly.
</div>
</xpath>
<!-- Add change request form when needed -->
<xpath expr="//div[@id='task_chat']" position="before">
<div t-if="request.params.get('visit_confirmation_status') == 'change'" class="mb-4 mt-4">
<div class="card">
<div class="card-header bg-warning text-white">
<h5 class="mb-0">
<i class="fa fa-exclamation-triangle me-2"></i>
Request Changes to Service Visit
</h5>
</div>
<div class="card-body">
<p>Please provide details about the changes you would like to make to this service visit:</p>
<form action="/my/fsm_confirmation/submit_change" method="post">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="access_token" t-att-value="request.params.get('access_token')"/>
<input type="hidden" name="lang" t-att-value="request.context.get('lang') or request.params.get('lang')"/>
<div class="form-group mb-3">
<label for="feedback" class="form-label">Your Comments:</label>
<textarea class="form-control" name="feedback" id="feedback" rows="4" required="required" placeholder="Please explain what changes you need..."></textarea>
</div>
<button type="submit" class="btn btn-warning">
Submit Request
</button>
</form>
</div>
</div>
</div>
</xpath>
</template>
</odoo>

View file

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="view_project_task_type_form_inherit_fsm_visit_confirmation" model="ir.ui.view">
<field name="name">project.task.type.form.inherit.fsm.visit.confirmation</field>
<field name="model">project.task.type</field>
<field name="inherit_id" ref="project.task_type_edit"/>
<field name="arch" type="xml">
<field name="mail_template_id" position="after">
<field name="approval_template_id"/>
</field>
</field>
</record>
</odoo>