- 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
328 lines
14 KiB
Python
328 lines
14 KiB
Python
# -*- 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')
|