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.",
"website": "http://www.bemade.org",
"license": "LGPL-3",
"depends": ["industry_fsm", "bemade_fsm", "rating"],
"data": ["data/mail_templates.xml"],
"depends": ["industry_fsm", "bemade_fsm", "rating", "http_routing"],
"data": [
"data/mail_templates.xml",
"views/project_portal_project_task_templates.xml",
],
"installable": True,
"auto_install": 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>
<record id="rating_project_request_email_template" model="mail.template">
<field name="name">Field Service Task: Rating Request</field>
<field name="model_id" ref="project.model_project_task"/>
<field name="subject">Please confirm your upcoming service visit with {{ object.company_id.name }}</field>
<record id="fsm_visit_confirmation_email_template" model="mail.template">
<field name="name">FSM Visit Confirmation</field>
<field name="model_id" ref="industry_fsm.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">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">
<div>
<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 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>
<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;">{{ object.name }}</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;">{{ 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>
<td style="padding:10px;background-color:#f8f9fa;border-bottom:1px solid #e7e7e7;">Location</td>
<td style="padding:10px;border-bottom:1px solid #e7e7e7;">
{{ object.partner_id.street }}<br/>
{% if object.partner_id.street2 %}
{{ object.partner_id.street2 }}<br/>
{% endif %}
{{ object.partner_id.city }}, {{ object.partner_id.state_id.code }} {{ object.partner_id.zip }}
</td>
</tr>
<tr>
<td style="padding:10px;background-color:#f8f9fa;">Assigned Technician(s)</td>
<td style="padding:10px;">
{% for user in object.user_ids %}
{{ user.name }}{% if not loop.last %}<br/>
{% endif %}
{% endfor %}
</td>
</tr>
</table>
<div style="margin: 16px 0px 16px 0px;">
Please take a moment to confirm this visit by clicking one of the following options:
<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()"/>
<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>
<table style="width:100%;border-spacing:0;">
<tr>
<td style="padding:10px;text-align:center;">
<a t-attf-href="/rate/{{ object._rating_get_access_token(partner=object.work_order_contacts and object.work_order_contacts[0] or object.partner_id) }}/5" style="background-color:#28a745;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Confirm Visit
</a>
</td>
<td style="padding:10px;text-align:center;">
<a t-attf-href="/rate/{{ object._rating_get_access_token(partner=object.work_order_contacts and object.work_order_contacts[0] or object.partner_id) }}/1" style="background-color:#dc3545;padding:8px 16px;text-decoration:none;color:#fff;border-radius:5px;font-size:13px;">
Request Changes
</a>
</td>
</tr>
</table>
<div style="margin-top: 16px;">
<p>Best regards,</p>
<p>The {{ object.company_id.name }} service management team</p>
</div>
</div>
</field>
<field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</field>
<field name="lang">{{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }}</field>
<field name="auto_delete" eval="True"/>
</record>
</odoo>

View file

@ -4,7 +4,6 @@ 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__)
@ -35,7 +34,6 @@ class TestFSMVisitConfirmation(HttpCase):
0,
[
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
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
}
)
@ -56,8 +56,6 @@ class TestFSMVisitConfirmation(HttpCase):
{
"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,
}
)
@ -76,10 +74,22 @@ class TestFSMVisitConfirmation(HttpCase):
"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,
}
)
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)],
"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"
)
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,
)
# 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")
# Verify project rating settings
self.assertTrue(self.project.rating_active, "Project rating should be active")
def test_01_task_approve_flow(self):
"""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.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",
response.status_code, 200, "FSM confirmation approve should succeed"
)
# Move task to confirmation stage
self.task.write({"stage_id": self.stage_needs_confirmation.id})
# Now check that the task state was updated
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
# import time
# time.sleep(1)
# Get the token directly from the task, passing the customer partner
rating = self.env["rating.rating"].search(
# Check that a message was posted
messages = self.env["mail.message"].search(
[
("model", "=", "project.task"),
("res_id", "=", self.task.id),
("res_model", "=", "project.task"),
("body", "ilike", "Visit approved by customer"),
]
)
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)
self.assertTrue(messages, "A message should be posted on the task")
# 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)
# First open the rating page
url = f"/rate/{rating.access_token}/5"
url = f"/my/fsm_confirmation/{self.token}/change"
response = self.url_open(url)
self.assertEqual(
response.status_code, 200, "Opening rating page should succeed"
response.status_code, 200, "FSM confirmation change should succeed"
)
# Then submit the feedback
url = f"/rate/{rating.access_token}/submit_feedback"
# Now submit the change request form
url = f"/my/fsm_confirmation/submit_change"
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",
"token": self.token,
"feedback": "Need some changes",
"csrf_token": http.Request.csrf_token(self),
},
)
self.assertEqual(
response.status_code, 200, "Feedback submission should succeed"
response.status_code, 200, "Change request 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",
)
# Check that the task state was updated
self.task.invalidate_recordset()
self.assertEqual(
self.task.state,
"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,10 +1,46 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<template id="portal_my_task" inherit_id="project.portal_my_task">
<xpath expr="//div[@id='task_content']" position="before">
<div t-if="visit_confirmation_accepted" class="alert alert-success" role="alert">
<strong>Success!</strong> We've received your confirmation. Thank you!
<!-- 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="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>
</xpath>
</template>
</odoo>
</odoo>