feat: separate fusion field service and LTC into standalone modules, update core modules
- fusion_claims: separated field service logic, updated controllers/views - fusion_tasks: updated task views and map integration - fusion_authorizer_portal: added page 11 signing, schedule booking, migrations - fusion_shipping: new standalone shipping module (Canada Post, FedEx, DHL, Purolator) - fusion_ltc_management: new standalone LTC management module
This commit is contained in:
@@ -3,4 +3,5 @@
|
||||
from . import portal_main
|
||||
from . import portal_assessment
|
||||
from . import pdf_editor
|
||||
from . import portal_repair
|
||||
from . import portal_schedule
|
||||
from . import portal_page11_sign
|
||||
@@ -26,6 +26,7 @@ class AuthorizerPortal(CustomerPortal):
|
||||
if hasattr(response, 'qcontext') and (partner.is_authorizer or partner.is_sales_rep_portal or partner.is_client_portal or partner.is_technician_portal):
|
||||
posting_info = self._get_adp_posting_info()
|
||||
response.qcontext.update(posting_info)
|
||||
response.qcontext.update(self._get_clock_status_data())
|
||||
|
||||
# Add signature count (documents to sign) - only if Sign module is installed
|
||||
sign_count = 0
|
||||
@@ -724,7 +725,7 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'sale_type_filter': sale_type,
|
||||
'status_filter': status,
|
||||
}
|
||||
|
||||
values.update(self._get_clock_status_data())
|
||||
return request.render('fusion_authorizer_portal.portal_sales_dashboard', values)
|
||||
|
||||
@http.route(['/my/sales/cases', '/my/sales/cases/page/<int:page>'], type='http', auth='user', website=True)
|
||||
@@ -1090,14 +1091,60 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.error(f"Error downloading proof of delivery: {e}")
|
||||
return request.redirect('/my/funding-claims')
|
||||
|
||||
# ==================== CLOCK STATUS HELPER ====================
|
||||
|
||||
def _get_clock_status_data(self):
|
||||
"""Get clock in/out status for the current portal user."""
|
||||
try:
|
||||
user = request.env.user
|
||||
Employee = request.env['hr.employee'].sudo()
|
||||
employee = Employee.search([('user_id', '=', user.id)], limit=1)
|
||||
if not employee:
|
||||
employee = Employee.search([
|
||||
('name', '=', user.partner_id.name),
|
||||
('user_id', '=', False),
|
||||
], limit=1)
|
||||
if not employee or not getattr(employee, 'x_fclk_enable_clock', False):
|
||||
return {'clock_enabled': False}
|
||||
|
||||
is_checked_in = employee.attendance_state == 'checked_in'
|
||||
check_in_time = ''
|
||||
location_name = ''
|
||||
if is_checked_in:
|
||||
att = request.env['hr.attendance'].sudo().search([
|
||||
('employee_id', '=', employee.id),
|
||||
('check_out', '=', False),
|
||||
], limit=1)
|
||||
if att:
|
||||
check_in_time = att.check_in.isoformat() if att.check_in else ''
|
||||
location_name = att.x_fclk_location_id.name if att.x_fclk_location_id else ''
|
||||
|
||||
return {
|
||||
'clock_enabled': True,
|
||||
'clock_checked_in': is_checked_in,
|
||||
'clock_check_in_time': check_in_time,
|
||||
'clock_location_name': location_name,
|
||||
}
|
||||
except Exception as e:
|
||||
_logger.warning("Clock status check failed: %s", e)
|
||||
return {'clock_enabled': False}
|
||||
|
||||
# ==================== TECHNICIAN PORTAL ====================
|
||||
|
||||
def _check_technician_access(self):
|
||||
"""Check if current user is a technician portal user."""
|
||||
partner = request.env.user.partner_id
|
||||
if not partner.is_technician_portal:
|
||||
return False
|
||||
return True
|
||||
if partner.is_technician_portal:
|
||||
return True
|
||||
has_tasks = request.env['fusion.technician.task'].sudo().search_count([
|
||||
'|',
|
||||
('technician_id', '=', request.env.user.id),
|
||||
('additional_technician_ids', 'in', [request.env.user.id]),
|
||||
], limit=1)
|
||||
if has_tasks:
|
||||
partner.sudo().write({'is_technician_portal': True})
|
||||
return True
|
||||
return False
|
||||
|
||||
@http.route(['/my/technician', '/my/technician/dashboard'], type='http', auth='user', website=True)
|
||||
def technician_dashboard(self, **kw):
|
||||
@@ -1159,6 +1206,8 @@ class AuthorizerPortal(CustomerPortal):
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||
|
||||
clock_data = self._get_clock_status_data()
|
||||
|
||||
values = {
|
||||
'today_tasks': today_tasks,
|
||||
'current_task': current_task,
|
||||
@@ -1174,6 +1223,7 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'google_maps_api_key': google_maps_api_key,
|
||||
'page_name': 'technician_dashboard',
|
||||
}
|
||||
values.update(clock_data)
|
||||
return request.render('fusion_authorizer_portal.portal_technician_dashboard', values)
|
||||
|
||||
@http.route(['/my/technician/tasks', '/my/technician/tasks/page/<int:page>'], type='http', auth='user', website=True)
|
||||
@@ -1423,11 +1473,17 @@ class AuthorizerPortal(CustomerPortal):
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@http.route('/my/technician/task/<int:task_id>/action', type='json', auth='user', website=True)
|
||||
def technician_task_action(self, task_id, action, **kw):
|
||||
"""Handle task status changes (start, complete, en_route, cancel)."""
|
||||
def technician_task_action(self, task_id, action, latitude=None, longitude=None, accuracy=None, **kw):
|
||||
"""Handle task status changes (start, complete, en_route, cancel).
|
||||
Location is mandatory -- the client must send GPS coordinates."""
|
||||
if not self._check_technician_access():
|
||||
return {'success': False, 'error': 'Access denied'}
|
||||
|
||||
if not latitude or not longitude:
|
||||
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
|
||||
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
||||
return {'success': False, 'error': 'Invalid GPS coordinates.'}
|
||||
|
||||
user = request.env.user
|
||||
Task = request.env['fusion.technician.task'].sudo()
|
||||
|
||||
@@ -1439,21 +1495,39 @@ class AuthorizerPortal(CustomerPortal):
|
||||
):
|
||||
return {'success': False, 'error': 'Task not found or not assigned to you'}
|
||||
|
||||
request.env['fusion.technician.location'].sudo().log_location(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
accuracy=accuracy,
|
||||
)
|
||||
|
||||
# Push location to remote instances for cross-instance visibility
|
||||
try:
|
||||
request.env['fusion.task.sync.config'].sudo()._push_technician_location(
|
||||
user.id, latitude, longitude, accuracy or 0)
|
||||
except Exception:
|
||||
pass # Non-blocking: sync failure should not block task action
|
||||
|
||||
location_ctx = {
|
||||
'action_latitude': latitude,
|
||||
'action_longitude': longitude,
|
||||
'action_accuracy': accuracy or 0,
|
||||
}
|
||||
|
||||
if action == 'en_route':
|
||||
task.action_start_en_route()
|
||||
task.with_context(**location_ctx).action_start_en_route()
|
||||
elif action == 'start':
|
||||
task.action_start_task()
|
||||
task.with_context(**location_ctx).action_start_task()
|
||||
elif action == 'complete':
|
||||
completion_notes = kw.get('completion_notes', '')
|
||||
if completion_notes:
|
||||
task.completion_notes = completion_notes
|
||||
task.action_complete_task()
|
||||
task.with_context(**location_ctx).action_complete_task()
|
||||
elif action == 'cancel':
|
||||
task.action_cancel_task()
|
||||
task.with_context(**location_ctx).action_cancel_task()
|
||||
else:
|
||||
return {'success': False, 'error': f'Unknown action: {action}'}
|
||||
|
||||
# For completion, also return next task info
|
||||
result = {
|
||||
'success': True,
|
||||
'status': task.status,
|
||||
@@ -1600,10 +1674,14 @@ class AuthorizerPortal(CustomerPortal):
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
@http.route('/my/technician/task/<int:task_id>/voice-complete', type='json', auth='user', website=True)
|
||||
def technician_voice_complete(self, task_id, transcription, **kw):
|
||||
def technician_voice_complete(self, task_id, transcription, latitude=None, longitude=None, accuracy=None, **kw):
|
||||
"""Format transcription with GPT and complete the task."""
|
||||
if not self._check_technician_access():
|
||||
return {'success': False, 'error': 'Access denied'}
|
||||
if not latitude or not longitude:
|
||||
return {'success': False, 'error': 'Location is required. Please enable GPS and try again.'}
|
||||
if not (-90 <= latitude <= 90 and -180 <= longitude <= 180):
|
||||
return {'success': False, 'error': 'Invalid GPS coordinates.'}
|
||||
|
||||
user = request.env.user
|
||||
Task = request.env['fusion.technician.task'].sudo()
|
||||
@@ -1675,7 +1753,18 @@ class AuthorizerPortal(CustomerPortal):
|
||||
'completion_notes': completion_html,
|
||||
'voice_note_transcription': transcription,
|
||||
})
|
||||
task.action_complete_task()
|
||||
|
||||
request.env['fusion.technician.location'].sudo().log_location(
|
||||
latitude=latitude,
|
||||
longitude=longitude,
|
||||
accuracy=accuracy,
|
||||
)
|
||||
location_ctx = {
|
||||
'action_latitude': latitude,
|
||||
'action_longitude': longitude,
|
||||
'action_accuracy': accuracy or 0,
|
||||
}
|
||||
task.with_context(**location_ctx).action_complete_task()
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
@@ -1788,6 +1877,25 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.warning(f"Location log error: {e}")
|
||||
return {'success': False}
|
||||
|
||||
@http.route('/my/technician/clock-status', type='json', auth='user', website=True)
|
||||
def technician_clock_status(self, **kw):
|
||||
"""Check if the current technician is clocked in.
|
||||
|
||||
Returns {clocked_in: bool} so the JS background logger can decide
|
||||
whether to track location. Replaces the fixed 9-6 hour window.
|
||||
"""
|
||||
if not self._check_technician_access():
|
||||
return {'clocked_in': False}
|
||||
try:
|
||||
emp = request.env['hr.employee'].sudo().search([
|
||||
('user_id', '=', request.env.user.id),
|
||||
], limit=1)
|
||||
if emp and emp.attendance_state == 'checked_in':
|
||||
return {'clocked_in': True}
|
||||
except Exception:
|
||||
pass
|
||||
return {'clocked_in': False}
|
||||
|
||||
@http.route('/my/technician/settings/start-location', type='json', auth='user', website=True)
|
||||
def technician_save_start_location(self, address='', **kw):
|
||||
"""Save the technician's personal start location."""
|
||||
@@ -2055,6 +2163,94 @@ class AuthorizerPortal(CustomerPortal):
|
||||
_logger.error(f"Error saving POD signature: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
# ==================== TASK-LEVEL POD SIGNATURE ====================
|
||||
|
||||
@http.route('/my/technician/task/<int:task_id>/pod', type='http', auth='user', website=True)
|
||||
def task_pod_signature_page(self, task_id, **kw):
|
||||
"""Task-level POD signature capture page (works for all tasks including shadow)."""
|
||||
if not self._check_technician_access():
|
||||
return request.redirect('/my')
|
||||
|
||||
user = request.env.user
|
||||
Task = request.env['fusion.technician.task'].sudo()
|
||||
|
||||
try:
|
||||
task = Task.browse(task_id)
|
||||
if not task.exists() or (
|
||||
task.technician_id.id != user.id
|
||||
and user.id not in task.additional_technician_ids.ids
|
||||
):
|
||||
raise AccessError(_('You do not have access to this task.'))
|
||||
except (AccessError, MissingError):
|
||||
return request.redirect('/my/technician/tasks')
|
||||
|
||||
values = {
|
||||
'task': task,
|
||||
'has_existing_signature': bool(task.pod_signature),
|
||||
'page_name': 'task_pod_signature',
|
||||
}
|
||||
return request.render('fusion_authorizer_portal.portal_task_pod_signature', values)
|
||||
|
||||
@http.route('/my/technician/task/<int:task_id>/pod/sign', type='json', auth='user', methods=['POST'])
|
||||
def task_pod_save_signature(self, task_id, client_name, signature_data, signature_date=None, **kw):
|
||||
"""Save POD signature directly on a task."""
|
||||
if not self._check_technician_access():
|
||||
return {'success': False, 'error': 'Access denied'}
|
||||
|
||||
user = request.env.user
|
||||
Task = request.env['fusion.technician.task'].sudo()
|
||||
|
||||
try:
|
||||
task = Task.browse(task_id)
|
||||
if not task.exists() or (
|
||||
task.technician_id.id != user.id
|
||||
and user.id not in task.additional_technician_ids.ids
|
||||
):
|
||||
return {'success': False, 'error': 'Task not found'}
|
||||
|
||||
if not client_name or not client_name.strip():
|
||||
return {'success': False, 'error': 'Client name is required'}
|
||||
if not signature_data:
|
||||
return {'success': False, 'error': 'Signature is required'}
|
||||
|
||||
if ',' in signature_data:
|
||||
signature_data = signature_data.split(',')[1]
|
||||
|
||||
from datetime import datetime as dt_datetime
|
||||
sig_date = None
|
||||
if signature_date:
|
||||
try:
|
||||
sig_date = dt_datetime.strptime(signature_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
pass
|
||||
|
||||
task.write({
|
||||
'pod_signature': signature_data,
|
||||
'pod_client_name': client_name.strip(),
|
||||
'pod_signature_date': sig_date,
|
||||
'pod_signed_by_user_id': user.id,
|
||||
'pod_signed_datetime': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
if task.sale_order_id:
|
||||
task.sale_order_id.write({
|
||||
'x_fc_pod_signature': signature_data,
|
||||
'x_fc_pod_client_name': client_name.strip(),
|
||||
'x_fc_pod_signature_date': sig_date,
|
||||
'x_fc_pod_signed_by_user_id': user.id,
|
||||
'x_fc_pod_signed_datetime': fields.Datetime.now(),
|
||||
})
|
||||
|
||||
return {
|
||||
'success': True,
|
||||
'message': 'Signature saved successfully',
|
||||
'redirect_url': f'/my/technician/task/{task_id}',
|
||||
}
|
||||
|
||||
except Exception as e:
|
||||
_logger.error(f"Error saving task POD signature: {e}")
|
||||
return {'success': False, 'error': str(e)}
|
||||
|
||||
def _generate_signed_pod_pdf(self, order, save_to_field=True):
|
||||
"""Generate a signed POD PDF with the signature embedded.
|
||||
|
||||
|
||||
206
fusion_authorizer_portal/controllers/portal_page11_sign.py
Normal file
206
fusion_authorizer_portal/controllers/portal_page11_sign.py
Normal file
@@ -0,0 +1,206 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
import base64
|
||||
import json
|
||||
import logging
|
||||
|
||||
from odoo import http, fields, _
|
||||
from odoo.http import request
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class Page11PublicSignController(http.Controller):
|
||||
|
||||
def _get_sign_request(self, token):
|
||||
"""Look up and validate a signing request by token."""
|
||||
req = request.env['fusion.page11.sign.request'].sudo().search([
|
||||
('access_token', '=', token),
|
||||
], limit=1)
|
||||
if not req:
|
||||
return None, 'not_found'
|
||||
if req.state == 'signed':
|
||||
return req, 'already_signed'
|
||||
if req.state == 'cancelled':
|
||||
return req, 'cancelled'
|
||||
if req.state == 'expired' or (
|
||||
req.expiry_date and req.expiry_date < fields.Datetime.now()
|
||||
):
|
||||
if req.state != 'expired':
|
||||
req.state = 'expired'
|
||||
return req, 'expired'
|
||||
return req, 'ok'
|
||||
|
||||
@http.route('/page11/sign/<string:token>', type='http', auth='public',
|
||||
website=True, sitemap=False)
|
||||
def page11_sign_form(self, token, **kw):
|
||||
"""Display the Page 11 signing form."""
|
||||
sign_req, status = self._get_sign_request(token)
|
||||
|
||||
if status == 'not_found':
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_page11_sign_invalid', {}
|
||||
)
|
||||
|
||||
if status in ('expired', 'cancelled'):
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_page11_sign_expired',
|
||||
{'sign_request': sign_req},
|
||||
)
|
||||
|
||||
if status == 'already_signed':
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_page11_sign_success',
|
||||
{'sign_request': sign_req, 'token': token},
|
||||
)
|
||||
|
||||
order = sign_req.sale_order_id
|
||||
partner = order.partner_id
|
||||
|
||||
assessment = request.env['fusion.assessment'].sudo().search([
|
||||
('sale_order_id', '=', order.id),
|
||||
], limit=1, order='create_date desc')
|
||||
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||
|
||||
client_first_name = ''
|
||||
client_last_name = ''
|
||||
client_middle_name = ''
|
||||
client_health_card = ''
|
||||
client_health_card_version = ''
|
||||
|
||||
if assessment:
|
||||
client_first_name = assessment.client_first_name or ''
|
||||
client_last_name = assessment.client_last_name or ''
|
||||
client_middle_name = assessment.client_middle_name or ''
|
||||
client_health_card = assessment.client_health_card or ''
|
||||
client_health_card_version = assessment.client_health_card_version or ''
|
||||
else:
|
||||
first, last = order._get_client_name_parts()
|
||||
client_first_name = first
|
||||
client_last_name = last
|
||||
|
||||
values = {
|
||||
'sign_request': sign_req,
|
||||
'order': order,
|
||||
'partner': partner,
|
||||
'assessment': assessment,
|
||||
'company': order.company_id,
|
||||
'token': token,
|
||||
'signer_type': sign_req.signer_type,
|
||||
'is_agent': sign_req.signer_type != 'client',
|
||||
'google_maps_api_key': google_maps_api_key,
|
||||
'client_first_name': client_first_name,
|
||||
'client_last_name': client_last_name,
|
||||
'client_middle_name': client_middle_name,
|
||||
'client_health_card': client_health_card,
|
||||
'client_health_card_version': client_health_card_version,
|
||||
}
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_page11_public_sign', values,
|
||||
)
|
||||
|
||||
@http.route('/page11/sign/<string:token>/submit', type='http',
|
||||
auth='public', methods=['POST'], website=True,
|
||||
csrf=True, sitemap=False)
|
||||
def page11_sign_submit(self, token, **post):
|
||||
"""Process the submitted Page 11 signature."""
|
||||
sign_req, status = self._get_sign_request(token)
|
||||
|
||||
if status != 'ok':
|
||||
return request.redirect(f'/page11/sign/{token}')
|
||||
|
||||
signature_data = post.get('signature_data', '')
|
||||
if not signature_data:
|
||||
return request.redirect(f'/page11/sign/{token}?error=no_signature')
|
||||
|
||||
if signature_data.startswith('data:image'):
|
||||
signature_data = signature_data.split(',', 1)[1]
|
||||
|
||||
consent_accepted = post.get('consent_declaration', '') == 'on'
|
||||
if not consent_accepted:
|
||||
return request.redirect(f'/page11/sign/{token}?error=no_consent')
|
||||
|
||||
signer_name = post.get('signer_name', sign_req.signer_name or '')
|
||||
chosen_signer_type = post.get('signer_type', sign_req.signer_type or 'client')
|
||||
consent_signed_by = 'applicant' if chosen_signer_type == 'client' else 'agent'
|
||||
|
||||
signer_type_labels = {
|
||||
'spouse': 'Spouse', 'parent': 'Parent',
|
||||
'legal_guardian': 'Legal Guardian',
|
||||
'poa': 'Power of Attorney',
|
||||
'public_trustee': 'Public Trustee',
|
||||
}
|
||||
|
||||
vals = {
|
||||
'signature_data': signature_data,
|
||||
'signer_name': signer_name,
|
||||
'signer_type': chosen_signer_type,
|
||||
'consent_declaration_accepted': True,
|
||||
'consent_signed_by': consent_signed_by,
|
||||
'signed_date': fields.Datetime.now(),
|
||||
'state': 'signed',
|
||||
'client_first_name': post.get('client_first_name', ''),
|
||||
'client_last_name': post.get('client_last_name', ''),
|
||||
'client_health_card': post.get('client_health_card', ''),
|
||||
'client_health_card_version': post.get('client_health_card_version', ''),
|
||||
}
|
||||
|
||||
if consent_signed_by == 'agent':
|
||||
vals.update({
|
||||
'agent_first_name': post.get('agent_first_name', ''),
|
||||
'agent_last_name': post.get('agent_last_name', ''),
|
||||
'agent_middle_initial': post.get('agent_middle_initial', ''),
|
||||
'agent_phone': post.get('agent_phone', ''),
|
||||
'agent_unit': post.get('agent_unit', ''),
|
||||
'agent_street_number': post.get('agent_street_number', ''),
|
||||
'agent_street': post.get('agent_street', ''),
|
||||
'agent_city': post.get('agent_city', ''),
|
||||
'agent_province': post.get('agent_province', 'Ontario'),
|
||||
'agent_postal_code': post.get('agent_postal_code', ''),
|
||||
'signer_relationship': signer_type_labels.get(chosen_signer_type, chosen_signer_type),
|
||||
})
|
||||
|
||||
sign_req.sudo().write(vals)
|
||||
|
||||
try:
|
||||
sign_req.sudo()._generate_signed_pdf()
|
||||
except Exception as e:
|
||||
_logger.error("PDF generation failed for sign request %s: %s", sign_req.id, e)
|
||||
|
||||
try:
|
||||
sign_req.sudo()._update_sale_order()
|
||||
except Exception as e:
|
||||
_logger.error("Sale order update failed for sign request %s: %s", sign_req.id, e)
|
||||
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_page11_sign_success',
|
||||
{'sign_request': sign_req, 'token': token},
|
||||
)
|
||||
|
||||
@http.route('/page11/sign/<string:token>/download', type='http',
|
||||
auth='public', website=True, sitemap=False)
|
||||
def page11_download_pdf(self, token, **kw):
|
||||
"""Download the signed Page 11 PDF."""
|
||||
sign_req = request.env['fusion.page11.sign.request'].sudo().search([
|
||||
('access_token', '=', token),
|
||||
('state', '=', 'signed'),
|
||||
], limit=1)
|
||||
|
||||
if not sign_req or not sign_req.signed_pdf:
|
||||
return request.redirect(f'/page11/sign/{token}')
|
||||
|
||||
pdf_content = base64.b64decode(sign_req.signed_pdf)
|
||||
filename = sign_req.signed_pdf_filename or 'Page11_Signed.pdf'
|
||||
|
||||
return request.make_response(
|
||||
pdf_content,
|
||||
headers=[
|
||||
('Content-Type', 'application/pdf'),
|
||||
('Content-Disposition', f'attachment; filename="{filename}"'),
|
||||
('Content-Length', str(len(pdf_content))),
|
||||
],
|
||||
)
|
||||
@@ -1,182 +0,0 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
# Copyright 2024-2026 Nexa Systems Inc.
|
||||
# License OPL-1 (Odoo Proprietary License v1.0)
|
||||
|
||||
from odoo import http, _, fields
|
||||
from odoo.http import request
|
||||
import base64
|
||||
import logging
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class LTCRepairPortal(http.Controller):
|
||||
|
||||
def _is_password_required(self):
|
||||
password = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.ltc_form_password', ''
|
||||
)
|
||||
return bool(password and password.strip())
|
||||
|
||||
def _process_photos(self, file_list, repair):
|
||||
attachment_ids = []
|
||||
for photo in file_list:
|
||||
if photo and photo.filename:
|
||||
data = photo.read()
|
||||
if data:
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': photo.filename,
|
||||
'datas': base64.b64encode(data),
|
||||
'res_model': 'fusion.ltc.repair',
|
||||
'res_id': repair.id,
|
||||
})
|
||||
attachment_ids.append(attachment.id)
|
||||
return attachment_ids
|
||||
|
||||
def _is_authenticated(self):
|
||||
if not request.env.user._is_public():
|
||||
return True
|
||||
if not self._is_password_required():
|
||||
return True
|
||||
return request.session.get('ltc_form_authenticated', False)
|
||||
|
||||
@http.route('/repair-form', type='http', auth='public', website=True,
|
||||
sitemap=False)
|
||||
def repair_form(self, **kw):
|
||||
if not self._is_authenticated():
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_password',
|
||||
{'error': kw.get('auth_error', False)}
|
||||
)
|
||||
|
||||
facilities = request.env['fusion.ltc.facility'].sudo().search(
|
||||
[('active', '=', True)], order='name'
|
||||
)
|
||||
is_technician = not request.env.user._is_public() and request.env.user.has_group(
|
||||
'base.group_user'
|
||||
)
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_form',
|
||||
{
|
||||
'facilities': facilities,
|
||||
'today': fields.Date.today(),
|
||||
'is_technician': is_technician,
|
||||
}
|
||||
)
|
||||
|
||||
@http.route('/repair-form/auth', type='http', auth='public',
|
||||
website=True, methods=['POST'], csrf=True)
|
||||
def repair_form_auth(self, **kw):
|
||||
stored_password = request.env['ir.config_parameter'].sudo().get_param(
|
||||
'fusion_claims.ltc_form_password', ''
|
||||
).strip()
|
||||
|
||||
entered_password = (kw.get('password', '') or '').strip()
|
||||
|
||||
if stored_password and entered_password == stored_password:
|
||||
request.session['ltc_form_authenticated'] = True
|
||||
return request.redirect('/repair-form')
|
||||
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_password',
|
||||
{'error': True}
|
||||
)
|
||||
|
||||
@http.route('/repair-form/submit', type='http', auth='public',
|
||||
website=True, methods=['POST'], csrf=True)
|
||||
def repair_form_submit(self, **kw):
|
||||
if not self._is_authenticated():
|
||||
return request.redirect('/repair-form')
|
||||
|
||||
try:
|
||||
facility_id = int(kw.get('facility_id', 0))
|
||||
if not facility_id:
|
||||
return request.redirect('/repair-form?error=facility')
|
||||
|
||||
vals = {
|
||||
'facility_id': facility_id,
|
||||
'client_name': kw.get('client_name', '').strip(),
|
||||
'room_number': kw.get('room_number', '').strip(),
|
||||
'product_serial': kw.get('product_serial', '').strip(),
|
||||
'issue_description': kw.get('issue_description', '').strip(),
|
||||
'issue_reported_date': kw.get('issue_reported_date') or fields.Date.today(),
|
||||
'is_emergency': kw.get('is_emergency') == 'on',
|
||||
'poa_name': kw.get('poa_name', '').strip() or False,
|
||||
'poa_phone': kw.get('poa_phone', '').strip() or False,
|
||||
'source': 'portal_form',
|
||||
}
|
||||
|
||||
if not vals['client_name']:
|
||||
return request.redirect('/repair-form?error=name')
|
||||
if not vals['issue_description']:
|
||||
return request.redirect('/repair-form?error=description')
|
||||
|
||||
before_files = request.httprequest.files.getlist('before_photos')
|
||||
has_before = any(f and f.filename for f in before_files)
|
||||
if not has_before:
|
||||
return request.redirect('/repair-form?error=photos')
|
||||
|
||||
repair = request.env['fusion.ltc.repair'].sudo().create(vals)
|
||||
|
||||
before_ids = self._process_photos(before_files, repair)
|
||||
if before_ids:
|
||||
repair.sudo().write({
|
||||
'before_photo_ids': [(6, 0, before_ids)],
|
||||
})
|
||||
|
||||
after_files = request.httprequest.files.getlist('after_photos')
|
||||
after_ids = self._process_photos(after_files, repair)
|
||||
if after_ids:
|
||||
repair.sudo().write({
|
||||
'after_photo_ids': [(6, 0, after_ids)],
|
||||
})
|
||||
|
||||
resolved = kw.get('resolved') == 'yes'
|
||||
if resolved:
|
||||
resolution = kw.get('resolution_description', '').strip()
|
||||
if resolution:
|
||||
repair.sudo().write({
|
||||
'resolution_description': resolution,
|
||||
'issue_fixed_date': fields.Date.today(),
|
||||
})
|
||||
|
||||
repair.sudo().activity_schedule(
|
||||
'mail.mail_activity_data_todo',
|
||||
summary=_('New repair request from portal: %s', repair.display_client_name),
|
||||
note=_(
|
||||
'Repair request submitted via portal form for %s at %s (Room %s).',
|
||||
repair.display_client_name,
|
||||
repair.facility_id.name,
|
||||
repair.room_number or 'N/A',
|
||||
),
|
||||
)
|
||||
|
||||
ip_address = request.httprequest.headers.get(
|
||||
'X-Forwarded-For', request.httprequest.remote_addr
|
||||
)
|
||||
if ip_address and ',' in ip_address:
|
||||
ip_address = ip_address.split(',')[0].strip()
|
||||
|
||||
try:
|
||||
request.env['fusion.ltc.form.submission'].sudo().create({
|
||||
'form_type': 'repair',
|
||||
'repair_id': repair.id,
|
||||
'facility_id': facility_id,
|
||||
'client_name': vals['client_name'],
|
||||
'room_number': vals['room_number'],
|
||||
'product_serial': vals['product_serial'],
|
||||
'is_emergency': vals['is_emergency'],
|
||||
'ip_address': ip_address or '',
|
||||
'status': 'processed',
|
||||
})
|
||||
except Exception:
|
||||
_logger.warning('Failed to log form submission', exc_info=True)
|
||||
|
||||
return request.render(
|
||||
'fusion_authorizer_portal.portal_ltc_repair_thank_you',
|
||||
{'repair': repair}
|
||||
)
|
||||
|
||||
except Exception:
|
||||
_logger.exception('Error submitting LTC repair form')
|
||||
return request.redirect('/repair-form?error=server')
|
||||
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal file
327
fusion_authorizer_portal/controllers/portal_schedule.py
Normal file
@@ -0,0 +1,327 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
|
||||
from odoo import http, _, fields
|
||||
from odoo.http import request
|
||||
from odoo.addons.portal.controllers.portal import CustomerPortal
|
||||
from odoo.exceptions import AccessError, ValidationError
|
||||
from datetime import datetime, timedelta
|
||||
import json
|
||||
import logging
|
||||
import pytz
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
class PortalSchedule(CustomerPortal):
|
||||
"""Portal controller for appointment scheduling and calendar management."""
|
||||
|
||||
def _get_schedule_values(self):
|
||||
"""Common values for schedule pages."""
|
||||
ICP = request.env['ir.config_parameter'].sudo()
|
||||
g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848')
|
||||
g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7')
|
||||
g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad')
|
||||
gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end)
|
||||
google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '')
|
||||
|
||||
return {
|
||||
'portal_gradient': gradient,
|
||||
'google_maps_api_key': google_maps_api_key,
|
||||
}
|
||||
|
||||
def _get_user_timezone(self):
|
||||
tz_name = request.env.user.tz or 'America/Toronto'
|
||||
try:
|
||||
return pytz.timezone(tz_name)
|
||||
except pytz.exceptions.UnknownTimeZoneError:
|
||||
return pytz.timezone('America/Toronto')
|
||||
|
||||
def _get_appointment_types(self):
|
||||
"""Get appointment types available to the current user."""
|
||||
return request.env['appointment.type'].sudo().search([
|
||||
('staff_user_ids', 'in', [request.env.user.id]),
|
||||
])
|
||||
|
||||
@http.route(['/my/schedule'], type='http', auth='user', website=True)
|
||||
def schedule_page(self, **kw):
|
||||
"""Schedule overview: upcoming appointments and shareable link."""
|
||||
partner = request.env.user.partner_id
|
||||
user = request.env.user
|
||||
now = fields.Datetime.now()
|
||||
|
||||
upcoming_events = request.env['calendar.event'].sudo().search([
|
||||
('partner_ids', 'in', [partner.id]),
|
||||
('start', '>=', now),
|
||||
], order='start asc', limit=20)
|
||||
|
||||
today_events = request.env['calendar.event'].sudo().search([
|
||||
('partner_ids', 'in', [partner.id]),
|
||||
('start', '>=', now.replace(hour=0, minute=0, second=0)),
|
||||
('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)),
|
||||
], order='start asc')
|
||||
|
||||
invite = request.env['appointment.invite'].sudo().search([
|
||||
('staff_user_ids', 'in', [user.id]),
|
||||
], limit=1)
|
||||
share_url = invite.book_url if invite else ''
|
||||
|
||||
appointment_types = self._get_appointment_types()
|
||||
tz = self._get_user_timezone()
|
||||
|
||||
values = self._get_schedule_values()
|
||||
values.update({
|
||||
'page_name': 'schedule',
|
||||
'upcoming_events': upcoming_events,
|
||||
'today_events': today_events,
|
||||
'share_url': share_url,
|
||||
'appointment_types': appointment_types,
|
||||
'user_tz': tz,
|
||||
'now': now,
|
||||
})
|
||||
return request.render('fusion_authorizer_portal.portal_schedule_page', values)
|
||||
|
||||
@http.route(['/my/schedule/book'], type='http', auth='user', website=True)
|
||||
def schedule_book(self, appointment_type_id=None, **kw):
|
||||
"""Booking form for a new appointment."""
|
||||
appointment_types = self._get_appointment_types()
|
||||
if not appointment_types:
|
||||
return request.redirect('/my/schedule')
|
||||
|
||||
if appointment_type_id:
|
||||
selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
|
||||
if not selected_type.exists():
|
||||
selected_type = appointment_types[0]
|
||||
else:
|
||||
selected_type = appointment_types[0]
|
||||
|
||||
values = self._get_schedule_values()
|
||||
values.update({
|
||||
'page_name': 'schedule_book',
|
||||
'appointment_types': appointment_types,
|
||||
'selected_type': selected_type,
|
||||
'now': fields.Datetime.now(),
|
||||
'error': kw.get('error'),
|
||||
'success': kw.get('success'),
|
||||
})
|
||||
return request.render('fusion_authorizer_portal.portal_schedule_book', values)
|
||||
|
||||
@http.route('/my/schedule/available-slots', type='json', auth='user', website=True)
|
||||
def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw):
|
||||
"""JSON-RPC endpoint: return available time slots for a date."""
|
||||
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
|
||||
if not appointment_type.exists():
|
||||
return {'error': 'Appointment type not found', 'slots': []}
|
||||
|
||||
user = request.env.user
|
||||
tz_name = user.tz or 'America/Toronto'
|
||||
tz = self._get_user_timezone()
|
||||
|
||||
ref_date = fields.Datetime.now()
|
||||
slot_data = appointment_type._get_appointment_slots(
|
||||
timezone=tz_name,
|
||||
filter_users=request.env['res.users'].sudo().browse(user.id),
|
||||
asked_capacity=1,
|
||||
reference_date=ref_date,
|
||||
)
|
||||
|
||||
filtered_slots = []
|
||||
target_date = None
|
||||
if selected_date:
|
||||
try:
|
||||
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
||||
except ValueError:
|
||||
return {'error': 'Invalid date format', 'slots': []}
|
||||
|
||||
for month_data in slot_data:
|
||||
for week in month_data.get('weeks', []):
|
||||
for day_info in week:
|
||||
if not day_info:
|
||||
continue
|
||||
day = day_info.get('day')
|
||||
if target_date and day != target_date:
|
||||
continue
|
||||
for slot in day_info.get('slots', []):
|
||||
slot_dt_str = slot.get('datetime')
|
||||
if not slot_dt_str:
|
||||
continue
|
||||
filtered_slots.append({
|
||||
'datetime': slot_dt_str,
|
||||
'start_hour': slot.get('start_hour', ''),
|
||||
'end_hour': slot.get('end_hour', ''),
|
||||
'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)),
|
||||
'staff_user_id': slot.get('staff_user_id', user.id),
|
||||
})
|
||||
|
||||
available_dates = []
|
||||
if not target_date:
|
||||
seen = set()
|
||||
for month_data in slot_data:
|
||||
for week in month_data.get('weeks', []):
|
||||
for day_info in week:
|
||||
if not day_info:
|
||||
continue
|
||||
day = day_info.get('day')
|
||||
if day and day_info.get('slots') and str(day) not in seen:
|
||||
seen.add(str(day))
|
||||
available_dates.append(str(day))
|
||||
|
||||
return {
|
||||
'slots': filtered_slots,
|
||||
'available_dates': sorted(available_dates),
|
||||
'duration': appointment_type.appointment_duration,
|
||||
'timezone': tz_name,
|
||||
}
|
||||
|
||||
@http.route('/my/schedule/week-events', type='json', auth='user', website=True)
|
||||
def schedule_week_events(self, selected_date, **kw):
|
||||
"""Return the user's calendar events for the Mon-Sun week containing selected_date."""
|
||||
try:
|
||||
target = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
||||
except (ValueError, TypeError):
|
||||
return {'error': 'Invalid date format', 'events': [], 'week_days': []}
|
||||
|
||||
monday = target - timedelta(days=target.weekday())
|
||||
sunday = monday + timedelta(days=6)
|
||||
|
||||
partner = request.env.user.partner_id
|
||||
tz = self._get_user_timezone()
|
||||
|
||||
monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time()))
|
||||
sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time()))
|
||||
monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
|
||||
events = request.env['calendar.event'].sudo().search([
|
||||
('partner_ids', 'in', [partner.id]),
|
||||
('start', '>=', monday_start_utc),
|
||||
('start', '<=', sunday_end_utc),
|
||||
], order='start asc')
|
||||
|
||||
event_list = []
|
||||
for ev in events:
|
||||
start_utc = ev.start
|
||||
stop_utc = ev.stop
|
||||
start_local = pytz.utc.localize(start_utc).astimezone(tz)
|
||||
stop_local = pytz.utc.localize(stop_utc).astimezone(tz)
|
||||
event_list.append({
|
||||
'name': ev.name or '',
|
||||
'start': start_local.strftime('%Y-%m-%d %H:%M'),
|
||||
'end': stop_local.strftime('%Y-%m-%d %H:%M'),
|
||||
'start_time': start_local.strftime('%I:%M %p'),
|
||||
'end_time': stop_local.strftime('%I:%M %p'),
|
||||
'day_of_week': start_local.weekday(),
|
||||
'date': start_local.strftime('%Y-%m-%d'),
|
||||
'location': ev.location or '',
|
||||
'duration': ev.duration,
|
||||
})
|
||||
|
||||
day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun']
|
||||
week_days = []
|
||||
for i in range(7):
|
||||
day = monday + timedelta(days=i)
|
||||
week_days.append({
|
||||
'label': day_labels[i],
|
||||
'date': day.strftime('%Y-%m-%d'),
|
||||
'day_num': day.day,
|
||||
'is_selected': day == target,
|
||||
})
|
||||
|
||||
return {
|
||||
'events': event_list,
|
||||
'week_days': week_days,
|
||||
'selected_date': selected_date,
|
||||
}
|
||||
|
||||
@http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST'])
|
||||
def schedule_book_submit(self, **post):
|
||||
"""Process the booking form submission."""
|
||||
appointment_type_id = int(post.get('appointment_type_id', 0))
|
||||
appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id)
|
||||
if not appointment_type.exists():
|
||||
return request.redirect('/my/schedule/book?error=Invalid+appointment+type')
|
||||
|
||||
client_name = (post.get('client_name') or '').strip()
|
||||
client_street = (post.get('client_street') or '').strip()
|
||||
client_city = (post.get('client_city') or '').strip()
|
||||
client_province = (post.get('client_province') or '').strip()
|
||||
client_postal = (post.get('client_postal') or '').strip()
|
||||
notes = (post.get('notes') or '').strip()
|
||||
slot_datetime = (post.get('slot_datetime') or '').strip()
|
||||
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
|
||||
|
||||
if not client_name or not slot_datetime:
|
||||
return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required')
|
||||
|
||||
user = request.env.user
|
||||
tz = self._get_user_timezone()
|
||||
|
||||
try:
|
||||
start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
||||
start_dt_local = tz.localize(start_dt_naive)
|
||||
start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None)
|
||||
except (ValueError, Exception) as e:
|
||||
_logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e)
|
||||
return request.redirect('/my/schedule/book?error=Invalid+time+slot')
|
||||
|
||||
duration = float(slot_duration)
|
||||
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
|
||||
|
||||
is_valid = appointment_type._check_appointment_is_valid_slot(
|
||||
staff_user=user,
|
||||
resources=request.env['appointment.resource'],
|
||||
asked_capacity=1,
|
||||
timezone=str(tz),
|
||||
start_dt=start_dt_utc,
|
||||
duration=duration,
|
||||
allday=False,
|
||||
)
|
||||
if not is_valid:
|
||||
return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.')
|
||||
|
||||
address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p]
|
||||
location = ', '.join(address_parts)
|
||||
|
||||
description_lines = []
|
||||
if client_name:
|
||||
description_lines.append(f"Client: {client_name}")
|
||||
if location:
|
||||
description_lines.append(f"Address: {location}")
|
||||
if notes:
|
||||
description_lines.append(f"Notes: {notes}")
|
||||
description = '\n'.join(description_lines)
|
||||
|
||||
event_name = f"{client_name} - {appointment_type.name}"
|
||||
|
||||
booking_line_values = [{
|
||||
'appointment_user_id': user.id,
|
||||
'capacity_reserved': 1,
|
||||
'capacity_used': 1,
|
||||
}]
|
||||
|
||||
try:
|
||||
event_vals = appointment_type._prepare_calendar_event_values(
|
||||
asked_capacity=1,
|
||||
booking_line_values=booking_line_values,
|
||||
description=description,
|
||||
duration=duration,
|
||||
allday=False,
|
||||
appointment_invite=request.env['appointment.invite'],
|
||||
guests=request.env['res.partner'],
|
||||
name=event_name,
|
||||
customer=user.partner_id,
|
||||
staff_user=user,
|
||||
start=start_dt_utc,
|
||||
stop=stop_dt_utc,
|
||||
)
|
||||
event_vals['location'] = location
|
||||
event = request.env['calendar.event'].sudo().create(event_vals)
|
||||
|
||||
_logger.info(
|
||||
"Appointment booked: %s at %s (event ID: %s)",
|
||||
event_name, start_dt_utc, event.id,
|
||||
)
|
||||
except Exception as e:
|
||||
_logger.error("Failed to create appointment: %s", e)
|
||||
return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.')
|
||||
|
||||
return request.redirect('/my/schedule?success=Appointment+booked+successfully')
|
||||
Reference in New Issue
Block a user