# -*- 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/ # ------------------------------------------------------------------------- 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/', 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//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//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//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/ # ------------------------------------------------------------------------- @http.route('/schedule/', 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//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//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)}