Files
Odoo-Modules/fusion_schedule/controllers/portal_schedule.py
gsinghpal 595dccc17d changes
2026-03-17 13:32:08 -04:00

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)}