implement restaurant reservation management system with scheduling, validation, and WhatsApp integration
This commit is contained in:
parent
3a13d2b882
commit
43e7007a93
@ -22,6 +22,7 @@
|
|||||||
'views/restaurant_table_views.xml',
|
'views/restaurant_table_views.xml',
|
||||||
'views/reservation_schedule_views.xml',
|
'views/reservation_schedule_views.xml',
|
||||||
'views/reservation_holiday_views.xml',
|
'views/reservation_holiday_views.xml',
|
||||||
|
'views/reservation_custom_field_views.xml',
|
||||||
'views/reservation_templates.xml',
|
'views/reservation_templates.xml',
|
||||||
'views/menu_items.xml',
|
'views/menu_items.xml',
|
||||||
],
|
],
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
from odoo import http, _
|
from odoo import http, _
|
||||||
from odoo.http import request
|
from odoo.http import request
|
||||||
import datetime
|
import datetime
|
||||||
import pytz
|
import pytz
|
||||||
@ -8,8 +8,11 @@ class TableReservationController(http.Controller):
|
|||||||
@http.route(['/reservation'], type='http', auth="public", website=True)
|
@http.route(['/reservation'], type='http', auth="public", website=True)
|
||||||
def reservation_form(self, **post):
|
def reservation_form(self, **post):
|
||||||
schedule = request.env['reservation.schedule'].sudo().search([])
|
schedule = request.env['reservation.schedule'].sudo().search([])
|
||||||
|
custom_fields = request.env['reservation.custom.field'].sudo().search([('is_active', '=', True)])
|
||||||
return request.render("dine360_reservation.reservation_page_template", {
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
'schedule': schedule
|
'schedule': schedule,
|
||||||
|
'custom_fields': custom_fields,
|
||||||
|
'post': post,
|
||||||
})
|
})
|
||||||
|
|
||||||
def _get_slot_duration(self, schedule, time_float):
|
def _get_slot_duration(self, schedule, time_float):
|
||||||
@ -185,20 +188,66 @@ class TableReservationController(http.Controller):
|
|||||||
|
|
||||||
@http.route(['/reservation/submit'], type='http', auth="public", website=True, methods=['POST'], csrf=True)
|
@http.route(['/reservation/submit'], type='http', auth="public", website=True, methods=['POST'], csrf=True)
|
||||||
def reservation_submit(self, **post):
|
def reservation_submit(self, **post):
|
||||||
# Extract data
|
schedule = request.env['reservation.schedule'].sudo().search([])
|
||||||
|
active_custom_fields = request.env['reservation.custom.field'].sudo().search([('is_active', '=', True)])
|
||||||
|
website = request.website
|
||||||
|
|
||||||
|
# Extract standard data
|
||||||
customer_name = post.get('customer_name')
|
customer_name = post.get('customer_name')
|
||||||
phone = post.get('phone')
|
phone = post.get('phone', '')
|
||||||
email = post.get('email')
|
email = post.get('email', '')
|
||||||
num_people = int(post.get('num_people', 1))
|
num_people = int(post.get('num_people', 2) or 2)
|
||||||
start_time_str = post.get('start_time')
|
start_time_str = post.get('start_time')
|
||||||
|
|
||||||
if not start_time_str:
|
# Extract optional fields
|
||||||
return request.render("dine360_reservation.reservation_page_template", {'error': 'Please select a time slot.'})
|
special_requests = post.get('special_requests')
|
||||||
|
occasion = post.get('occasion') or False
|
||||||
|
dietary_requirements = post.get('dietary_requirements')
|
||||||
|
preferred_zone = post.get('preferred_zone')
|
||||||
|
|
||||||
if not email:
|
if not customer_name:
|
||||||
return request.render("dine360_reservation.reservation_page_template", {
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
'error': 'Email is required.',
|
'error': _('Please provide your name.'),
|
||||||
'post': post,
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
if not start_time_str:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
|
'error': _('Please select an available time slot.'),
|
||||||
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Check required fields based on website settings
|
||||||
|
if hasattr(website, 'reservation_show_email') and website.reservation_show_email and not email:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
|
'error': _('Email is required.'),
|
||||||
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
if hasattr(website, 'reservation_show_phone') and website.reservation_show_phone and not phone:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
|
'error': _('Phone number is required.'),
|
||||||
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
|
})
|
||||||
|
|
||||||
|
# Validate required custom fields
|
||||||
|
for cfield in active_custom_fields:
|
||||||
|
if cfield.is_required:
|
||||||
|
cval = post.get(f'custom_field_{cfield.id}')
|
||||||
|
if not cval:
|
||||||
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
|
'error': _("%s is required.") % cfield.name,
|
||||||
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
# Convert start_time to datetime object and localize to restaurant timezone (America/Toronto)
|
# Convert start_time to datetime object and localize to restaurant timezone (America/Toronto)
|
||||||
@ -212,15 +261,17 @@ class TableReservationController(http.Controller):
|
|||||||
return request.render("dine360_reservation.reservation_page_template", {
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
'error': _("Invalid date or time format."),
|
'error': _("Invalid date or time format."),
|
||||||
'post': post,
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
})
|
})
|
||||||
|
|
||||||
res_date = local_start.date() # Use local_start for date to get correct weekday
|
res_date = local_start.date() # Use local_start for date to get correct weekday
|
||||||
|
|
||||||
# Determine Duration
|
# Determine Duration
|
||||||
day = str(res_date.weekday())
|
day = str(res_date.weekday())
|
||||||
schedule = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1)
|
schedule_day = request.env['reservation.schedule'].sudo().search([('day', '=', day)], limit=1)
|
||||||
time_float = local_start.hour + local_start.minute / 60.0
|
time_float = local_start.hour + local_start.minute / 60.0
|
||||||
duration = self._get_slot_duration(schedule, time_float)
|
duration = self._get_slot_duration(schedule_day, time_float)
|
||||||
end_time = start_time + datetime.timedelta(hours=duration)
|
end_time = start_time + datetime.timedelta(hours=duration)
|
||||||
|
|
||||||
# FIND TABLES (Nearest Merge)
|
# FIND TABLES (Nearest Merge)
|
||||||
@ -252,21 +303,41 @@ class TableReservationController(http.Controller):
|
|||||||
if len(combo) == 1: break
|
if len(combo) == 1: break
|
||||||
|
|
||||||
if not assigned_tables:
|
if not assigned_tables:
|
||||||
return request.render("dine360_reservation.reservation_page_template", {'error': 'Sorry, no tables available for this time/group size.'})
|
return request.render("dine360_reservation.reservation_page_template", {
|
||||||
|
'error': _('Sorry, no tables available for this time/group size.'),
|
||||||
|
'post': post,
|
||||||
|
'schedule': schedule,
|
||||||
|
'custom_fields': active_custom_fields,
|
||||||
|
})
|
||||||
|
|
||||||
# Create Reservation
|
# Create Reservation
|
||||||
try:
|
try:
|
||||||
reservation = request.env['restaurant.reservation'].sudo().create({
|
reservation_vals = {
|
||||||
'customer_name': customer_name,
|
'customer_name': customer_name,
|
||||||
'phone': phone,
|
'phone': phone,
|
||||||
'email': email,
|
'email': email,
|
||||||
'num_people': num_people,
|
'num_people': num_people,
|
||||||
'floor_id': target_floor.id,
|
'special_requests': special_requests,
|
||||||
|
'occasion': occasion,
|
||||||
|
'dietary_requirements': dietary_requirements,
|
||||||
|
'preferred_zone': preferred_zone,
|
||||||
|
'floor_id': target_floor.id if target_floor else False,
|
||||||
'table_ids': [(6, 0, [t.id for t in assigned_tables])],
|
'table_ids': [(6, 0, [t.id for t in assigned_tables])],
|
||||||
'table_id': assigned_tables[0].id,
|
'table_id': assigned_tables[0].id if assigned_tables else False,
|
||||||
'start_time': start_time,
|
'start_time': start_time,
|
||||||
'end_time': end_time,
|
'end_time': end_time,
|
||||||
'state': 'confirmed' # Direct confirmation from website
|
'state': 'confirmed' # Direct confirmation from website
|
||||||
|
}
|
||||||
|
reservation = request.env['restaurant.reservation'].sudo().create(reservation_vals)
|
||||||
|
|
||||||
|
# Save Custom Field Answers
|
||||||
|
for cfield in active_custom_fields:
|
||||||
|
ans = post.get(f'custom_field_{cfield.id}')
|
||||||
|
if ans:
|
||||||
|
request.env['reservation.custom.field.value'].sudo().create({
|
||||||
|
'reservation_id': reservation.id,
|
||||||
|
'field_id': cfield.id,
|
||||||
|
'value': str(ans)
|
||||||
})
|
})
|
||||||
|
|
||||||
# Send Emails
|
# Send Emails
|
||||||
|
|||||||
@ -3,3 +3,4 @@ from . import restaurant_table
|
|||||||
from . import reservation_schedule
|
from . import reservation_schedule
|
||||||
from . import reservation_holiday
|
from . import reservation_holiday
|
||||||
from . import reservation_peak_hour
|
from . import reservation_peak_hour
|
||||||
|
from . import reservation_custom_field
|
||||||
|
|||||||
@ -0,0 +1,51 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from odoo import models, fields, api, _
|
||||||
|
import re
|
||||||
|
|
||||||
|
class ReservationCustomField(models.Model):
|
||||||
|
_name = 'reservation.custom.field'
|
||||||
|
_description = 'Reservation Custom Field'
|
||||||
|
_order = 'sequence, id'
|
||||||
|
|
||||||
|
name = fields.Char(string='Field Label / Question', required=True, help="Label shown on the reservation form.")
|
||||||
|
field_key = fields.Char(string='Field Key', compute='_compute_field_key', store=True, readonly=False,
|
||||||
|
help="Technical identifier for form input.")
|
||||||
|
field_type = fields.Selection([
|
||||||
|
('char', 'Text (Single Line)'),
|
||||||
|
('text', 'Text (Multi-line)'),
|
||||||
|
('selection', 'Dropdown (Selection)'),
|
||||||
|
('boolean', 'Checkbox (Yes / No)'),
|
||||||
|
('integer', 'Number'),
|
||||||
|
], string='Field Type', default='char', required=True)
|
||||||
|
selection_options = fields.Text(string='Dropdown Options',
|
||||||
|
help="Comma-separated list of choices, e.g. Vegetarian, Vegan, Halal, Gluten-Free")
|
||||||
|
placeholder = fields.Char(string='Placeholder', help="Optional placeholder text for the input box.")
|
||||||
|
is_required = fields.Boolean(string='Required', default=False, help="Whether this field must be filled by customer.")
|
||||||
|
is_active = fields.Boolean(string='Active (Show on Form)', default=True, help="If checked, field will appear on reservation form.")
|
||||||
|
sequence = fields.Integer(string='Sequence', default=10)
|
||||||
|
|
||||||
|
@api.depends('name')
|
||||||
|
def _compute_field_key(self):
|
||||||
|
for rec in self:
|
||||||
|
if rec.name and not rec.field_key:
|
||||||
|
clean = re.sub(r'[^a-zA-Z0-9_]', '_', rec.name.lower().strip())
|
||||||
|
rec.field_key = f"custom_{clean[:30]}"
|
||||||
|
|
||||||
|
def get_options_list(self):
|
||||||
|
""" Return options as a list of strings for selection rendering """
|
||||||
|
self.ensure_one()
|
||||||
|
if not self.selection_options:
|
||||||
|
return []
|
||||||
|
return [opt.strip() for opt in self.selection_options.split(',') if opt.strip()]
|
||||||
|
|
||||||
|
|
||||||
|
class ReservationCustomFieldValue(models.Model):
|
||||||
|
_name = 'reservation.custom.field.value'
|
||||||
|
_description = 'Reservation Custom Field Answer'
|
||||||
|
_order = 'id'
|
||||||
|
|
||||||
|
reservation_id = fields.Many2one('restaurant.reservation', string='Reservation', ondelete='cascade', required=True)
|
||||||
|
field_id = fields.Many2one('reservation.custom.field', string='Custom Field', ondelete='cascade', required=True)
|
||||||
|
field_name = fields.Char(related='field_id.name', string='Field Label', readonly=True)
|
||||||
|
field_type = fields.Selection(related='field_id.field_type', string='Field Type', readonly=True)
|
||||||
|
value = fields.Text(string='Value / Answer')
|
||||||
@ -10,10 +10,23 @@ class RestaurantReservation(models.Model):
|
|||||||
|
|
||||||
name = fields.Char(string='Reservation Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'))
|
name = fields.Char(string='Reservation Reference', required=True, copy=False, readonly=True, default=lambda self: _('New'))
|
||||||
customer_name = fields.Char(string='Customer Name', required=True)
|
customer_name = fields.Char(string='Customer Name', required=True)
|
||||||
phone = fields.Char(string='Phone Number', required=True)
|
phone = fields.Char(string='Phone Number', required=False)
|
||||||
email = fields.Char(string='Email', required=True)
|
email = fields.Char(string='Email', required=False)
|
||||||
num_people = fields.Integer(string='Number of People', default=1)
|
num_people = fields.Integer(string='Number of People', default=1)
|
||||||
|
|
||||||
|
special_requests = fields.Text(string='Special Requests / Notes')
|
||||||
|
occasion = fields.Selection([
|
||||||
|
('birthday', 'Birthday'),
|
||||||
|
('anniversary', 'Anniversary'),
|
||||||
|
('business', 'Business Meal'),
|
||||||
|
('date', 'Date Night'),
|
||||||
|
('casual', 'Casual Dining'),
|
||||||
|
('other', 'Other Occasion')
|
||||||
|
], string='Dining Occasion')
|
||||||
|
dietary_requirements = fields.Char(string='Dietary Requirements / Allergies')
|
||||||
|
preferred_zone = fields.Char(string='Preferred Seating Area / Zone')
|
||||||
|
custom_field_value_ids = fields.One2many('reservation.custom.field.value', 'reservation_id', string='Additional Field Answers')
|
||||||
|
|
||||||
floor_id = fields.Many2one('restaurant.floor', string='Floor')
|
floor_id = fields.Many2one('restaurant.floor', string='Floor')
|
||||||
table_id = fields.Many2one('restaurant.table', string='Primary Table')
|
table_id = fields.Many2one('restaurant.table', string='Primary Table')
|
||||||
table_ids = fields.Many2many('restaurant.table', string='Tables')
|
table_ids = fields.Many2many('restaurant.table', string='Tables')
|
||||||
|
|||||||
@ -3,3 +3,6 @@ access_restaurant_reservation_user,restaurant.reservation,model_restaurant_reser
|
|||||||
access_reservation_schedule_user,reservation.schedule,model_reservation_schedule,base.group_user,1,1,1,1
|
access_reservation_schedule_user,reservation.schedule,model_reservation_schedule,base.group_user,1,1,1,1
|
||||||
access_reservation_holiday_user,reservation.holiday,model_reservation_holiday,base.group_user,1,1,1,1
|
access_reservation_holiday_user,reservation.holiday,model_reservation_holiday,base.group_user,1,1,1,1
|
||||||
access_reservation_peak_hour_user,reservation.peak_hour,model_reservation_peak_hour,base.group_user,1,1,1,1
|
access_reservation_peak_hour_user,reservation.peak_hour,model_reservation_peak_hour,base.group_user,1,1,1,1
|
||||||
|
access_reservation_custom_field_user,reservation.custom.field,model_reservation_custom_field,base.group_user,1,1,1,1
|
||||||
|
access_reservation_custom_field_value_user,reservation.custom.field.value,model_reservation_custom_field_value,base.group_user,1,1,1,1
|
||||||
|
access_reservation_custom_field_public,reservation.custom.field,model_reservation_custom_field,base.group_public,1,0,0,0
|
||||||
|
|||||||
|
@ -0,0 +1,72 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<odoo>
|
||||||
|
<!-- Tree View -->
|
||||||
|
<record id="view_reservation_custom_field_tree" model="ir.ui.view">
|
||||||
|
<field name="name">reservation.custom.field.tree</field>
|
||||||
|
<field name="model">reservation.custom.field</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<tree string="Reservation Custom Fields" editable="bottom">
|
||||||
|
<field name="sequence" widget="handle"/>
|
||||||
|
<field name="name"/>
|
||||||
|
<field name="field_type"/>
|
||||||
|
<field name="selection_options" invisible="field_type != 'selection'" placeholder="e.g. Yes, No"/>
|
||||||
|
<field name="placeholder" invisible="field_type not in ['char', 'text']"/>
|
||||||
|
<field name="is_required"/>
|
||||||
|
<field name="is_active"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Form View -->
|
||||||
|
<record id="view_reservation_custom_field_form" model="ir.ui.view">
|
||||||
|
<field name="name">reservation.custom.field.form</field>
|
||||||
|
<field name="model">reservation.custom.field</field>
|
||||||
|
<field name="arch" type="xml">
|
||||||
|
<form string="Custom Reservation Field">
|
||||||
|
<sheet>
|
||||||
|
<div class="oe_title">
|
||||||
|
<label for="name" class="oe_edit_only"/>
|
||||||
|
<h1>
|
||||||
|
<field name="name" placeholder="e.g. Dietary Restrictions or Allergies"/>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
<group>
|
||||||
|
<group string="Field Configuration">
|
||||||
|
<field name="field_type"/>
|
||||||
|
<field name="field_key" groups="base.group_no_one"/>
|
||||||
|
<field name="selection_options" invisible="field_type != 'selection'" required="field_type == 'selection'" placeholder="Option 1, Option 2, Option 3..."/>
|
||||||
|
<field name="placeholder" invisible="field_type not in ['char', 'text']" placeholder="e.g. Please let us know if any guests have allergies..."/>
|
||||||
|
</group>
|
||||||
|
<group string="Display & Validation">
|
||||||
|
<field name="is_required"/>
|
||||||
|
<field name="is_active"/>
|
||||||
|
<field name="sequence"/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</sheet>
|
||||||
|
</form>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Window Action -->
|
||||||
|
<record id="action_reservation_custom_fields" model="ir.actions.act_window">
|
||||||
|
<field name="name">Additional Custom Fields</field>
|
||||||
|
<field name="res_model">reservation.custom.field</field>
|
||||||
|
<field name="view_mode">tree,form</field>
|
||||||
|
<field name="help" type="html">
|
||||||
|
<p class="o_view_nocontent_smiling_face">
|
||||||
|
Add an additional field or question to your Table Reservation form!
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
You can create custom questions such as Allergies, Company Name, Seating Preferences, or High Chair requirements.
|
||||||
|
</p>
|
||||||
|
</field>
|
||||||
|
</record>
|
||||||
|
|
||||||
|
<!-- Submenu under Table Reservation -->
|
||||||
|
<menuitem id="menu_reservation_custom_fields"
|
||||||
|
name="Additional Fields"
|
||||||
|
parent="dine360_reservation.menu_restaurant_reservation_root"
|
||||||
|
action="action_reservation_custom_fields"
|
||||||
|
sequence="40"/>
|
||||||
|
</odoo>
|
||||||
@ -128,22 +128,122 @@
|
|||||||
<label class="form-label fw-bold">Full Name</label>
|
<label class="form-label fw-bold">Full Name</label>
|
||||||
<input type="text" name="customer_name" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="John Doe" required="1" t-att-value="post.get('customer_name') if post else ''"/>
|
<input type="text" name="customer_name" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="John Doe" required="1" t-att-value="post.get('customer_name') if post else ''"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Phone Number -->
|
||||||
|
<t t-if="'reservation_show_phone' not in website._fields or website.reservation_show_phone">
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-bold">Phone Number</label>
|
<label class="form-label fw-bold">Phone Number</label>
|
||||||
<input type="tel" name="phone" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="+1 (647) 000-0000" required="1" t-att-value="post.get('phone') if post else ''"/>
|
<input type="tel" name="phone" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="+1 (647) 000-0000" required="1" t-att-value="post.get('phone') if post else ''"/>
|
||||||
</div>
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Email Address -->
|
||||||
|
<t t-if="'reservation_show_email' not in website._fields or website.reservation_show_email">
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<label class="form-label fw-bold">Email</label>
|
<label class="form-label fw-bold">Email</label>
|
||||||
<input type="email" name="email" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="john@example.com" required="1" t-att-value="post.get('email') if post else ''"/>
|
<input type="email" name="email" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="john@example.com" required="1" t-att-value="post.get('email') if post else ''"/>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-12">
|
</t>
|
||||||
|
|
||||||
|
<!-- Number of Guests -->
|
||||||
|
<t t-if="'reservation_show_guests' not in website._fields or website.reservation_show_guests">
|
||||||
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-bold">Number of Guests</label>
|
<label class="form-label fw-bold">Number of Guests</label>
|
||||||
<input type="number" name="num_people" class="form-control form-control-lg border-0 shadow-sm res-input" value="2" min="1" required="1" t-att-value="post.get('num_people') if post else 2"/>
|
<input type="number" name="num_people" class="form-control form-control-lg border-0 shadow-sm res-input" value="2" min="1" required="1" t-att-value="post.get('num_people') if post else 2"/>
|
||||||
</div>
|
</div>
|
||||||
|
</t>
|
||||||
|
<t t-else="">
|
||||||
|
<input type="hidden" name="num_people" value="2"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
<div class="col-md-6">
|
<div class="col-md-6">
|
||||||
<label class="form-label fw-bold">Reservation Date</label>
|
<label class="form-label fw-bold">Reservation Date</label>
|
||||||
<input type="date" id="res_date" name="res_date" class="form-control form-control-lg border-0 shadow-sm res-input" required="1" t-att-value="post.get('res_date') if post else datetime.date.today().strftime('%Y-%m-%d')"/>
|
<input type="date" id="res_date" name="res_date" class="form-control form-control-lg border-0 shadow-sm res-input" required="1" t-att-value="post.get('res_date') if post else datetime.date.today().strftime('%Y-%m-%d')"/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Dining Occasion -->
|
||||||
|
<t t-if="'reservation_show_occasion' in website._fields and website.reservation_show_occasion">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-bold">Dining Occasion</label>
|
||||||
|
<select name="occasion" class="form-select form-select-lg border-0 shadow-sm res-input">
|
||||||
|
<option value="">Select an occasion (optional)...</option>
|
||||||
|
<option value="birthday">🎂 Birthday</option>
|
||||||
|
<option value="anniversary">💍 Anniversary</option>
|
||||||
|
<option value="business">💼 Business Meal</option>
|
||||||
|
<option value="date">🌹 Date Night</option>
|
||||||
|
<option value="casual">🍽️ Casual Dining</option>
|
||||||
|
<option value="other">✨ Other Special Occasion</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Seating Area / Zone Preference -->
|
||||||
|
<t t-if="'reservation_show_zone' in website._fields and website.reservation_show_zone">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<label class="form-label fw-bold">Seating Preference</label>
|
||||||
|
<input type="text" name="preferred_zone" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="e.g. Window Side, Garden, Quiet Corner..."/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Dietary Requirements / Allergies -->
|
||||||
|
<t t-if="'reservation_show_dietary' in website._fields and website.reservation_show_dietary">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-bold">Dietary Requirements / Allergies</label>
|
||||||
|
<input type="text" name="dietary_requirements" class="form-control form-control-lg border-0 shadow-sm res-input" placeholder="e.g. Nut allergy, Gluten-free, Vegetarian..."/>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Special Requests / Notes -->
|
||||||
|
<t t-if="'reservation_show_special_requests' not in website._fields or website.reservation_show_special_requests">
|
||||||
|
<div class="col-md-12">
|
||||||
|
<label class="form-label fw-bold">Special Requests / Notes</label>
|
||||||
|
<textarea name="special_requests" rows="2" class="form-control border-0 shadow-sm res-input" placeholder="Any additional notes or requests for the team..."><t t-esc="post.get('special_requests') if post else ''"/></textarea>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Dynamic Additional Custom Fields -->
|
||||||
|
<t t-if="custom_fields">
|
||||||
|
<t t-foreach="custom_fields" t-as="cfield">
|
||||||
|
<div t-attf-class="col-md-#{'12' if cfield.field_type in ['text'] else '6'}">
|
||||||
|
<label class="form-label fw-bold">
|
||||||
|
<t t-esc="cfield.name"/>
|
||||||
|
<t t-if="cfield.is_required"><span class="text-danger ms-1">*</span></t>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<t t-if="cfield.field_type == 'char'">
|
||||||
|
<input type="text" t-att-name="'custom_field_' + str(cfield.id)" class="form-control form-control-lg border-0 shadow-sm res-input" t-att-placeholder="cfield.placeholder or ''" t-att-required="cfield.is_required and '1' or None"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="cfield.field_type == 'text'">
|
||||||
|
<textarea t-att-name="'custom_field_' + str(cfield.id)" rows="2" class="form-control border-0 shadow-sm res-input" t-att-placeholder="cfield.placeholder or ''" t-att-required="cfield.is_required and '1' or None"></textarea>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="cfield.field_type == 'integer'">
|
||||||
|
<input type="number" t-att-name="'custom_field_' + str(cfield.id)" class="form-control form-control-lg border-0 shadow-sm res-input" t-att-placeholder="cfield.placeholder or ''" t-att-required="cfield.is_required and '1' or None"/>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="cfield.field_type == 'selection'">
|
||||||
|
<select t-att-name="'custom_field_' + str(cfield.id)" class="form-select form-select-lg border-0 shadow-sm res-input" t-att-required="cfield.is_required and '1' or None">
|
||||||
|
<option value="">Select an option...</option>
|
||||||
|
<t t-foreach="cfield.get_options_list()" t-as="opt">
|
||||||
|
<option t-att-value="opt"><t t-esc="opt"/></option>
|
||||||
|
</t>
|
||||||
|
</select>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<t t-elif="cfield.field_type == 'boolean'">
|
||||||
|
<div class="form-check form-switch mt-2">
|
||||||
|
<input class="form-check-input" type="checkbox" t-att-name="'custom_field_' + str(cfield.id)" value="Yes" t-att-id="'custom_field_' + str(cfield.id)"/>
|
||||||
|
<label class="form-check-label ms-2" t-att-for="'custom_field_' + str(cfield.id)">
|
||||||
|
<t t-esc="cfield.placeholder or 'Yes'"/>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</div>
|
||||||
|
</t>
|
||||||
|
</t>
|
||||||
|
|
||||||
|
<!-- Available Time Slots -->
|
||||||
<div class="col-md-12">
|
<div class="col-md-12">
|
||||||
<label class="form-label fw-bold">Available Time Slots</label>
|
<label class="form-label fw-bold">Available Time Slots</label>
|
||||||
<div id="slot_container" class="d-flex flex-wrap gap-2 p-3 rounded" style="min-height: 100px;">
|
<div id="slot_container" class="d-flex flex-wrap gap-2 p-3 rounded" style="min-height: 100px;">
|
||||||
@ -172,7 +272,7 @@
|
|||||||
|
|
||||||
function fetchSlots() {
|
function fetchSlots() {
|
||||||
const date = dateInput.value;
|
const date = dateInput.value;
|
||||||
const guests = guestsInput.value;
|
const guests = guestsInput ? (guestsInput.value || 2) : 2;
|
||||||
|
|
||||||
if (!date || !guests) return;
|
if (!date || !guests) return;
|
||||||
|
|
||||||
@ -224,7 +324,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
dateInput.addEventListener('change', fetchSlots);
|
dateInput.addEventListener('change', fetchSlots);
|
||||||
guestsInput.addEventListener('change', fetchSlots);
|
if (guestsInput) guestsInput.addEventListener('change', fetchSlots);
|
||||||
|
|
||||||
if(dateInput.value) fetchSlots();
|
if(dateInput.value) fetchSlots();
|
||||||
});
|
});
|
||||||
|
|||||||
@ -58,6 +58,28 @@
|
|||||||
<field name="end_time"/>
|
<field name="end_time"/>
|
||||||
</group>
|
</group>
|
||||||
</group>
|
</group>
|
||||||
|
<notebook>
|
||||||
|
<page string="Customer Preferences & Notes" name="customer_requests">
|
||||||
|
<group>
|
||||||
|
<group string="Preferences">
|
||||||
|
<field name="occasion"/>
|
||||||
|
<field name="preferred_zone"/>
|
||||||
|
<field name="dietary_requirements"/>
|
||||||
|
</group>
|
||||||
|
<group string="Special Notes">
|
||||||
|
<field name="special_requests" placeholder="Customer special requests..."/>
|
||||||
|
</group>
|
||||||
|
</group>
|
||||||
|
</page>
|
||||||
|
<page string="Additional Field Answers" name="custom_fields">
|
||||||
|
<field name="custom_field_value_ids">
|
||||||
|
<tree string="Custom Field Answers" editable="bottom">
|
||||||
|
<field name="field_name"/>
|
||||||
|
<field name="value"/>
|
||||||
|
</tree>
|
||||||
|
</field>
|
||||||
|
</page>
|
||||||
|
</notebook>
|
||||||
</sheet>
|
</sheet>
|
||||||
</form>
|
</form>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
@ -181,7 +181,10 @@ class SaasRestaurant(models.Model):
|
|||||||
def action_create_database(self):
|
def action_create_database(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.database_name in db.list_dbs():
|
if self.database_name in db.list_dbs():
|
||||||
raise UserError(f"Database {self.database_name} already exists in PostgreSQL!")
|
raise UserError(
|
||||||
|
f"Database '{self.database_name}' already exists in PostgreSQL!\n"
|
||||||
|
f"Please click 'Drop Database' to remove the existing leftover database, or choose a different Subdomain."
|
||||||
|
)
|
||||||
|
|
||||||
# 1. Provision PostgreSQL database
|
# 1. Provision PostgreSQL database
|
||||||
admin_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=12))
|
admin_pass = ''.join(random.choices(string.ascii_letters + string.digits, k=12))
|
||||||
@ -249,6 +252,38 @@ class SaasRestaurant(models.Model):
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
def action_drop_database(self):
|
||||||
|
""" Drop the tenant database from PostgreSQL """
|
||||||
|
self.ensure_one()
|
||||||
|
if self.database_name and self.database_name in db.list_dbs():
|
||||||
|
try:
|
||||||
|
db.exp_drop(self.database_name)
|
||||||
|
_logger.info(f"Database {self.database_name} dropped by user action.")
|
||||||
|
except Exception as e:
|
||||||
|
raise UserError(f"Failed to drop database from PostgreSQL: {str(e)}")
|
||||||
|
self.status = 'draft'
|
||||||
|
return {
|
||||||
|
'type': 'ir.actions.client',
|
||||||
|
'tag': 'display_notification',
|
||||||
|
'params': {
|
||||||
|
'title': 'Database Removed',
|
||||||
|
'message': f'PostgreSQL database {self.database_name} has been dropped.',
|
||||||
|
'sticky': False,
|
||||||
|
'next': {'type': 'ir.actions.client', 'tag': 'reload'},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
def unlink(self):
|
||||||
|
""" Drop the PostgreSQL database when a restaurant record is deleted """
|
||||||
|
for record in self:
|
||||||
|
if record.database_name and record.database_name in db.list_dbs():
|
||||||
|
try:
|
||||||
|
_logger.info(f"Dropping tenant database {record.database_name} on record deletion...")
|
||||||
|
db.exp_drop(record.database_name)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.warning(f"Failed to drop database {record.database_name} during unlink: {str(e)}")
|
||||||
|
return super(SaasRestaurant, self).unlink()
|
||||||
|
|
||||||
def action_suspend(self):
|
def action_suspend(self):
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
# Suspend by disabling login in target database
|
# Suspend by disabling login in target database
|
||||||
|
|||||||
@ -25,6 +25,7 @@
|
|||||||
<form string="Restaurant Tenant">
|
<form string="Restaurant Tenant">
|
||||||
<header>
|
<header>
|
||||||
<button name="action_create_database" string="Provision Database" type="object" class="oe_highlight" invisible="status not in ['draft', 'failed']"/>
|
<button name="action_create_database" string="Provision Database" type="object" class="oe_highlight" invisible="status not in ['draft', 'failed']"/>
|
||||||
|
<button name="action_drop_database" string="Drop Database" type="object" class="btn-secondary" invisible="status not in ['draft', 'failed']" confirm="Are you sure you want to delete this tenant database from PostgreSQL?"/>
|
||||||
<button name="action_suspend" string="Suspend Subscription" type="object" class="btn-danger" invisible="status != 'active'"/>
|
<button name="action_suspend" string="Suspend Subscription" type="object" class="btn-danger" invisible="status != 'active'"/>
|
||||||
<button name="action_activate" string="Activate Subscription" type="object" class="oe_highlight" invisible="status not in ['suspended', 'expired']"/>
|
<button name="action_activate" string="Activate Subscription" type="object" class="oe_highlight" invisible="status not in ['suspended', 'expired']"/>
|
||||||
<field name="status" widget="statusbar" statusbar_visible="draft,provisioning,active,suspended,expired"/>
|
<field name="status" widget="statusbar" statusbar_visible="draft,provisioning,active,suspended,expired"/>
|
||||||
|
|||||||
@ -19,3 +19,44 @@ class ResConfigSettings(models.TransientModel):
|
|||||||
readonly=False,
|
readonly=False,
|
||||||
string='Reservation Secondary Color'
|
string='Reservation Secondary Color'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Form Field Visibility Settings
|
||||||
|
reservation_show_phone = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_phone',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Phone Number'
|
||||||
|
)
|
||||||
|
reservation_show_email = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_email',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Email Address'
|
||||||
|
)
|
||||||
|
reservation_show_guests = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_guests',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Number of Guests'
|
||||||
|
)
|
||||||
|
reservation_show_special_requests = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_special_requests',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Special Requests / Notes'
|
||||||
|
)
|
||||||
|
reservation_show_occasion = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_occasion',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Dining Occasion'
|
||||||
|
)
|
||||||
|
reservation_show_dietary = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_dietary',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Dietary Requirements / Allergies'
|
||||||
|
)
|
||||||
|
reservation_show_zone = fields.Boolean(
|
||||||
|
related='website_id.reservation_show_zone',
|
||||||
|
readonly=False,
|
||||||
|
string='Show Preferred Seating Area / Zone'
|
||||||
|
)
|
||||||
|
|
||||||
|
def action_open_custom_fields(self):
|
||||||
|
""" Open Custom Fields configuration list """
|
||||||
|
return self.env['ir.actions.act_window']._for_xml_id('dine360_reservation.action_reservation_custom_fields')
|
||||||
|
|||||||
@ -19,3 +19,40 @@ class Website(models.Model):
|
|||||||
default='#171422',
|
default='#171422',
|
||||||
help='Secondary color used for text within buttons and other components to ensure high contrast.'
|
help='Secondary color used for text within buttons and other components to ensure high contrast.'
|
||||||
)
|
)
|
||||||
|
|
||||||
|
# Form Fields Visibility Controls
|
||||||
|
reservation_show_phone = fields.Boolean(
|
||||||
|
string='Show Phone Number',
|
||||||
|
default=True,
|
||||||
|
help="If checked, Phone Number field will be displayed on the reservation form."
|
||||||
|
)
|
||||||
|
reservation_show_email = fields.Boolean(
|
||||||
|
string='Show Email Address',
|
||||||
|
default=True,
|
||||||
|
help="If checked, Email Address field will be displayed on the reservation form."
|
||||||
|
)
|
||||||
|
reservation_show_guests = fields.Boolean(
|
||||||
|
string='Show Number of Guests',
|
||||||
|
default=True,
|
||||||
|
help="If checked, Number of Guests field will be displayed on the reservation form."
|
||||||
|
)
|
||||||
|
reservation_show_special_requests = fields.Boolean(
|
||||||
|
string='Show Special Requests / Notes',
|
||||||
|
default=True,
|
||||||
|
help="If checked, Special Requests input box will be displayed on the reservation form."
|
||||||
|
)
|
||||||
|
reservation_show_occasion = fields.Boolean(
|
||||||
|
string='Show Dining Occasion',
|
||||||
|
default=False,
|
||||||
|
help="If checked, Dining Occasion dropdown (Birthday, Anniversary, etc.) will be displayed."
|
||||||
|
)
|
||||||
|
reservation_show_dietary = fields.Boolean(
|
||||||
|
string='Show Dietary Requirements / Allergies',
|
||||||
|
default=False,
|
||||||
|
help="If checked, Dietary Requirements input field will be displayed on the reservation form."
|
||||||
|
)
|
||||||
|
reservation_show_zone = fields.Boolean(
|
||||||
|
string='Show Preferred Seating Area / Zone',
|
||||||
|
default=False,
|
||||||
|
help="If checked, Seating Area preference dropdown will be displayed on the reservation form."
|
||||||
|
)
|
||||||
|
|||||||
@ -7,8 +7,9 @@
|
|||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//form" position="inside">
|
<xpath expr="//form" position="inside">
|
||||||
<app string="Theme Settings" name="dine360_theme_reservation" data-string="Theme Settings" data-key="dine360_theme_reservation">
|
<app string="Theme Settings" name="dine360_theme_reservation" data-string="Theme Settings" data-key="dine360_theme_reservation">
|
||||||
<block title="Table Reservation Theme" id="theme_reservation_settings">
|
<!-- 1. Theme Styling Colors -->
|
||||||
<setting string="Table Reservation Theme" help="Configure custom styling colors for the Table Reservation system" id="theme_reservation_settings_detail">
|
<block title="Table Reservation Theme Colors" id="theme_reservation_settings">
|
||||||
|
<setting string="Branding & Colors" help="Configure custom styling colors for the Table Reservation system" id="theme_reservation_settings_detail">
|
||||||
<field name="is_reservation_theme_active"/>
|
<field name="is_reservation_theme_active"/>
|
||||||
<div class="content-group" invisible="not is_reservation_theme_active">
|
<div class="content-group" invisible="not is_reservation_theme_active">
|
||||||
<div class="row mt16">
|
<div class="row mt16">
|
||||||
@ -22,6 +23,58 @@
|
|||||||
</div>
|
</div>
|
||||||
</setting>
|
</setting>
|
||||||
</block>
|
</block>
|
||||||
|
|
||||||
|
<!-- 2. Form Field Visibility Checklist -->
|
||||||
|
<block title="Reservation Form Fields (Visibility Checklist)" id="reservation_fields_settings">
|
||||||
|
<setting string="Contact & Guest Fields" help="Select which contact and guest inputs are shown on the reservation form" id="reservation_contact_fields">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_phone" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_phone" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_email" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_email" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_guests" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_guests" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
<setting string="Dining Preferences & Requests" help="Enable additional dining preferences and customer note fields" id="reservation_preference_fields">
|
||||||
|
<div class="content-group">
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_special_requests" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_special_requests" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_occasion" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_occasion" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_dietary" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_dietary" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
<div class="mt8">
|
||||||
|
<field name="reservation_show_zone" class="oe_inline"/>
|
||||||
|
<label for="reservation_show_zone" class="oe_inline ms-2"/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
|
|
||||||
|
<!-- 3. Additional Custom Fields -->
|
||||||
|
<block title="Additional Custom Fields" id="reservation_custom_fields_block">
|
||||||
|
<setting string="Custom Questions & Inputs" help="Add your own custom questions or fields (e.g. High Chair, Allergies, Special Occasion) to the reservation form">
|
||||||
|
<div class="mt8">
|
||||||
|
<button name="action_open_custom_fields" type="object" string="Manage Additional Fields" class="btn-primary" icon="fa-plus-circle"/>
|
||||||
|
</div>
|
||||||
|
<div class="text-muted mt8">
|
||||||
|
Click to create, activate, or reorder dynamic custom fields.
|
||||||
|
</div>
|
||||||
|
</setting>
|
||||||
|
</block>
|
||||||
</app>
|
</app>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user