fsm_visit_confirmation: new module good to go

This commit is contained in:
Marc Durepos 2025-02-26 14:52:17 -05:00
parent f9f32aab34
commit 95a95298bb
7 changed files with 328 additions and 168 deletions

View file

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

View file

@ -42,8 +42,11 @@
"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", "bemade_fsm", "rating"], "depends": ["industry_fsm", "bemade_fsm", "rating", "http_routing"],
"data": ["data/mail_templates.xml"], "data": [
"data/mail_templates.xml",
"views/project_portal_project_task_templates.xml",
],
"installable": True, "installable": True,
"auto_install": False, "auto_install": False,
"application": False, "application": False,

View file

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

View file

@ -0,0 +1,149 @@
# -*- coding: utf-8 -*-
import logging
import werkzeug
from odoo import http
from odoo.http import request
from odoo.tools.translate import _
from odoo.tools.misc import get_lang
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_sudo = self._document_check_access(
"project.task", task_id, access_token
)
except (AccessError, MissingError):
return request.redirect("/my")
# Pass the task to the original method
return super(CustomerPortalExtended, self).portal_my_task(
task_id, access_token=access_token, **kw
)
@http.route(
"/my/fsm_confirmation/<string:token>/<string:action>",
type="http",
auth="public",
website=True,
)
def fsm_confirmation_action(self, token, action, **kwargs):
"""Custom landing page for FSM visit confirmations.
Args:
token: The access token for the task
action: Either 'approve' or 'change'
"""
if action not in ("approve", "change"):
raise werkzeug.exceptions.BadRequest(_("Invalid action"))
# Get the task using the token
task_sudo = self._get_task_by_token(token)
if not task_sudo:
return self._render_error_page(_("Task not found or invalid token"))
# If it's an approval, update the task state directly
if action == "approve":
try:
# Update task state to approved
task_sudo.sudo().write({"state": "03_approved"})
# Add a message to the chatter
task_sudo.sudo().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
return request.redirect(
"/my/task/%s?visit_confirmation_status=approved&access_token=%s"
% (task_sudo.id, token)
)
except Exception as e:
_logger.exception("Error approving task: %s", e)
return self._render_error_page(str(e))
# For change requests, redirect to the task portal page with change request form
return request.redirect(
"/my/task/%s?visit_confirmation_status=change&token=%s&access_token=%s"
% (task_sudo.id, token, token)
)
@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."""
token = kwargs.get("token")
feedback = kwargs.get("feedback", "")
if not token or not feedback:
return request.redirect("/my")
# Get the task using the token
task_sudo = self._get_task_by_token(token)
if not task_sudo:
return self._render_error_page(_("Task not found or invalid token"))
try:
# Update task state to changes requested
task_sudo.sudo().write({"state": "02_changes_requested"})
# Add the feedback as a message to the chatter
task_sudo.sudo().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
return request.redirect(
"/my/task/%s?visit_confirmation_status=change_submitted&access_token=%s"
% (task_sudo.id, token)
)
except Exception as e:
_logger.exception("Error submitting change request: %s", e)
return self._render_error_page(str(e))
def _get_task_by_token(self, token):
"""Get a task by its access token."""
# First try to find the task directly by 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):
"""Render an error page."""
return request.render(
"http_routing.http_error",
{
"status_code": "400",
"status_message": error_message,
},
)

View file

@ -1,73 +1,88 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="utf-8"?>
<odoo> <odoo>
<record id="rating_project_request_email_template" model="mail.template"> <record id="fsm_visit_confirmation_email_template" model="mail.template">
<field name="name">Field Service Task: Rating Request</field> <field name="name">FSM Visit Confirmation</field>
<field name="model_id" ref="project.model_project_task"/> <field name="model_id" ref="industry_fsm.model_project_task"/>
<field name="subject">Please confirm your upcoming service visit with {{ object.company_id.name }}</field> <field name="subject">Service Visit Confirmation: {{ object.name }}</field>
<field name="email_from">{{ user.email_formatted }}</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="partner_to">{{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }}</field>
<field name="description">Sent to request a client rating/confirmation for a field service task</field> <field name="description">Send a visit confirmation email to the customer</field>
<field name="body_html" type="html"> <field name="body_html" type="html">
<div> <div>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
Dear {{ object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name }}, Dear <t t-out="object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name"/>,
</div> </div>
<div style="margin-bottom: 16px;"> <div style="margin-bottom: 16px;">
We would like to confirm the details of your upcoming service visit: We would like to confirm the details of your upcoming service visit:
</div> </div>
<table style="width:100%;border-spacing:0;border:1px solid #e7e7e7;"> <table style="width:100%;border-spacing:0;border:1px solid #e7e7e7;">
<tr> <tr>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td> <td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Task Summary</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">{{ object.name }}</td> <td style="padding:10px;border-bottom:1px solid #e7e7e7;">
<t t-out="object.name"/>
</td>
</tr> </tr>
<tr> <tr>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td> <td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Technician Arrival</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">{{ object.planned_date_begin }}</td> <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>
<tr> <tr>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td> <td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;"> <td style="padding:10px;border-bottom:1px solid #e7e7e7;">
{{ object.partner_id.street }}<br/> <t t-out="object.partner_id.street"/>
{% if object.partner_id.street2 %} <br/>
{{ object.partner_id.street2 }}<br/> <t t-if="object.partner_id.street2">
{% endif %} <t t-out="object.partner_id.street2"/>
{{ object.partner_id.city }}, {{ object.partner_id.state_id.code }} {{ object.partner_id.zip }} <br/>
</td> </t>
</tr> <t t-out="object.partner_id.city"/>, <t t-out="object.partner_id.state_id.code"/>
<tr> <t t-out="object.partner_id.zip"/>
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td> </td>
<td style="padding:10px;"> </tr>
{% for user in object.user_ids %} <tr>
{{ user.name }}{% if not loop.last %}<br/> <td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
{% endif %} <td style="padding:10px;">
{% endfor %} <t t-foreach="object.user_ids" t-as="user">
</td> <t t-out="user.name"/>
</tr> <t t-if="not user_last">
</table> <br/>
<div style="margin: 16px 0px 16px 0px;"> </t>
Please take a moment to confirm this visit by clicking one of the following options: </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()"/>
<a t-att-href="base_url + '/my/fsm_confirmation/' + object.access_token + '/approve'" 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/' + object.access_token + '/change'" 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> </div>
<table style="width:100%;border-spacing:0;"> </field>
<tr> <field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
<td style="padding:10px;text-align:center;"> <field name="auto_delete" eval="True"/>
<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;"> </record>
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>

View file

@ -4,7 +4,6 @@ import logging
from datetime import datetime, timedelta from datetime import datetime, timedelta
from odoo import http from odoo import http
from odoo.tests import HttpCase, tagged from odoo.tests import HttpCase, tagged
from odoo.addons.rating.models import rating_data
_logger = logging.getLogger(__name__) _logger = logging.getLogger(__name__)
@ -35,7 +34,6 @@ class TestFSMVisitConfirmation(HttpCase):
0, 0,
[ [
self.env.ref("industry_fsm.group_fsm_user").id, self.env.ref("industry_fsm.group_fsm_user").id,
self.env.ref("project.group_project_rating").id,
], ],
) )
], ],
@ -43,11 +41,13 @@ class TestFSMVisitConfirmation(HttpCase):
) )
# Set email on user's partner # Set email on user's partner
self.fsm_user.partner_id.email = "fsm@example.com" 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( self.customer = self.env["res.partner"].create(
{ {
"name": "Customer", "name": "Customer",
"email": "customer@example.com", "email": "customer@example.com",
"lang": "en_US", # Set language explicitly
} }
) )
@ -56,8 +56,6 @@ class TestFSMVisitConfirmation(HttpCase):
{ {
"name": "Test FSM Project", "name": "Test FSM Project",
"is_fsm": True, "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, "company_id": self.env.user.company_id.id,
} }
) )
@ -76,10 +74,22 @@ class TestFSMVisitConfirmation(HttpCase):
"name": "Need Confirmation", "name": "Need Confirmation",
"sequence": 1, "sequence": 1,
"project_ids": [(4, self.project.id)], "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, 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)],
} }
) )
@ -93,140 +103,85 @@ class TestFSMVisitConfirmation(HttpCase):
"user_ids": [(4, self.fsm_user.id)], "user_ids": [(4, self.fsm_user.id)],
"planned_date_begin": datetime.now() + timedelta(days=1), "planned_date_begin": datetime.now() + timedelta(days=1),
"stage_id": self.stage_new.id, "stage_id": self.stage_new.id,
"state": "01_in_progress", # Using valid state value
} }
) )
self.assertEqual( self.assertEqual(
self.task.stage_id, self.stage_new, "Task should start in New stage" self.task.stage_id, self.stage_new, "Task should start in New stage"
) )
def test_01_task_rating_flow(self): # Ensure the task has an access token
"""Test the complete task rating flow""" if not self.task.access_token:
# Move task to confirmation stage self.task._portal_ensure_token()
_logger.info("Project rating_active: %s", self.project.rating_active) self.token = self.task.access_token
_logger.info( self.assertTrue(self.token, "Task should have an access token")
"Stage rating_template_id: %s",
self.stage_needs_confirmation.rating_template_id,
)
# Verify project rating settings def test_01_task_approve_flow(self):
self.assertTrue(self.project.rating_active, "Project rating should be active") """Test the complete task approval flow"""
# Test the FSM confirmation approve endpoint
self.authenticate(None, None)
url = f"/my/fsm_confirmation/{self.token}/approve"
response = self.url_open(url)
self.assertEqual( self.assertEqual(
self.project.rating_status, response.status_code, 200, "FSM confirmation approve should succeed"
"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 # Now check that the task state was updated
self.task.write({"stage_id": self.stage_needs_confirmation.id}) self.task.invalidate_recordset()
self.assertEqual(
self.task.state, "03_approved", "Task state should be updated to approved"
)
# # Force a small delay to allow email processing # Check that a message was posted
# import time messages = self.env["mail.message"].search(
# time.sleep(1)
# Get the token directly from the task, passing the customer partner
rating = self.env["rating.rating"].search(
[ [
("model", "=", "project.task"),
("res_id", "=", self.task.id), ("res_id", "=", self.task.id),
("res_model", "=", "project.task"), ("body", "ilike", "Visit approved by customer"),
] ]
) )
self.assertTrue(rating, "Rating should be created") self.assertTrue(messages, "A message should be posted on the task")
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) def test_02_task_request_changes_flow(self):
"""Test the complete task request changes flow"""
# Test the FSM confirmation change endpoint
self.authenticate(None, None) self.authenticate(None, None)
url = f"/my/fsm_confirmation/{self.token}/change"
# First open the rating page
url = f"/rate/{rating.access_token}/5"
response = self.url_open(url) response = self.url_open(url)
self.assertEqual( self.assertEqual(
response.status_code, 200, "Opening rating page should succeed" response.status_code, 200, "FSM confirmation change should succeed"
) )
# Then submit the feedback # Now submit the change request form
url = f"/rate/{rating.access_token}/submit_feedback" url = f"/my/fsm_confirmation/submit_change"
response = self.url_open( response = self.url_open(
url, url,
data={ data={
"rate": "5", "token": self.token,
"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", "feedback": "Need some changes",
"csrf_token": http.Request.csrf_token(self), "csrf_token": http.Request.csrf_token(self),
}, },
) )
self.assertEqual( self.assertEqual(
response.status_code, 200, "Feedback submission should succeed" response.status_code, 200, "Change request submission should succeed"
) )
# Refresh the rating record # Check that the task state was updated
rating.invalidate_recordset() self.task.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.assertEqual(
self.task.state, self.task.state,
"02_changes_requested", "02_changes_requested",
"Task should be in changes requested state", "Task state should be updated to changes requested",
)
# Check that a message was posted with the feedback
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

@ -1,9 +1,45 @@
<?xml version="1.0" encoding="UTF-8" ?> <?xml version="1.0" encoding="UTF-8" ?>
<odoo> <odoo>
<template id="portal_my_task" inherit_id="project.portal_my_task"> <template id="portal_my_task" inherit_id="project.portal_my_task">
<xpath expr="//div[@id='task_content']" position="before"> <!-- Add success message when visit is approved -->
<div t-if="visit_confirmation_accepted" class="alert alert-success" role="alert"> <xpath expr="//div[@id='task_content']" position="inside">
<strong>Success!</strong> We've received your confirmation. Thank you! <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="token" t-att-value="request.params.get('token')"/>
<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> </div>
</xpath> </xpath>
</template> </template>