# -*- coding: utf-8 -*- from odoo import http, _, fields from odoo.http import request from odoo.addons.portal.controllers.portal import CustomerPortal from odoo.exceptions import AccessError, ValidationError from datetime import datetime, timedelta import json import logging import pytz _logger = logging.getLogger(__name__) class PortalSchedule(CustomerPortal): """Portal controller for appointment scheduling and calendar management.""" def _get_schedule_values(self): """Common values for schedule pages.""" ICP = request.env['ir.config_parameter'].sudo() g_start = ICP.get_param('fusion_claims.portal_gradient_start', '#5ba848') g_mid = ICP.get_param('fusion_claims.portal_gradient_mid', '#3a8fb7') g_end = ICP.get_param('fusion_claims.portal_gradient_end', '#2e7aad') gradient = 'linear-gradient(135deg, %s 0%%, %s 60%%, %s 100%%)' % (g_start, g_mid, g_end) google_maps_api_key = ICP.get_param('fusion_claims.google_maps_api_key', '') return { 'portal_gradient': gradient, 'google_maps_api_key': google_maps_api_key, } def _get_user_timezone(self): tz_name = request.env.user.tz or 'America/Toronto' try: return pytz.timezone(tz_name) except pytz.exceptions.UnknownTimeZoneError: return pytz.timezone('America/Toronto') def _get_appointment_types(self): """Get appointment types available to the current user.""" return request.env['appointment.type'].sudo().search([ ('staff_user_ids', 'in', [request.env.user.id]), ]) @http.route(['/my/schedule'], type='http', auth='user', website=True) def schedule_page(self, **kw): """Schedule overview: upcoming appointments and shareable link.""" partner = request.env.user.partner_id user = request.env.user now = fields.Datetime.now() upcoming_events = request.env['calendar.event'].sudo().search([ ('partner_ids', 'in', [partner.id]), ('start', '>=', now), ], order='start asc', limit=20) today_events = request.env['calendar.event'].sudo().search([ ('partner_ids', 'in', [partner.id]), ('start', '>=', now.replace(hour=0, minute=0, second=0)), ('start', '<', (now + timedelta(days=1)).replace(hour=0, minute=0, second=0)), ], order='start asc') invite = request.env['appointment.invite'].sudo().search([ ('staff_user_ids', 'in', [user.id]), ], limit=1) share_url = invite.book_url if invite else '' appointment_types = self._get_appointment_types() tz = self._get_user_timezone() values = self._get_schedule_values() values.update({ 'page_name': 'schedule', 'upcoming_events': upcoming_events, 'today_events': today_events, 'share_url': share_url, 'appointment_types': appointment_types, 'user_tz': tz, 'now': now, }) return request.render('fusion_authorizer_portal.portal_schedule_page', values) @http.route(['/my/schedule/book'], type='http', auth='user', website=True) def schedule_book(self, appointment_type_id=None, **kw): """Booking form for a new appointment.""" appointment_types = self._get_appointment_types() if not appointment_types: return request.redirect('/my/schedule') if appointment_type_id: selected_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id)) if not selected_type.exists(): selected_type = appointment_types[0] else: selected_type = appointment_types[0] values = self._get_schedule_values() values.update({ 'page_name': 'schedule_book', 'appointment_types': appointment_types, 'selected_type': selected_type, 'now': fields.Datetime.now(), 'error': kw.get('error'), 'success': kw.get('success'), }) return request.render('fusion_authorizer_portal.portal_schedule_book', values) @http.route('/my/schedule/available-slots', type='json', auth='user', website=True) def schedule_available_slots(self, appointment_type_id, selected_date=None, **kw): """JSON-RPC endpoint: return available time slots for a date.""" appointment_type = request.env['appointment.type'].sudo().browse(int(appointment_type_id)) if not appointment_type.exists(): return {'error': 'Appointment type not found', 'slots': []} user = request.env.user tz_name = user.tz or 'America/Toronto' tz = self._get_user_timezone() ref_date = fields.Datetime.now() slot_data = appointment_type._get_appointment_slots( timezone=tz_name, filter_users=request.env['res.users'].sudo().browse(user.id), asked_capacity=1, reference_date=ref_date, ) filtered_slots = [] target_date = None if selected_date: try: target_date = datetime.strptime(selected_date, '%Y-%m-%d').date() except ValueError: return {'error': 'Invalid date format', 'slots': []} for month_data in slot_data: for week in month_data.get('weeks', []): for day_info in week: if not day_info: continue day = day_info.get('day') if target_date and day != target_date: continue for slot in day_info.get('slots', []): slot_dt_str = slot.get('datetime') if not slot_dt_str: continue filtered_slots.append({ 'datetime': slot_dt_str, 'start_hour': slot.get('start_hour', ''), 'end_hour': slot.get('end_hour', ''), 'duration': slot.get('slot_duration', str(appointment_type.appointment_duration)), 'staff_user_id': slot.get('staff_user_id', user.id), }) available_dates = [] if not target_date: seen = set() for month_data in slot_data: for week in month_data.get('weeks', []): for day_info in week: if not day_info: continue day = day_info.get('day') if day and day_info.get('slots') and str(day) not in seen: seen.add(str(day)) available_dates.append(str(day)) return { 'slots': filtered_slots, 'available_dates': sorted(available_dates), 'duration': appointment_type.appointment_duration, 'timezone': tz_name, } @http.route('/my/schedule/week-events', type='json', auth='user', website=True) def schedule_week_events(self, selected_date, **kw): """Return the user's calendar events for the Mon-Sun week containing selected_date.""" try: target = datetime.strptime(selected_date, '%Y-%m-%d').date() except (ValueError, TypeError): return {'error': 'Invalid date format', 'events': [], 'week_days': []} monday = target - timedelta(days=target.weekday()) sunday = monday + timedelta(days=6) partner = request.env.user.partner_id tz = self._get_user_timezone() monday_start_local = tz.localize(datetime.combine(monday, datetime.min.time())) sunday_end_local = tz.localize(datetime.combine(sunday, datetime.max.time())) monday_start_utc = monday_start_local.astimezone(pytz.utc).replace(tzinfo=None) sunday_end_utc = sunday_end_local.astimezone(pytz.utc).replace(tzinfo=None) events = request.env['calendar.event'].sudo().search([ ('partner_ids', 'in', [partner.id]), ('start', '>=', monday_start_utc), ('start', '<=', sunday_end_utc), ], order='start asc') event_list = [] for ev in events: start_utc = ev.start stop_utc = ev.stop start_local = pytz.utc.localize(start_utc).astimezone(tz) stop_local = pytz.utc.localize(stop_utc).astimezone(tz) event_list.append({ 'name': ev.name or '', 'start': start_local.strftime('%Y-%m-%d %H:%M'), 'end': stop_local.strftime('%Y-%m-%d %H:%M'), 'start_time': start_local.strftime('%I:%M %p'), 'end_time': stop_local.strftime('%I:%M %p'), 'day_of_week': start_local.weekday(), 'date': start_local.strftime('%Y-%m-%d'), 'location': ev.location or '', 'duration': ev.duration, }) day_labels = ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'] week_days = [] for i in range(7): day = monday + timedelta(days=i) week_days.append({ 'label': day_labels[i], 'date': day.strftime('%Y-%m-%d'), 'day_num': day.day, 'is_selected': day == target, }) return { 'events': event_list, 'week_days': week_days, 'selected_date': selected_date, } @http.route('/my/schedule/book/submit', type='http', auth='user', website=True, methods=['POST']) def schedule_book_submit(self, **post): """Process the booking form submission.""" appointment_type_id = int(post.get('appointment_type_id', 0)) appointment_type = request.env['appointment.type'].sudo().browse(appointment_type_id) if not appointment_type.exists(): return request.redirect('/my/schedule/book?error=Invalid+appointment+type') client_name = (post.get('client_name') or '').strip() client_street = (post.get('client_street') or '').strip() client_city = (post.get('client_city') or '').strip() client_province = (post.get('client_province') or '').strip() client_postal = (post.get('client_postal') or '').strip() notes = (post.get('notes') or '').strip() slot_datetime = (post.get('slot_datetime') or '').strip() slot_duration = post.get('slot_duration', str(appointment_type.appointment_duration)) if not client_name or not slot_datetime: return request.redirect('/my/schedule/book?error=Client+name+and+time+slot+are+required') user = request.env.user tz = self._get_user_timezone() try: start_dt_naive = datetime.strptime(slot_datetime, '%Y-%m-%d %H:%M:%S') start_dt_local = tz.localize(start_dt_naive) start_dt_utc = start_dt_local.astimezone(pytz.utc).replace(tzinfo=None) except (ValueError, Exception) as e: _logger.error("Failed to parse slot datetime %s: %s", slot_datetime, e) return request.redirect('/my/schedule/book?error=Invalid+time+slot') duration = float(slot_duration) stop_dt_utc = start_dt_utc + timedelta(hours=duration) is_valid = appointment_type._check_appointment_is_valid_slot( staff_user=user, resources=request.env['appointment.resource'], asked_capacity=1, timezone=str(tz), start_dt=start_dt_utc, duration=duration, allday=False, ) if not is_valid: return request.redirect('/my/schedule/book?error=This+slot+is+no+longer+available.+Please+choose+another+time.') address_parts = [p for p in [client_street, client_city, client_province, client_postal] if p] location = ', '.join(address_parts) description_lines = [] if client_name: description_lines.append(f"Client: {client_name}") if location: description_lines.append(f"Address: {location}") if notes: description_lines.append(f"Notes: {notes}") description = '\n'.join(description_lines) event_name = f"{client_name} - {appointment_type.name}" booking_line_values = [{ 'appointment_user_id': user.id, 'capacity_reserved': 1, 'capacity_used': 1, }] try: event_vals = appointment_type._prepare_calendar_event_values( asked_capacity=1, booking_line_values=booking_line_values, description=description, duration=duration, allday=False, appointment_invite=request.env['appointment.invite'], guests=request.env['res.partner'], name=event_name, customer=user.partner_id, staff_user=user, start=start_dt_utc, stop=stop_dt_utc, ) event_vals['location'] = location event = request.env['calendar.event'].sudo().create(event_vals) _logger.info( "Appointment booked: %s at %s (event ID: %s)", event_name, start_dt_utc, event.id, ) except Exception as e: _logger.error("Failed to create appointment: %s", e) return request.redirect('/my/schedule/book?error=Failed+to+create+appointment.+Please+try+again.') return request.redirect('/my/schedule?success=Appointment+booked+successfully')