diff --git a/fsm_visit_confirmation/__init__.py b/fsm_visit_confirmation/__init__.py index e69de29..a03bfd0 100644 --- a/fsm_visit_confirmation/__init__.py +++ b/fsm_visit_confirmation/__init__.py @@ -0,0 +1 @@ +from . import controllers \ No newline at end of file diff --git a/fsm_visit_confirmation/__manifest__.py b/fsm_visit_confirmation/__manifest__.py index 90071c5..45c58e3 100644 --- a/fsm_visit_confirmation/__manifest__.py +++ b/fsm_visit_confirmation/__manifest__.py @@ -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, diff --git a/fsm_visit_confirmation/controllers/__init__.py b/fsm_visit_confirmation/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/fsm_visit_confirmation/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/fsm_visit_confirmation/controllers/main.py b/fsm_visit_confirmation/controllers/main.py new file mode 100644 index 0000000..29d4269 --- /dev/null +++ b/fsm_visit_confirmation/controllers/main.py @@ -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/", 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//", + 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, + }, + ) diff --git a/fsm_visit_confirmation/data/mail_templates.xml b/fsm_visit_confirmation/data/mail_templates.xml index b5202fe..6bdf82a 100644 --- a/fsm_visit_confirmation/data/mail_templates.xml +++ b/fsm_visit_confirmation/data/mail_templates.xml @@ -1,73 +1,88 @@ - + - - Field Service Task: Rating Request - - Please confirm your upcoming service visit with {{ object.company_id.name }} + + FSM Visit Confirmation + + Service Visit Confirmation: {{ object.name }} {{ user.email_formatted }} {{ object.work_order_contacts and object.work_order_contacts[0].id or object.partner_id.id }} - Sent to request a client rating/confirmation for a field service task + Send a visit confirmation email to the customer
- Dear {{ object.work_order_contacts and object.work_order_contacts[0].name or object.partner_id.name }}, + Dear ,
- We would like to confirm the details of your upcoming service visit: + We would like to confirm the details of your upcoming service visit:
- + - + - - - - - -
Task Summary{{ object.name }} + +
Technician Arrival{{ object.planned_date_begin }} + + + +
+ (Timezone: ) +
+
Location - {{ object.partner_id.street }}
- {% if object.partner_id.street2 %} - {{ object.partner_id.street2 }}
- {% endif %} - {{ object.partner_id.city }}, {{ object.partner_id.state_id.code }} {{ object.partner_id.zip }} -
Assigned Technician(s) - {% for user in object.user_ids %} - {{ user.name }}{% if not loop.last %}
-{% endif %} - {% endfor %} -
-
- Please take a moment to confirm this visit by clicking one of the following options: + +
+ + +
+
+ , + + + + + Assigned Technician(s) + + + + +
+
+
+ + + +
+ Please take a moment to confirm this visit by clicking one of the following options: +
+ + + + + +
+ + + Approve Visit + + + + Request Changes + +
+
+

Best regards,

+

The service management team

+
- - - - - -
- - Confirm Visit - - - - Request Changes - -
-
-

Best regards,

-

The {{ object.company_id.name }} service management team

-
-
-
- {{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }} - -
+
+ {{ object.work_order_contacts and object.work_order_contacts[0].lang or object.partner_id.lang }} + +
\ No newline at end of file diff --git a/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py b/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py index 496568f..72f8024 100644 --- a/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py +++ b/fsm_visit_confirmation/tests/test_fsm_visit_confirmation.py @@ -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" ) diff --git a/fsm_visit_confirmation/views/project_portal_project_task_templates.xml b/fsm_visit_confirmation/views/project_portal_project_task_templates.xml index 4ef2b48..5e8e5e3 100644 --- a/fsm_visit_confirmation/views/project_portal_project_task_templates.xml +++ b/fsm_visit_confirmation/views/project_portal_project_task_templates.xml @@ -1,10 +1,46 @@ - \ No newline at end of file +