1607 lines
68 KiB
Python
1607 lines
68 KiB
Python
# -*- coding: utf-8 -*-
|
|
|
|
import json
|
|
import hashlib
|
|
import logging
|
|
import secrets
|
|
|
|
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
|
|
from markupsafe import Markup
|
|
import pytz
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
# Google OAuth endpoints (same as model constants)
|
|
GOOGLE_AUTH_ENDPOINT = 'https://accounts.google.com/o/oauth2/auth'
|
|
GOOGLE_SCOPES = 'https://www.googleapis.com/auth/calendar https://www.googleapis.com/auth/userinfo.email'
|
|
|
|
# Microsoft OAuth endpoints
|
|
MICROSOFT_AUTH_ENDPOINT = 'https://login.microsoftonline.com/common/oauth2/v2.0/authorize'
|
|
MICROSOFT_SCOPES = 'offline_access openid Calendars.ReadWrite User.Read'
|
|
|
|
|
|
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)
|
|
|
|
return {
|
|
'portal_gradient': gradient,
|
|
'google_maps_api_key': self._get_maps_api_key(),
|
|
}
|
|
|
|
def _get_user_timezone(self):
|
|
return self._resolve_timezone(request.env.user)
|
|
|
|
def _resolve_timezone(self, user=None):
|
|
"""Resolve timezone: user pref -> browser cookie -> company calendar -> UTC."""
|
|
candidates = []
|
|
if user:
|
|
candidates.append(getattr(user, 'tz', None))
|
|
try:
|
|
candidates.append(request.httprequest.cookies.get('tz'))
|
|
except Exception:
|
|
pass
|
|
try:
|
|
cal = request.env.company.resource_calendar_id
|
|
if cal:
|
|
candidates.append(cal.tz)
|
|
except Exception:
|
|
pass
|
|
for tz_name in candidates:
|
|
if tz_name:
|
|
try:
|
|
return pytz.timezone(tz_name)
|
|
except pytz.exceptions.UnknownTimeZoneError:
|
|
continue
|
|
return pytz.UTC
|
|
|
|
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]),
|
|
])
|
|
|
|
def _get_user_prefs(self, user):
|
|
"""Return schedule preferences for the user, falling back to company defaults."""
|
|
ICP = request.env['ir.config_parameter'].sudo()
|
|
def _val(user_field, param_key, default):
|
|
v = getattr(user, user_field, None)
|
|
if v is not None and v != 0 and v != default:
|
|
return v
|
|
stored = ICP.get_param(param_key, '')
|
|
if stored:
|
|
try:
|
|
return type(default)(stored)
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return default
|
|
|
|
return {
|
|
'work_start': _val('x_fc_work_start', 'fusion_schedule.default_work_start', 9.0),
|
|
'work_end': _val('x_fc_work_end', 'fusion_schedule.default_work_end', 17.0),
|
|
'break_start': _val('x_fc_break_start', 'fusion_schedule.default_break_start', 12.0),
|
|
'break_duration': _val('x_fc_break_duration', 'fusion_schedule.default_break_duration', 0.5),
|
|
'travel_buffer': _val('x_fc_travel_buffer', 'fusion_schedule.default_travel_buffer', 30),
|
|
'home_address': user.x_fc_home_address or '',
|
|
'home_lat': user.x_fc_home_lat or 0.0,
|
|
'home_lng': user.x_fc_home_lng or 0.0,
|
|
}
|
|
|
|
def _get_maps_api_key(self):
|
|
"""Get Google Maps API key via fusion.api.service with fallback."""
|
|
try:
|
|
return request.env['fusion.api.service'].get_api_key(
|
|
provider_type='google_maps',
|
|
consumer='fusion_schedule',
|
|
feature='distance_matrix',
|
|
)
|
|
except Exception:
|
|
return request.env['ir.config_parameter'].sudo().get_param(
|
|
'fusion_claims.google_maps_api_key', ''
|
|
)
|
|
|
|
def _call_ai(self, messages, feature='general'):
|
|
"""Call OpenAI via fusion.api.service with fallback to direct HTTP."""
|
|
try:
|
|
return request.env['fusion.api.service'].call_openai(
|
|
consumer='fusion_schedule',
|
|
feature=feature,
|
|
messages=messages,
|
|
model='gpt-4o-mini',
|
|
)
|
|
except Exception:
|
|
import requests as req
|
|
api_key = request.env['ir.config_parameter'].sudo().get_param(
|
|
'fusion_claims.ai_api_key', ''
|
|
)
|
|
if not api_key:
|
|
raise
|
|
resp = req.post(
|
|
'https://api.openai.com/v1/chat/completions',
|
|
headers={
|
|
'Authorization': 'Bearer %s' % api_key,
|
|
'Content-Type': 'application/json',
|
|
},
|
|
json={
|
|
'model': 'gpt-4o-mini',
|
|
'messages': messages,
|
|
'max_tokens': 2048,
|
|
'temperature': 0.2,
|
|
},
|
|
timeout=30,
|
|
)
|
|
data = resp.json()
|
|
return data['choices'][0]['message']['content']
|
|
|
|
def _get_travel_time(self, from_lat, from_lng, to_lat, to_lng):
|
|
"""Travel time in minutes via Google Distance Matrix API."""
|
|
if not all([from_lat, from_lng, to_lat, to_lng]):
|
|
return 0
|
|
api_key = self._get_maps_api_key()
|
|
if not api_key:
|
|
return 0
|
|
try:
|
|
import requests as req
|
|
resp = req.get(
|
|
'https://maps.googleapis.com/maps/api/distancematrix/json',
|
|
params={
|
|
'origins': '%s,%s' % (from_lat, from_lng),
|
|
'destinations': '%s,%s' % (to_lat, to_lng),
|
|
'mode': 'driving',
|
|
'avoid': 'tolls',
|
|
'departure_time': 'now',
|
|
'key': api_key,
|
|
},
|
|
timeout=5,
|
|
)
|
|
data = resp.json()
|
|
if data.get('status') == 'OK':
|
|
element = data['rows'][0]['elements'][0]
|
|
if element.get('status') == 'OK':
|
|
duration = element.get('duration_in_traffic', element.get('duration', {}))
|
|
return max(1, int(duration.get('value', 0) / 60))
|
|
except Exception:
|
|
_logger.warning('Distance Matrix API call failed', exc_info=True)
|
|
return 0
|
|
|
|
def _geocode_address(self, address):
|
|
"""Geocode an address string, return (lat, lng) or (0.0, 0.0)."""
|
|
if not address:
|
|
return 0.0, 0.0
|
|
api_key = self._get_maps_api_key()
|
|
if not api_key:
|
|
return 0.0, 0.0
|
|
try:
|
|
import requests as req
|
|
resp = req.get(
|
|
'https://maps.googleapis.com/maps/api/geocode/json',
|
|
params={'address': address, 'key': api_key, 'region': 'ca'},
|
|
timeout=10,
|
|
)
|
|
data = resp.json()
|
|
if data.get('status') == 'OK' and data.get('results'):
|
|
loc = data['results'][0]['geometry']['location']
|
|
return loc.get('lat', 0.0), loc.get('lng', 0.0)
|
|
except Exception:
|
|
_logger.warning('Geocoding failed for %s', address, exc_info=True)
|
|
return 0.0, 0.0
|
|
|
|
def _create_travel_blocks(self, new_event, staff_user):
|
|
"""After booking, calculate travel time from/to adjacent appointments
|
|
and create 'Travel' placeholder events to block the calendar."""
|
|
if not new_event.x_fc_address_lat or not new_event.x_fc_address_lng:
|
|
return
|
|
|
|
prefs = self._get_user_prefs(staff_user)
|
|
min_buffer = prefs['travel_buffer']
|
|
|
|
tz = self._resolve_timezone(staff_user)
|
|
|
|
event_date = new_event.start.date()
|
|
day_start = tz.localize(datetime.combine(event_date, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
|
day_end = tz.localize(datetime.combine(event_date, datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
day_events = request.env['calendar.event'].sudo().search([
|
|
('partner_ids', 'in', [staff_user.partner_id.id]),
|
|
('start', '>=', day_start),
|
|
('start', '<=', day_end),
|
|
('x_fc_is_travel_block', '!=', True),
|
|
('id', '!=', new_event.id),
|
|
], order='start asc')
|
|
|
|
prev_event = None
|
|
next_event = None
|
|
for ev in day_events:
|
|
if ev.stop <= new_event.start:
|
|
prev_event = ev
|
|
elif ev.start >= new_event.stop and not next_event:
|
|
next_event = ev
|
|
|
|
CalEvent = request.env['calendar.event'].sudo().with_context(
|
|
no_calendar_sync=True, dont_notify=True,
|
|
mail_create_nosubscribe=True, mail_create_nolog=True,
|
|
no_mail_notification=True, no_mail_to_attendees=True,
|
|
)
|
|
|
|
if prev_event and prev_event.x_fc_address_lat and prev_event.x_fc_address_lng:
|
|
travel_min = self._get_travel_time(
|
|
prev_event.x_fc_address_lat, prev_event.x_fc_address_lng,
|
|
new_event.x_fc_address_lat, new_event.x_fc_address_lng,
|
|
)
|
|
travel_min = max(travel_min, min_buffer)
|
|
if travel_min > 0:
|
|
travel_end = new_event.start
|
|
travel_start = travel_end - timedelta(minutes=travel_min)
|
|
if travel_start >= prev_event.stop:
|
|
CalEvent.create({
|
|
'name': 'Travel to %s' % (new_event.name or 'appointment'),
|
|
'start': travel_start,
|
|
'stop': travel_end,
|
|
'duration': travel_min / 60.0,
|
|
'user_id': staff_user.id,
|
|
'partner_ids': [(4, staff_user.partner_id.id)],
|
|
'show_as': 'busy',
|
|
'x_fc_is_travel_block': True,
|
|
'x_fc_travel_minutes_before': travel_min,
|
|
})
|
|
new_event.sudo().write({'x_fc_travel_minutes_before': travel_min})
|
|
|
|
if next_event and next_event.x_fc_address_lat and next_event.x_fc_address_lng:
|
|
travel_min = self._get_travel_time(
|
|
new_event.x_fc_address_lat, new_event.x_fc_address_lng,
|
|
next_event.x_fc_address_lat, next_event.x_fc_address_lng,
|
|
)
|
|
travel_min = max(travel_min, min_buffer)
|
|
if travel_min > 0:
|
|
travel_start = new_event.stop
|
|
travel_end = travel_start + timedelta(minutes=travel_min)
|
|
if travel_end <= next_event.start:
|
|
CalEvent.create({
|
|
'name': 'Travel to %s' % (next_event.name or 'appointment'),
|
|
'start': travel_start,
|
|
'stop': travel_end,
|
|
'duration': travel_min / 60.0,
|
|
'user_id': staff_user.id,
|
|
'partner_ids': [(4, staff_user.partner_id.id)],
|
|
'show_as': 'busy',
|
|
'x_fc_is_travel_block': True,
|
|
'x_fc_travel_minutes_before': travel_min,
|
|
})
|
|
next_event.sudo().write({'x_fc_travel_minutes_before': travel_min})
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Schedule Page
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route(['/my/schedule'], type='http', auth='user', website=True)
|
|
def schedule_page(self, **kw):
|
|
"""Schedule overview: upcoming appointments, connected calendars, and shareable link."""
|
|
partner = request.env.user.partner_id
|
|
user = request.env.user
|
|
now = fields.Datetime.now()
|
|
tz = self._get_user_timezone()
|
|
|
|
now_local = pytz.utc.localize(now).astimezone(tz)
|
|
today_local = now_local.date()
|
|
today_start_local = tz.localize(datetime.combine(today_local, datetime.min.time()))
|
|
today_end_local = tz.localize(datetime.combine(today_local, datetime.max.time()))
|
|
today_start_utc = today_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
|
today_end_utc = today_end_local.astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
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', '>=', today_start_utc),
|
|
('start', '<=', today_end_utc),
|
|
], 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()
|
|
|
|
calendar_accounts = request.env['fusion.calendar.account'].sudo().search([
|
|
('x_fc_user_id', '=', user.id),
|
|
('x_fc_active', '=', True),
|
|
])
|
|
|
|
booking_slug = user.x_fc_schedule_slug or ''
|
|
booking_enabled = user.x_fc_booking_enabled
|
|
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
public_booking_url = '%s/schedule/%s' % (base_url, booking_slug) if booking_slug else ''
|
|
|
|
def _event_source(ev):
|
|
if ev.x_fc_source_account_id:
|
|
return {
|
|
'provider': ev.x_fc_source_account_id.x_fc_provider or '',
|
|
'email': ev.x_fc_source_account_id.x_fc_email or '',
|
|
}
|
|
return {'provider': 'odoo', 'email': ''}
|
|
|
|
event_sources = {ev.id: _event_source(ev) for ev in (today_events | upcoming_events)}
|
|
|
|
user_prefs = self._get_user_prefs(user)
|
|
|
|
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_local,
|
|
'calendar_accounts': calendar_accounts,
|
|
'booking_slug': booking_slug,
|
|
'booking_enabled': booking_enabled,
|
|
'public_booking_url': public_booking_url,
|
|
'event_sources': event_sources,
|
|
'user_prefs': user_prefs,
|
|
'success': kw.get('success', ''),
|
|
'error': kw.get('error', ''),
|
|
})
|
|
return request.render('fusion_schedule.portal_schedule_page', values)
|
|
|
|
@http.route('/my/schedule/preferences', type='jsonrpc', auth='user', website=True)
|
|
def schedule_save_preferences(self, **kw):
|
|
"""Save the user's schedule preferences."""
|
|
user = request.env.user
|
|
vals = {}
|
|
if 'work_start' in kw:
|
|
vals['x_fc_work_start'] = float(kw['work_start'])
|
|
if 'work_end' in kw:
|
|
vals['x_fc_work_end'] = float(kw['work_end'])
|
|
if 'break_start' in kw:
|
|
vals['x_fc_break_start'] = float(kw['break_start'])
|
|
if 'break_duration' in kw:
|
|
vals['x_fc_break_duration'] = float(kw['break_duration'])
|
|
if 'travel_buffer' in kw:
|
|
vals['x_fc_travel_buffer'] = int(kw['travel_buffer'])
|
|
if 'home_address' in kw:
|
|
addr = (kw['home_address'] or '').strip()
|
|
vals['x_fc_home_address'] = addr
|
|
if addr:
|
|
lat, lng = self._geocode_address(addr)
|
|
vals['x_fc_home_lat'] = lat
|
|
vals['x_fc_home_lng'] = lng
|
|
else:
|
|
vals['x_fc_home_lat'] = 0.0
|
|
vals['x_fc_home_lng'] = 0.0
|
|
|
|
if vals:
|
|
user.sudo().write(vals)
|
|
return {'success': True}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Booking Form
|
|
# -------------------------------------------------------------------------
|
|
|
|
@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]
|
|
|
|
tz = self._get_user_timezone()
|
|
now_local = pytz.utc.localize(fields.Datetime.now()).astimezone(tz)
|
|
|
|
values = self._get_schedule_values()
|
|
values.update({
|
|
'page_name': 'schedule_book',
|
|
'appointment_types': appointment_types,
|
|
'selected_type': selected_type,
|
|
'now': now_local,
|
|
'error': kw.get('error'),
|
|
'success': kw.get('success'),
|
|
})
|
|
return request.render('fusion_schedule.portal_schedule_book', values)
|
|
|
|
@staticmethod
|
|
def _format_hour(hour_float):
|
|
"""Convert a decimal hour (e.g. 13.5) to '1:30 PM'."""
|
|
h = int(hour_float)
|
|
m = int(round((hour_float - h) * 60))
|
|
period = 'AM' if h < 12 else 'PM'
|
|
display_h = h % 12
|
|
if display_h == 0:
|
|
display_h = 12
|
|
return '%d:%02d %s' % (display_h, m, period)
|
|
|
|
def _generate_available_slots(self, appointment_type, staff_user, target_date):
|
|
"""Build available time slots for *target_date*.
|
|
|
|
Uses the staff user's schedule preferences (work hours, break, travel
|
|
buffer) when available, falling back to appointment-type slot
|
|
definitions. Travel buffer time after each existing appointment is
|
|
enforced so back-to-back bookings respect minimum travel gaps.
|
|
"""
|
|
tz = self._resolve_timezone(staff_user)
|
|
|
|
duration = appointment_type.appointment_duration or 1.0
|
|
prefs = self._get_user_prefs(staff_user)
|
|
work_start = prefs['work_start']
|
|
work_end = prefs['work_end']
|
|
break_start = prefs['break_start']
|
|
break_end = break_start + prefs['break_duration']
|
|
travel_buffer_hrs = prefs['travel_buffer'] / 60.0
|
|
|
|
weekday_str = str(target_date.isoweekday())
|
|
day_slots = appointment_type.sudo().slot_ids.filtered(
|
|
lambda s: s.slot_type == 'recurring' and s.weekday == weekday_str
|
|
)
|
|
|
|
candidate_hours = set()
|
|
|
|
if day_slots:
|
|
for slot_def in day_slots.sorted(key=lambda s: s.start_hour):
|
|
s = max(slot_def.start_hour, work_start)
|
|
e = slot_def.end_hour or (slot_def.start_hour + duration)
|
|
e = min(e, work_end)
|
|
h = s
|
|
while h + duration <= e + 0.001:
|
|
candidate_hours.add(round(h, 2))
|
|
h += duration
|
|
|
|
h = work_start
|
|
while h + duration <= work_end + 0.001:
|
|
candidate_hours.add(round(h, 2))
|
|
h += duration
|
|
|
|
candidate_hours = sorted(candidate_hours)
|
|
|
|
now_utc = datetime.utcnow()
|
|
now_local = pytz.utc.localize(now_utc).astimezone(tz)
|
|
|
|
day_start_utc = tz.localize(datetime.combine(target_date, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
|
day_end_utc = tz.localize(datetime.combine(target_date, datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
existing_events = request.env['calendar.event'].sudo().search([
|
|
('partner_ids', 'in', [staff_user.partner_id.id]),
|
|
('start', '<=', day_end_utc),
|
|
('stop', '>=', day_start_utc),
|
|
])
|
|
busy_ranges = sorted(
|
|
[(ev.start, ev.stop) for ev in existing_events],
|
|
key=lambda r: r[0],
|
|
)
|
|
|
|
midnight_local = datetime.combine(target_date, datetime.min.time())
|
|
result = []
|
|
for start_hour in candidate_hours:
|
|
if start_hour < work_start or start_hour + duration > work_end + 0.001:
|
|
continue
|
|
|
|
slot_overlaps_break = (start_hour < break_end and start_hour + duration > break_start)
|
|
if slot_overlaps_break:
|
|
continue
|
|
|
|
slot_start_local = tz.localize(midnight_local + timedelta(hours=start_hour))
|
|
if slot_start_local <= now_local:
|
|
continue
|
|
|
|
slot_start_utc = slot_start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
|
slot_end_utc = slot_start_utc + timedelta(hours=duration)
|
|
|
|
conflict = False
|
|
for b_start, b_end in busy_ranges:
|
|
buffered_end = b_end + timedelta(hours=travel_buffer_hrs)
|
|
if slot_start_utc < buffered_end and slot_end_utc > b_start:
|
|
conflict = True
|
|
break
|
|
if conflict:
|
|
continue
|
|
|
|
result.append({
|
|
'datetime': slot_start_utc.strftime('%Y-%m-%d %H:%M:%S'),
|
|
'start_hour': self._format_hour(start_hour),
|
|
'end_hour': self._format_hour(start_hour + duration),
|
|
'duration': str(duration),
|
|
'staff_user_id': staff_user.id,
|
|
})
|
|
|
|
return result
|
|
|
|
@http.route('/my/schedule/available-slots', type='jsonrpc', auth='user', website=True)
|
|
def schedule_available_slots(self, appointment_type_id=0, selected_date=None, **kw):
|
|
"""JSON-RPC endpoint: return available time slots for a date."""
|
|
type_id = int(appointment_type_id)
|
|
if type_id:
|
|
appointment_type = request.env['appointment.type'].sudo().browse(type_id)
|
|
else:
|
|
appointment_type = self._get_appointment_types()[:1]
|
|
if not appointment_type.exists():
|
|
return {'error': 'Appointment type not found', 'slots': []}
|
|
|
|
user = request.env.user
|
|
tz = self._resolve_timezone(user)
|
|
|
|
if not selected_date:
|
|
return {'error': 'Date is required', 'slots': []}
|
|
|
|
try:
|
|
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return {'error': 'Invalid date format', 'slots': []}
|
|
|
|
slots = self._generate_available_slots(appointment_type, user, target_date)
|
|
|
|
return {
|
|
'slots': slots,
|
|
'duration': appointment_type.appointment_duration,
|
|
'timezone': str(tz),
|
|
}
|
|
|
|
@http.route('/my/schedule/week-events', type='jsonrpc', 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)
|
|
|
|
# Source account info for colour coding
|
|
source_provider = ''
|
|
source_email = ''
|
|
if ev.x_fc_source_account_id:
|
|
source_provider = ev.x_fc_source_account_id.x_fc_provider or ''
|
|
source_email = ev.x_fc_source_account_id.x_fc_email or ''
|
|
|
|
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,
|
|
'source_provider': source_provider,
|
|
'source_email': source_email,
|
|
})
|
|
|
|
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_email = (post.get('client_email') or '').strip()
|
|
client_phone = (post.get('client_phone') 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))
|
|
|
|
try:
|
|
client_lat = float(post.get('client_lat', 0))
|
|
client_lng = float(post.get('client_lng', 0))
|
|
except (ValueError, TypeError):
|
|
client_lat, client_lng = 0.0, 0.0
|
|
|
|
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_utc = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
|
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)
|
|
|
|
start_dt_local = pytz.utc.localize(start_dt_utc).astimezone(tz)
|
|
available = self._generate_available_slots(appointment_type, user, start_dt_local.date())
|
|
is_valid = any(s['datetime'] == slot_datetime for s in available)
|
|
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("Client: %s" % client_name)
|
|
if client_email:
|
|
description_lines.append("Email: %s" % client_email)
|
|
if client_phone:
|
|
description_lines.append("Phone: %s" % client_phone)
|
|
if location:
|
|
description_lines.append("Address: %s" % location)
|
|
if notes:
|
|
description_lines.append("Notes: %s" % notes)
|
|
description = '\n'.join(description_lines)
|
|
|
|
event_name = "%s - %s" % (client_name, appointment_type.name)
|
|
|
|
partner_ids = [(4, user.partner_id.id)]
|
|
client_partner = None
|
|
if client_email:
|
|
Partner = request.env['res.partner'].sudo()
|
|
client_partner = Partner.search([('email', '=ilike', client_email)], limit=1)
|
|
if not client_partner:
|
|
client_partner = Partner.create({
|
|
'name': client_name,
|
|
'email': client_email,
|
|
'phone': client_phone or False,
|
|
})
|
|
elif client_phone and not client_partner.phone:
|
|
client_partner.phone = client_phone
|
|
partner_ids.append((4, client_partner.id))
|
|
|
|
manage_token = secrets.token_hex(16)
|
|
|
|
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_vals['partner_ids'] = partner_ids
|
|
if client_partner:
|
|
event_vals.setdefault('attendee_ids', []).append(
|
|
(0, 0, {'partner_id': client_partner.id, 'state': 'needsAction'})
|
|
)
|
|
event_vals['x_fc_manage_token'] = manage_token
|
|
event_vals['x_fc_client_email'] = client_email or False
|
|
event_vals['x_fc_client_phone'] = client_phone or False
|
|
event_vals['x_fc_address_lat'] = client_lat
|
|
event_vals['x_fc_address_lng'] = client_lng
|
|
event = request.env['calendar.event'].sudo().with_context(
|
|
dont_notify=True, no_mail_notification=True,
|
|
).create(event_vals)
|
|
|
|
_logger.info(
|
|
"Appointment booked: %s at %s (event ID: %s)",
|
|
event_name, start_dt_utc, event.id,
|
|
)
|
|
|
|
try:
|
|
if client_email:
|
|
template = request.env.ref(
|
|
'fusion_schedule.fusion_schedule_booking_confirmation',
|
|
raise_if_not_found=False,
|
|
)
|
|
if template:
|
|
template.sudo().send_mail(event.id, force_send=True)
|
|
except Exception as me:
|
|
_logger.warning("Booking confirmation email failed for event %s: %s", event.id, me)
|
|
|
|
try:
|
|
self._create_travel_blocks(event, user)
|
|
except Exception as te:
|
|
_logger.warning("Travel block creation failed for event %s: %s", event.id, te)
|
|
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')
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Event Actions — Reschedule / Cancel (Staff)
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/my/schedule/event/cancel', type='jsonrpc', auth='user', website=True)
|
|
def schedule_event_cancel(self, event_id, **kw):
|
|
"""Cancel (delete) a calendar event owned by the current user."""
|
|
partner = request.env.user.partner_id
|
|
event = request.env['calendar.event'].sudo().browse(int(event_id))
|
|
if not event.exists() or partner not in event.partner_ids:
|
|
return {'success': False, 'error': 'Event not found or access denied.'}
|
|
|
|
try:
|
|
event.unlink()
|
|
return {'success': True}
|
|
except Exception as e:
|
|
_logger.exception("Failed to cancel event %s: %s", event_id, e)
|
|
return {'success': False, 'error': 'Failed to cancel event.'}
|
|
|
|
@http.route('/my/schedule/event/reschedule', type='jsonrpc', auth='user', website=True)
|
|
def schedule_event_reschedule(self, event_id, new_datetime, new_duration=None, **kw):
|
|
"""Reschedule a calendar event to a new date/time."""
|
|
partner = request.env.user.partner_id
|
|
event = request.env['calendar.event'].sudo().browse(int(event_id))
|
|
if not event.exists() or partner not in event.partner_ids:
|
|
return {'success': False, 'error': 'Event not found or access denied.'}
|
|
|
|
tz = self._get_user_timezone()
|
|
try:
|
|
start_naive = datetime.strptime(new_datetime, '%Y-%m-%d %H:%M:%S')
|
|
start_local = tz.localize(start_naive)
|
|
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
|
except (ValueError, Exception) as e:
|
|
return {'success': False, 'error': 'Invalid date/time format.'}
|
|
|
|
duration = float(new_duration) if new_duration else event.duration
|
|
stop_utc = start_utc + timedelta(hours=duration)
|
|
|
|
try:
|
|
event.write({
|
|
'start': start_utc,
|
|
'stop': stop_utc,
|
|
'duration': duration,
|
|
})
|
|
return {'success': True}
|
|
except Exception as e:
|
|
_logger.exception("Failed to reschedule event %s: %s", event_id, e)
|
|
return {'success': False, 'error': 'Failed to reschedule event.'}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Public Manage Page — /schedule/manage/<token>
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _get_event_by_token(self, token):
|
|
"""Look up a calendar event by its manage token."""
|
|
if not token or len(token) != 32:
|
|
return None
|
|
return request.env['calendar.event'].sudo().search([
|
|
('x_fc_manage_token', '=', token),
|
|
], limit=1)
|
|
|
|
@http.route('/schedule/manage/<string:token>', type='http', auth='public', website=True, sitemap=False)
|
|
def public_manage_page(self, token, **kw):
|
|
"""Public page for visitors to view/manage their booked appointment."""
|
|
event = self._get_event_by_token(token)
|
|
if not event:
|
|
return request.not_found()
|
|
|
|
tz = self._resolve_timezone(event.user_id)
|
|
|
|
cancelled = kw.get('cancelled')
|
|
rescheduled = kw.get('rescheduled')
|
|
|
|
booking_slug = event.user_id.x_fc_schedule_slug or ''
|
|
|
|
values = {
|
|
'event': event,
|
|
'token': token,
|
|
'user_tz': tz,
|
|
'cancelled': cancelled,
|
|
'rescheduled': rescheduled,
|
|
'booking_slug': booking_slug,
|
|
'error': kw.get('error', ''),
|
|
}
|
|
return request.render('fusion_schedule.public_manage_page', values)
|
|
|
|
@http.route('/schedule/manage/<string:token>/cancel', type='http', auth='public',
|
|
website=True, methods=['POST'], csrf=True, sitemap=False)
|
|
def public_manage_cancel(self, token, **post):
|
|
"""Public cancel -- visitor cancels their appointment via token."""
|
|
event = self._get_event_by_token(token)
|
|
if not event:
|
|
return request.not_found()
|
|
|
|
try:
|
|
event.unlink()
|
|
except Exception as e:
|
|
_logger.error("Public cancel failed for token %s: %s", token[:8], e)
|
|
return request.redirect('/schedule/manage/%s?error=Failed+to+cancel' % token)
|
|
|
|
return request.redirect('/schedule/manage/%s?cancelled=1' % token)
|
|
|
|
@http.route('/schedule/manage/<string:token>/reschedule', type='http', auth='public',
|
|
website=True, methods=['POST'], csrf=True, sitemap=False)
|
|
def public_manage_reschedule(self, token, **post):
|
|
"""Public reschedule -- visitor picks a new slot via token."""
|
|
event = self._get_event_by_token(token)
|
|
if not event:
|
|
return request.not_found()
|
|
|
|
slot_datetime = (post.get('slot_datetime') or '').strip()
|
|
if not slot_datetime:
|
|
return request.redirect('/schedule/manage/%s?error=Please+select+a+new+time+slot' % token)
|
|
|
|
tz = self._resolve_timezone(event.user_id)
|
|
|
|
try:
|
|
start_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S')
|
|
start_local = tz.localize(start_naive)
|
|
start_utc = start_local.astimezone(pytz.utc).replace(tzinfo=None)
|
|
except (ValueError, Exception):
|
|
return request.redirect('/schedule/manage/%s?error=Invalid+time+slot' % token)
|
|
|
|
stop_utc = start_utc + timedelta(hours=event.duration)
|
|
|
|
try:
|
|
event.write({'start': start_utc, 'stop': stop_utc})
|
|
except Exception as e:
|
|
_logger.error("Public reschedule failed for token %s: %s", token[:8], e)
|
|
return request.redirect('/schedule/manage/%s?error=Failed+to+reschedule' % token)
|
|
|
|
return request.redirect('/schedule/manage/%s?rescheduled=1' % token)
|
|
|
|
@http.route('/schedule/manage/<string:token>/available-slots', type='jsonrpc',
|
|
auth='public', website=True, csrf=False)
|
|
def public_manage_slots(self, token, selected_date, **kw):
|
|
"""Return available slots for the staff user tied to this event."""
|
|
event = self._get_event_by_token(token)
|
|
if not event:
|
|
return {'error': 'Not found', 'slots': []}
|
|
|
|
staff_user = event.user_id
|
|
appointment_type = request.env['appointment.type'].sudo().search([
|
|
('staff_user_ids', 'in', [staff_user.id]),
|
|
], limit=1)
|
|
if not appointment_type:
|
|
return {'error': 'No appointment types', 'slots': []}
|
|
|
|
try:
|
|
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return {'error': 'Invalid date', 'slots': []}
|
|
|
|
slots = self._generate_available_slots(appointment_type, staff_user, target_date)
|
|
return {'slots': slots, 'timezone': str(self._resolve_timezone(staff_user))}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# AI Smart Scheduling
|
|
# -------------------------------------------------------------------------
|
|
|
|
def _build_schedule_context(self, staff_user, target_date, new_location=None):
|
|
"""Build a structured schedule context string for AI prompts."""
|
|
tz = self._resolve_timezone(staff_user)
|
|
|
|
day_start = tz.localize(datetime.combine(target_date, datetime.min.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
|
day_end = tz.localize(datetime.combine(target_date, datetime.max.time())).astimezone(pytz.utc).replace(tzinfo=None)
|
|
|
|
events = request.env['calendar.event'].sudo().search([
|
|
('partner_ids', 'in', [staff_user.partner_id.id]),
|
|
('start', '>=', day_start),
|
|
('start', '<=', day_end),
|
|
('x_fc_is_travel_block', '!=', True),
|
|
], order='start asc')
|
|
|
|
prefs = self._get_user_prefs(staff_user)
|
|
lines = [
|
|
'Date: %s (%s)' % (target_date.strftime('%A, %B %d, %Y'), tz),
|
|
'Work hours: %s - %s' % (self._format_hour(prefs['work_start']), self._format_hour(prefs['work_end'])),
|
|
'Break: %s for %d min' % (self._format_hour(prefs['break_start']), int(prefs['break_duration'] * 60)),
|
|
'Travel buffer: %d min minimum' % prefs['travel_buffer'],
|
|
'',
|
|
'Current appointments:',
|
|
]
|
|
|
|
event_data = []
|
|
for ev in events:
|
|
local_start = pytz.utc.localize(ev.start).astimezone(tz)
|
|
local_end = pytz.utc.localize(ev.stop).astimezone(tz)
|
|
loc = ev.location or 'No location'
|
|
lat = ev.x_fc_address_lat or 0
|
|
lng = ev.x_fc_address_lng or 0
|
|
lines.append(' - %s to %s: %s @ %s (lat:%s, lng:%s)' % (
|
|
local_start.strftime('%I:%M %p'), local_end.strftime('%I:%M %p'),
|
|
ev.name or 'Untitled', loc, lat, lng,
|
|
))
|
|
event_data.append({
|
|
'name': ev.name, 'start': str(ev.start), 'stop': str(ev.stop),
|
|
'location': loc, 'lat': lat, 'lng': lng,
|
|
})
|
|
|
|
if not events:
|
|
lines.append(' (no appointments)')
|
|
|
|
if new_location:
|
|
lines.append('')
|
|
lines.append('New appointment location: %s' % new_location)
|
|
|
|
return '\n'.join(lines), event_data, prefs
|
|
|
|
@http.route('/my/schedule/ai/suggest', type='jsonrpc', auth='user', website=True)
|
|
def schedule_ai_suggest(self, selected_date, appointment_type_id=0,
|
|
location=None, lat=0, lng=0,
|
|
duration=1.0, **kw):
|
|
"""AI-powered slot suggestions considering travel and schedule."""
|
|
user = request.env.user
|
|
try:
|
|
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
|
except (ValueError, TypeError):
|
|
return {'error': 'Invalid date', 'suggestions': []}
|
|
|
|
type_id = int(appointment_type_id or 0)
|
|
if type_id:
|
|
appointment_type = request.env['appointment.type'].sudo().browse(type_id)
|
|
else:
|
|
appointment_type = self._get_appointment_types()[:1]
|
|
if not appointment_type or not appointment_type.exists():
|
|
return {'error': 'No appointment type', 'suggestions': []}
|
|
|
|
available_slots = self._generate_available_slots(
|
|
appointment_type, user, target_date,
|
|
)
|
|
if not available_slots:
|
|
return {'error': 'No available slots', 'suggestions': []}
|
|
|
|
slot_times = [s['start_hour'] for s in available_slots]
|
|
|
|
context_str, event_data, prefs = self._build_schedule_context(
|
|
user, target_date, new_location=location,
|
|
)
|
|
|
|
travel_info = []
|
|
new_lat = float(lat or 0)
|
|
new_lng = float(lng or 0)
|
|
if new_lat and new_lng:
|
|
for ed in event_data:
|
|
if ed['lat'] and ed['lng']:
|
|
t = self._get_travel_time(ed['lat'], ed['lng'], new_lat, new_lng)
|
|
travel_info.append('%s -> new location: %d min' % (ed['name'], t))
|
|
|
|
system_prompt = (
|
|
'You are a scheduling assistant. The ONLY times you may suggest '
|
|
'are from this list: [%s]. Never invent times outside this list.\n\n'
|
|
'Rules:\n'
|
|
'1. Pick exactly 3 DIFFERENT times from the list.\n'
|
|
'2. Spread suggestions across the day -- if morning AND afternoon '
|
|
'slots exist, include at least one from each.\n'
|
|
'3. Rank by: minimize travel, maximize gap from existing events, '
|
|
'prefer earlier available time.\n'
|
|
'4. Each suggestion MUST use the EXACT text from the list '
|
|
'(e.g. "10:00 AM" not "10:00AM").\n'
|
|
'5. Never repeat the same time.\n\n'
|
|
'Return ONLY a JSON array, no markdown:\n'
|
|
'[{"time":"10:00 AM","reason":"short reason"}]\n'
|
|
'Appointment duration: %.1f hours.'
|
|
) % (', '.join(slot_times), float(duration))
|
|
|
|
user_msg = context_str
|
|
if travel_info:
|
|
user_msg += '\n\nTravel times:\n' + '\n'.join(travel_info)
|
|
user_msg += (
|
|
'\n\nAvailable slots: %s\n'
|
|
'Pick 3 different times. Include morning if available.'
|
|
) % ', '.join(slot_times)
|
|
|
|
try:
|
|
ai_response = self._call_ai(
|
|
messages=[
|
|
{'role': 'system', 'content': system_prompt},
|
|
{'role': 'user', 'content': user_msg},
|
|
],
|
|
feature='slot_suggestion',
|
|
)
|
|
suggestions = json.loads(
|
|
ai_response.strip().strip('`').replace('json\n', '').replace('json', '')
|
|
)
|
|
except Exception as e:
|
|
_logger.warning('AI suggestion failed: %s', e)
|
|
return {'error': 'AI suggestion unavailable', 'suggestions': []}
|
|
|
|
def _normalize_time(t):
|
|
t = (t or '').strip().upper().replace(' ', ' ')
|
|
parts = t.split(':')
|
|
if len(parts) == 2:
|
|
h = parts[0].lstrip('0') or '0'
|
|
return h + ':' + parts[1]
|
|
return t
|
|
|
|
slot_lookup = {}
|
|
for sl in available_slots:
|
|
slot_lookup[_normalize_time(sl['start_hour'])] = sl
|
|
|
|
enriched = []
|
|
seen_times = set()
|
|
for s in suggestions:
|
|
key = _normalize_time(s.get('time', ''))
|
|
if key in seen_times:
|
|
continue
|
|
seen_times.add(key)
|
|
matched_slot = slot_lookup.get(key)
|
|
if not matched_slot:
|
|
continue
|
|
enriched.append({
|
|
'time': matched_slot['start_hour'],
|
|
'reason': s.get('reason', ''),
|
|
'datetime': matched_slot['datetime'],
|
|
'duration': matched_slot['duration'],
|
|
})
|
|
|
|
return {'suggestions': enriched[:3]}
|
|
|
|
@http.route('/my/schedule/ai/optimize', type='jsonrpc', auth='user', website=True)
|
|
def schedule_ai_optimize(self, selected_date=None, **kw):
|
|
"""AI-powered schedule optimization for an entire day."""
|
|
user = request.env.user
|
|
try:
|
|
if selected_date:
|
|
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
|
else:
|
|
target_date = datetime.utcnow().date()
|
|
except (ValueError, TypeError):
|
|
target_date = datetime.utcnow().date()
|
|
|
|
context_str, event_data, prefs = self._build_schedule_context(user, target_date)
|
|
|
|
if len(event_data) < 2:
|
|
return {'error': 'Need at least 2 appointments to optimize', 'optimization': None}
|
|
|
|
located_events = [e for e in event_data if e['lat'] and e['lng']]
|
|
travel_matrix = []
|
|
for i, a in enumerate(located_events):
|
|
for j, b in enumerate(located_events):
|
|
if i != j:
|
|
t = self._get_travel_time(a['lat'], a['lng'], b['lat'], b['lng'])
|
|
travel_matrix.append('%s -> %s: %d min' % (a['name'], b['name'], t))
|
|
|
|
system_prompt = (
|
|
'You are a route optimization assistant. Given the appointments below with '
|
|
'their locations and the travel time matrix, suggest the optimal ordering '
|
|
'to minimize total travel time. Respect the work hours and break constraints. '
|
|
'Return ONLY valid JSON: {"current_order": ["name1", ...], '
|
|
'"optimized_order": ["name1", ...], "current_travel_total_min": N, '
|
|
'"optimized_travel_total_min": N, "savings_min": N, '
|
|
'"schedule": [{"name": "...", "suggested_time": "HH:MM AM/PM", '
|
|
'"reason": "..."}]}. No markdown.'
|
|
)
|
|
|
|
user_msg = context_str
|
|
if travel_matrix:
|
|
user_msg += '\n\nTravel time matrix:\n' + '\n'.join(travel_matrix)
|
|
user_msg += '\n\nOptimize this schedule to minimize total travel time.'
|
|
|
|
try:
|
|
ai_response = self._call_ai(
|
|
messages=[
|
|
{'role': 'system', 'content': system_prompt},
|
|
{'role': 'user', 'content': user_msg},
|
|
],
|
|
feature='schedule_optimization',
|
|
)
|
|
optimization = json.loads(
|
|
ai_response.strip().strip('`').replace('json\n', '').replace('json', '')
|
|
)
|
|
except Exception as e:
|
|
_logger.warning('AI optimization failed: %s', e)
|
|
return {'error': 'AI optimization unavailable', 'optimization': None}
|
|
|
|
return {'optimization': optimization}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OAuth Connect — Google
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/my/schedule/connect/google', type='http', auth='user', website=True)
|
|
def connect_google(self, **kw):
|
|
"""Start Google OAuth flow to connect a Google Calendar account."""
|
|
AccountModel = request.env['fusion.calendar.account'].sudo()
|
|
client_id = AccountModel._get_google_client_id()
|
|
if not client_id:
|
|
return request.redirect('/my/schedule?error=Google+OAuth+not+configured.+Contact+administrator.')
|
|
|
|
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
redirect_uri = '%s/my/schedule/oauth/callback' % base_url
|
|
|
|
csrf_token = secrets.token_hex(16)
|
|
request.session['fc_oauth_csrf'] = csrf_token
|
|
|
|
state = json.dumps({
|
|
'provider': 'google',
|
|
'csrf': csrf_token,
|
|
})
|
|
|
|
params = {
|
|
'client_id': client_id,
|
|
'redirect_uri': redirect_uri,
|
|
'response_type': 'code',
|
|
'scope': GOOGLE_SCOPES,
|
|
'access_type': 'offline',
|
|
'prompt': 'consent',
|
|
'state': state,
|
|
}
|
|
from werkzeug.urls import url_encode
|
|
auth_url = '%s?%s' % (GOOGLE_AUTH_ENDPOINT, url_encode(params))
|
|
|
|
return request.redirect(auth_url, local=False)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OAuth Connect — Microsoft
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/my/schedule/connect/microsoft', type='http', auth='user', website=True)
|
|
def connect_microsoft(self, **kw):
|
|
"""Start Microsoft OAuth flow to connect an Outlook Calendar account."""
|
|
AccountModel = request.env['fusion.calendar.account'].sudo()
|
|
client_id = AccountModel._get_microsoft_client_id()
|
|
if not client_id:
|
|
return request.redirect('/my/schedule?error=Microsoft+OAuth+not+configured.+Contact+administrator.')
|
|
|
|
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
redirect_uri = '%s/my/schedule/oauth/callback' % base_url
|
|
|
|
csrf_token = secrets.token_hex(16)
|
|
request.session['fc_oauth_csrf'] = csrf_token
|
|
|
|
state = json.dumps({
|
|
'provider': 'microsoft',
|
|
'csrf': csrf_token,
|
|
})
|
|
|
|
params = {
|
|
'client_id': client_id,
|
|
'redirect_uri': redirect_uri,
|
|
'response_type': 'code',
|
|
'scope': MICROSOFT_SCOPES,
|
|
'response_mode': 'query',
|
|
'prompt': 'select_account',
|
|
'state': state,
|
|
}
|
|
|
|
from werkzeug.urls import url_encode
|
|
auth_url = '%s?%s' % (MICROSOFT_AUTH_ENDPOINT, url_encode(params))
|
|
|
|
return request.redirect(auth_url, local=False)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# OAuth Callback
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/my/schedule/oauth/callback', type='http', auth='user', website=True)
|
|
def oauth_callback(self, **kw):
|
|
"""Handle OAuth callback from Google or Microsoft."""
|
|
error = kw.get('error')
|
|
if error:
|
|
_logger.warning("OAuth callback error: %s - %s", error, kw.get('error_description', ''))
|
|
return request.redirect('/my/schedule?error=OAuth+authorisation+was+denied+or+failed.')
|
|
|
|
code = kw.get('code')
|
|
state_str = kw.get('state', '{}')
|
|
|
|
if not code:
|
|
return request.redirect('/my/schedule?error=No+authorisation+code+received.')
|
|
|
|
try:
|
|
state = json.loads(state_str)
|
|
except (json.JSONDecodeError, TypeError):
|
|
return request.redirect('/my/schedule?error=Invalid+OAuth+state.')
|
|
|
|
csrf_token = state.get('csrf', '')
|
|
session_csrf = request.session.pop('fc_oauth_csrf', '')
|
|
|
|
provider = state.get('provider')
|
|
if provider not in ('google', 'microsoft'):
|
|
return request.redirect('/my/schedule?error=Unknown+calendar+provider.')
|
|
|
|
if not csrf_token or csrf_token != session_csrf:
|
|
existing = self._find_recently_connected_account(provider)
|
|
if existing:
|
|
provider_label = 'Google' if provider == 'google' else 'Microsoft'
|
|
return request.redirect(
|
|
'/my/schedule?success=%s+account+%s+connected.+Initial+sync+in+progress.' % (
|
|
provider_label, existing.x_fc_email,
|
|
)
|
|
)
|
|
return request.redirect('/my/schedule?error=Invalid+security+token.+Please+try+again.')
|
|
|
|
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
redirect_uri = '%s/my/schedule/oauth/callback' % base_url
|
|
|
|
AccountModel = request.env['fusion.calendar.account'].sudo()
|
|
|
|
try:
|
|
if provider == 'google':
|
|
token_data = AccountModel._exchange_google_code(code, redirect_uri)
|
|
access_token = token_data.get('access_token')
|
|
refresh_token = token_data.get('refresh_token')
|
|
expires_in = token_data.get('expires_in', 3600)
|
|
email = AccountModel._fetch_google_email(access_token)
|
|
else:
|
|
token_data = AccountModel._exchange_microsoft_code(code, redirect_uri)
|
|
access_token = token_data.get('access_token')
|
|
refresh_token = token_data.get('refresh_token')
|
|
expires_in = token_data.get('expires_in', 3600)
|
|
email = AccountModel._fetch_microsoft_email(access_token)
|
|
|
|
if not refresh_token:
|
|
return request.redirect(
|
|
'/my/schedule?error=No+refresh+token+received.+Please+try+again+and+grant+offline+access.'
|
|
)
|
|
|
|
existing = AccountModel.search([
|
|
('x_fc_user_id', '=', request.env.user.id),
|
|
('x_fc_provider', '=', provider),
|
|
('x_fc_email', '=', email),
|
|
], limit=1)
|
|
|
|
if existing:
|
|
existing.write({
|
|
'x_fc_active': True,
|
|
'x_fc_rtoken': refresh_token,
|
|
'x_fc_token': access_token,
|
|
'x_fc_token_validity': fields.Datetime.now() + timedelta(seconds=expires_in),
|
|
'x_fc_sync_status': 'active',
|
|
'x_fc_error_message': False,
|
|
'x_fc_sync_token': False,
|
|
})
|
|
else:
|
|
AccountModel.create({
|
|
'x_fc_user_id': request.env.user.id,
|
|
'x_fc_provider': provider,
|
|
'x_fc_email': email,
|
|
'x_fc_rtoken': refresh_token,
|
|
'x_fc_token': access_token,
|
|
'x_fc_token_validity': fields.Datetime.now() + timedelta(seconds=expires_in),
|
|
'x_fc_sync_status': 'active',
|
|
})
|
|
|
|
provider_label = 'Google' if provider == 'google' else 'Microsoft'
|
|
return request.redirect(
|
|
'/my/schedule?success=%s+account+%s+connected.+Initial+sync+in+progress.' % (
|
|
provider_label, email,
|
|
)
|
|
)
|
|
|
|
except Exception as e:
|
|
_logger.exception("OAuth callback error for %s: %s", provider, e)
|
|
existing = self._find_recently_connected_account(provider)
|
|
if existing:
|
|
provider_label = 'Google' if provider == 'google' else 'Microsoft'
|
|
return request.redirect(
|
|
'/my/schedule?success=%s+account+%s+connected.+Initial+sync+in+progress.' % (
|
|
provider_label, existing.x_fc_email,
|
|
)
|
|
)
|
|
return request.redirect('/my/schedule?error=Failed+to+connect+account.+Please+try+again.')
|
|
|
|
def _find_recently_connected_account(self, provider):
|
|
"""Check if the current user already has a recently connected account for this provider.
|
|
|
|
Handles the case where the user refreshes the callback URL after the
|
|
account was already created but the browser timed out.
|
|
"""
|
|
cutoff = fields.Datetime.now() - timedelta(minutes=10)
|
|
return request.env['fusion.calendar.account'].sudo().search([
|
|
('x_fc_user_id', '=', request.env.user.id),
|
|
('x_fc_provider', '=', provider),
|
|
('x_fc_active', '=', True),
|
|
('x_fc_rtoken', '!=', False),
|
|
('create_date', '>=', cutoff),
|
|
], limit=1, order='create_date desc')
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Account Management — Disconnect / Sync Now
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/my/schedule/disconnect', type='jsonrpc', auth='user', website=True)
|
|
def schedule_disconnect(self, account_id, **kw):
|
|
"""Disconnect a calendar account."""
|
|
account = request.env['fusion.calendar.account'].sudo().browse(int(account_id))
|
|
if not account.exists() or account.x_fc_user_id.id != request.env.user.id:
|
|
return {'success': False, 'error': 'Account not found or access denied.'}
|
|
|
|
try:
|
|
account.action_disconnect()
|
|
return {'success': True, 'message': 'Account disconnected.'}
|
|
except Exception as e:
|
|
_logger.exception("Disconnect error for account %s: %s", account_id, e)
|
|
return {'success': False, 'error': 'Failed to disconnect account.'}
|
|
|
|
@http.route('/my/schedule/sync-now', type='jsonrpc', auth='user', website=True)
|
|
def schedule_sync_now(self, account_id, **kw):
|
|
"""Trigger immediate sync for a calendar account."""
|
|
account = request.env['fusion.calendar.account'].sudo().browse(int(account_id))
|
|
if not account.exists() or account.x_fc_user_id.id != request.env.user.id:
|
|
return {'success': False, 'error': 'Account not found or access denied.'}
|
|
|
|
try:
|
|
result = account._sync_pull()
|
|
if result:
|
|
return {
|
|
'success': True,
|
|
'message': 'Sync completed.',
|
|
'last_sync': fields.Datetime.now().strftime('%Y-%m-%d %H:%M'),
|
|
}
|
|
else:
|
|
return {
|
|
'success': False,
|
|
'error': account.x_fc_error_message or 'Sync failed.',
|
|
}
|
|
except Exception as e:
|
|
_logger.exception("Sync error for account %s: %s", account_id, e)
|
|
return {'success': False, 'error': str(e)[:200]}
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Public Booking Page — /schedule/<slug>
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/schedule/<string:slug>', type='http', auth='public', website=True, sitemap=False)
|
|
def public_booking_page(self, slug, **kw):
|
|
"""Public booking page — no login required."""
|
|
user = request.env['res.users'].sudo().search([
|
|
('x_fc_schedule_slug', '=', slug),
|
|
('x_fc_booking_enabled', '=', True),
|
|
], limit=1)
|
|
|
|
if not user:
|
|
return request.not_found()
|
|
|
|
# Get appointment types for this user
|
|
appointment_types = request.env['appointment.type'].sudo().search([
|
|
('staff_user_ids', 'in', [user.id]),
|
|
])
|
|
|
|
if not appointment_types:
|
|
return request.not_found()
|
|
|
|
google_maps_api_key = self._get_maps_api_key()
|
|
|
|
values = {
|
|
'staff_user': user,
|
|
'appointment_types': appointment_types,
|
|
'selected_type': appointment_types[0] if appointment_types else False,
|
|
'booking_slug': slug,
|
|
'google_maps_api_key': google_maps_api_key,
|
|
'success': kw.get('success', ''),
|
|
'error': kw.get('error', ''),
|
|
}
|
|
return request.render('fusion_schedule.public_booking_page', values)
|
|
|
|
@http.route('/schedule/<string:slug>/available-slots', type='jsonrpc', auth='public',
|
|
website=True, csrf=False)
|
|
def public_available_slots(self, slug, appointment_type_id, selected_date=None, **kw):
|
|
"""Public endpoint: return available slots for a date (no login needed)."""
|
|
user = request.env['res.users'].sudo().search([
|
|
('x_fc_schedule_slug', '=', slug),
|
|
('x_fc_booking_enabled', '=', True),
|
|
], limit=1)
|
|
|
|
if not user:
|
|
return {'error': 'Booking page not found', 'slots': []}
|
|
|
|
appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id))
|
|
if not appointment_type.exists():
|
|
return {'error': 'Appointment type not found', 'slots': []}
|
|
|
|
tz = self._resolve_timezone(user)
|
|
|
|
if not selected_date:
|
|
return {'error': 'Date is required', 'slots': []}
|
|
|
|
try:
|
|
target_date = datetime.strptime(selected_date, '%Y-%m-%d').date()
|
|
except ValueError:
|
|
return {'error': 'Invalid date format', 'slots': []}
|
|
|
|
slots = self._generate_available_slots(appointment_type, user, target_date)
|
|
|
|
return {
|
|
'slots': slots,
|
|
'duration': appointment_type.appointment_duration,
|
|
'timezone': str(tz),
|
|
}
|
|
|
|
@http.route('/schedule/<string:slug>/book', type='http', auth='public', website=True,
|
|
methods=['POST'], csrf=True)
|
|
def public_book_submit(self, slug, **post):
|
|
"""Process public booking form submission."""
|
|
user = request.env['res.users'].sudo().search([
|
|
('x_fc_schedule_slug', '=', slug),
|
|
('x_fc_booking_enabled', '=', True),
|
|
], limit=1)
|
|
|
|
if not user:
|
|
return request.not_found()
|
|
|
|
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('/schedule/%s?error=Invalid+appointment+type' % slug)
|
|
|
|
visitor_name = (post.get('visitor_name') or '').strip()
|
|
visitor_email = (post.get('visitor_email') or '').strip()
|
|
visitor_phone = (post.get('visitor_phone') or '').strip()
|
|
visitor_street = (post.get('client_street') or '').strip()
|
|
visitor_city = (post.get('client_city') or '').strip()
|
|
visitor_province = (post.get('client_province') or '').strip()
|
|
visitor_postal = (post.get('client_postal') or '').strip()
|
|
notes = (post.get('visitor_notes') or post.get('notes') or '').strip()
|
|
slot_datetime = (post.get('slot_datetime') or '').strip()
|
|
slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration))
|
|
|
|
try:
|
|
visitor_lat = float(post.get('client_lat', 0))
|
|
visitor_lng = float(post.get('client_lng', 0))
|
|
except (ValueError, TypeError):
|
|
visitor_lat, visitor_lng = 0.0, 0.0
|
|
|
|
if not visitor_name or not visitor_email or not slot_datetime:
|
|
return request.redirect(
|
|
'/schedule/%s?error=Name,+email,+and+time+slot+are+required' % slug
|
|
)
|
|
|
|
tz = self._resolve_timezone(user)
|
|
|
|
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('/schedule/%s?error=Invalid+time+slot' % slug)
|
|
|
|
duration = float(slot_duration)
|
|
stop_dt_utc = start_dt_utc + timedelta(hours=duration)
|
|
|
|
# Find or create partner for the visitor
|
|
Partner = request.env['res.partner'].sudo()
|
|
partner = Partner.search([('email', '=ilike', visitor_email)], limit=1)
|
|
if not partner:
|
|
partner = Partner.create({
|
|
'name': visitor_name,
|
|
'email': visitor_email,
|
|
'phone': visitor_phone,
|
|
})
|
|
elif visitor_phone and not partner.phone:
|
|
partner.phone = visitor_phone
|
|
|
|
address_parts = [p for p in [visitor_street, visitor_city, visitor_province, visitor_postal] if p]
|
|
location = ', '.join(address_parts)
|
|
|
|
description_lines = [
|
|
'Booked via public scheduling link',
|
|
'Visitor: %s' % visitor_name,
|
|
'Email: %s' % visitor_email,
|
|
]
|
|
if visitor_phone:
|
|
description_lines.append('Phone: %s' % visitor_phone)
|
|
if location:
|
|
description_lines.append('Address: %s' % location)
|
|
if notes:
|
|
description_lines.append('Notes: %s' % notes)
|
|
description = '\n'.join(description_lines)
|
|
|
|
event_name = '%s — %s' % (visitor_name, appointment_type.name)
|
|
|
|
manage_token = secrets.token_hex(16)
|
|
|
|
try:
|
|
event = request.env['calendar.event'].sudo().with_context(
|
|
dont_notify=True, no_mail_notification=True,
|
|
).create({
|
|
'name': event_name,
|
|
'start': start_dt_utc,
|
|
'stop': stop_dt_utc,
|
|
'duration': duration,
|
|
'description': description,
|
|
'location': location,
|
|
'partner_ids': [(4, user.partner_id.id), (4, partner.id)],
|
|
'user_id': user.id,
|
|
'allday': False,
|
|
'x_fc_manage_token': manage_token,
|
|
'x_fc_client_email': visitor_email,
|
|
'x_fc_client_phone': visitor_phone or False,
|
|
'x_fc_address_lat': visitor_lat,
|
|
'x_fc_address_lng': visitor_lng,
|
|
})
|
|
|
|
_logger.info(
|
|
"Public booking: %s at %s by %s (event ID: %s)",
|
|
event_name, start_dt_utc, visitor_email, event.id,
|
|
)
|
|
|
|
try:
|
|
self._create_travel_blocks(event, user)
|
|
except Exception as te:
|
|
_logger.warning("Travel block creation failed for public event %s: %s", event.id, te)
|
|
|
|
try:
|
|
template = request.env.ref(
|
|
'fusion_schedule.fusion_schedule_booking_confirmation',
|
|
raise_if_not_found=False,
|
|
)
|
|
if template:
|
|
template.sudo().send_mail(event.id, force_send=True)
|
|
except Exception as me:
|
|
_logger.warning("Booking confirmation email failed for public event %s: %s", event.id, me)
|
|
except Exception as e:
|
|
_logger.error("Failed to create public booking: %s", e)
|
|
return request.redirect('/schedule/%s?error=Failed+to+create+booking.+Please+try+again.' % slug)
|
|
|
|
base_url = request.env['ir.config_parameter'].sudo().get_param('web.base.url', '')
|
|
manage_url = '%s/schedule/manage/%s' % (base_url, manage_token)
|
|
|
|
return request.redirect(
|
|
'/schedule/%s?success=Appointment+booked!+Manage+it+here:+%s' % (slug, manage_url)
|
|
)
|
|
|
|
# -------------------------------------------------------------------------
|
|
# Booking Settings Toggle
|
|
# -------------------------------------------------------------------------
|
|
|
|
@http.route('/my/schedule/toggle-booking', type='jsonrpc', auth='user', website=True)
|
|
def schedule_toggle_booking(self, enabled, **kw):
|
|
"""Enable or disable public booking page."""
|
|
user = request.env.user
|
|
user.sudo().write({'x_fc_booking_enabled': bool(enabled)})
|
|
return {'success': True, 'enabled': bool(enabled)}
|