from odoo import http, _ from odoo.http import request import datetime import pytz class TableReservationController(http.Controller): @http.route(['/reservation'], type='http', auth="public", website=True) def reservation_form(self, **post): schedule = request.env['reservation.schedule'].sudo().search([]) return request.render("dine360_reservation.reservation_page_template", { 'schedule': schedule }) def _get_slot_duration(self, schedule, time_float): """ Returns the slot duration for a given time based on peak hour settings """ if not schedule: return 1.0 for peak in schedule.peak_hour_ids: if peak.start_time <= time_float < peak.end_time: return float(peak.slot_duration) return float(schedule.default_slot_duration or 1.0) def _find_best_tables(self, floor, available_floor_tables, num_people): """ Find the best combination of nearby tables on a floor, preferring same zone """ # Case 1: Single table fits singles = [t for t in available_floor_tables if t.min_party_size <= num_people <= t.max_party_size] if singles: best_single = min(singles, key=lambda t: t.max_party_size) return [best_single] # Case 2: Merge needed # Group tables by zone zones = {} for t in available_floor_tables: z = t.zone or 'Default' if z not in zones: zones[z] = [] zones[z].append(t) best_overall_combo = [] min_overall_dist = float('inf') # Try merging within each zone first for zone_name, zone_tables in zones.items(): if sum(t.max_party_size for t in zone_tables) < num_people: continue for start_table in zone_tables: current_combo = [start_table] current_cap = start_table.max_party_size others = [t for t in zone_tables if t.id != start_table.id] while current_cap < num_people and others: nearest = min(others, key=lambda o: min( (((o.position_h or 0) - (c.position_h or 0))**2 + ((o.position_v or 0) - (c.position_v or 0))**2)**0.5 for c in current_combo )) current_combo.append(nearest) current_cap += nearest.max_party_size others.remove(nearest) if current_cap >= num_people: dist_sum = 0 for i in range(len(current_combo)): for j in range(i + 1, len(current_combo)): t1, t2 = current_combo[i], current_combo[j] dist_sum += (((t1.position_h or 0) - (t2.position_h or 0))**2 + ((t1.position_v or 0) - (t2.position_v or 0))**2)**0.5 if dist_sum < min_overall_dist: min_overall_dist = dist_sum best_overall_combo = current_combo # If no same-zone combo found, use the best combo across the whole floor if not best_overall_combo: for start_table in available_floor_tables: current_combo = [start_table] current_cap = start_table.max_party_size others = [t for t in available_floor_tables if t.id != start_table.id] while current_cap < num_people and others: nearest = min(others, key=lambda o: min( (((o.position_h or 0) - (c.position_h or 0))**2 + ((o.position_v or 0) - (c.position_v or 0))**2)**0.5 for c in current_combo )) current_combo.append(nearest) current_cap += nearest.max_party_size others.remove(nearest) if current_cap >= num_people: dist_sum = 0 for i in range(len(current_combo)): for j in range(i + 1, len(current_combo)): t1, t2 = current_combo[i], current_combo[j] dist_sum += (((t1.position_h or 0) - (t2.position_h or 0))**2 + ((t1.position_v or 0) - (t2.position_v or 0))**2)**0.5 if dist_sum < min_overall_dist: min_overall_dist = dist_sum best_overall_combo = current_combo return best_overall_combo @http.route(['/reservation/get_slots'], type='json', auth="public", website=True) def get_available_slots(self, date_str, num_people): try: res_date = datetime.datetime.strptime(date_str, '%Y-%m-%d').date() num_people = int(num_people) except: return {'error': 'Invalid date or guest count'} # 1. Check for Holiday holiday = request.env['reservation.holiday'].sudo().search([('date', '=', res_date)], limit=1) if holiday and holiday.is_closed: return {'error': 'The restaurant is closed on this day for %s.' % holiday.name} # 2. Get Schedule day = str(res_date.weekday()) schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1) if not schedule or schedule.is_closed: return {'error': 'Reservations are not available on this day.'} # Determine operating hours opening = holiday.opening_time if (holiday and holiday.opening_time) else schedule.opening_time closing = holiday.closing_time if (holiday and holiday.closing_time) else schedule.closing_time # 3. Generate Slots (intervals based on table duration) slots = [] current_time = opening restaurant_tz = pytz.timezone('America/Toronto') now_local = datetime.datetime.now(restaurant_tz).replace(tzinfo=None) all_tables = request.env['restaurant.table'].sudo().search([('is_reservation_enabled', '=', True)]) floors = request.env['restaurant.floor'].sudo().search([]) while current_time < closing: duration = self._get_slot_duration(schedule, current_time) slot_start_dt = datetime.datetime.combine(res_date, datetime.time(int(current_time), int((current_time % 1) * 60))) slot_end_dt = slot_start_dt + datetime.timedelta(hours=duration) if slot_start_dt > now_local: # Check for Break is_in_break = False if schedule.has_break: if current_time < schedule.break_end_time and (current_time + duration) > schedule.break_start_time: is_in_break = True if not is_in_break: best_available_combo = [] for floor in floors: floor_tables = all_tables.filtered(lambda t: t.floor_id == floor) available_on_floor = [] for table in floor_tables: overlap = request.env['restaurant.reservation'].sudo().search([ ('table_ids', 'in', [table.id]), ('state', 'in', ['confirmed', 'draft']), ('start_time', '<', slot_end_dt), ('end_time', '>', slot_start_dt), ]) if not overlap: available_on_floor.append(table) combo = self._find_best_tables(floor, available_on_floor, num_people) if combo and (not best_available_combo or len(combo) < len(best_available_combo)): best_available_combo = combo if len(combo) == 1: break # Perfect match found if best_available_combo: hours_int = int(current_time) mins_int = int((current_time % 1) * 60) ampm = "AM" if hours_int < 12 else "PM" disp_h = hours_int if hours_int <= 12 else hours_int - 12 if disp_h == 0: disp_h = 12 table_names = ", ".join([t.name for t in best_available_combo]) floor_name = best_available_combo[0].floor_id.name slots.append({ 'time': f"{hours_int:02d}:{mins_int:02d}", 'display': f"{disp_h}:{mins_int:02d} {ampm}", 'full_dt': slot_start_dt.strftime('%Y-%m-%dT%H:%M'), 'tables': f"{floor_name} - {table_names}" }) # Use the calculated duration as the interval to respect user configuration current_time += duration return {'slots': slots} @http.route(['/reservation/submit'], type='http', auth="public", website=True, methods=['POST'], csrf=True) def reservation_submit(self, **post): # Extract data customer_name = post.get('customer_name') phone = post.get('phone') email = post.get('email') num_people = int(post.get('num_people', 1)) start_time_str = post.get('start_time') if not start_time_str: return request.render("dine360_reservation.reservation_page_template", {'error': 'Please select a time slot.'}) if not email: return request.render("dine360_reservation.reservation_page_template", { 'error': 'Email is required.', 'post': post, }) # Convert start_time to datetime object and localize to restaurant timezone (America/Toronto) restaurant_tz = pytz.timezone('America/Toronto') try: local_start = datetime.datetime.strptime(start_time_str, '%Y-%m-%dT%H:%M') local_start = restaurant_tz.localize(local_start) # Convert to UTC for Odoo start_time = local_start.astimezone(pytz.utc).replace(tzinfo=None) except Exception: return request.render("dine360_reservation.reservation_page_template", { 'error': _("Invalid date or time format."), 'post': post, }) res_date = local_start.date() # Use local_start for date to get correct weekday # Determine Duration day = str(res_date.weekday()) schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1) time_float = local_start.hour + local_start.minute / 60.0 duration = self._get_slot_duration(schedule, time_float) end_time = start_time + datetime.timedelta(hours=duration) # FIND TABLES (Nearest Merge) assigned_tables = [] target_floor = False all_floors = request.env['restaurant.floor'].sudo().search([]) for floor in all_floors: floor_tables = request.env['restaurant.table'].sudo().search([ ('floor_id', '=', floor.id), ('is_reservation_enabled', '=', True) ]) available_tables = [] for table in floor_tables: overlap = request.env['restaurant.reservation'].sudo().search([ ('table_ids', 'in', [table.id]), ('state', 'in', ['confirmed', 'draft']), ('start_time', '<', end_time), ('end_time', '>', start_time), ]) if not overlap: available_tables.append(table) combo = self._find_best_tables(floor, available_tables, num_people) if combo and (not assigned_tables or len(combo) < len(assigned_tables)): assigned_tables = combo target_floor = floor if len(combo) == 1: break if not assigned_tables: return request.render("dine360_reservation.reservation_page_template", {'error': 'Sorry, no tables available for this time/group size.'}) # Create Reservation try: reservation = request.env['restaurant.reservation'].sudo().create({ 'customer_name': customer_name, 'phone': phone, 'email': email, 'num_people': num_people, 'floor_id': target_floor.id, 'table_ids': [(6, 0, [t.id for t in assigned_tables])], 'table_id': assigned_tables[0].id, 'start_time': start_time, 'end_time': end_time, 'state': 'confirmed' # Direct confirmation from website }) return request.render("dine360_reservation.reservation_success_template", { 'reservation': reservation, }) except Exception as e: return request.render("dine360_reservation.reservation_page_template", { 'error': str(e), 'post': post, })