diff --git a/addons/dine360_reservation/__manifest__.py b/addons/dine360_reservation/__manifest__.py index 3af7b7e..2e873d1 100644 --- a/addons/dine360_reservation/__manifest__.py +++ b/addons/dine360_reservation/__manifest__.py @@ -22,6 +22,7 @@ 'views/restaurant_table_views.xml', 'views/reservation_schedule_views.xml', 'views/reservation_holiday_views.xml', + 'views/reservation_custom_field_views.xml', 'views/reservation_templates.xml', 'views/menu_items.xml', ], diff --git a/addons/dine360_reservation/controllers/main.py b/addons/dine360_reservation/controllers/main.py index 32c1f30..1336dfc 100644 --- a/addons/dine360_reservation/controllers/main.py +++ b/addons/dine360_reservation/controllers/main.py @@ -1,4 +1,4 @@ -from odoo import http, _ +from odoo import http, _ from odoo.http import request import datetime import pytz @@ -8,8 +8,11 @@ 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([]) + custom_fields = request.env['reservation.custom.field'].sudo().search([('is_active', '=', True)]) 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): @@ -185,22 +188,68 @@ class TableReservationController(http.Controller): @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.'}) + schedule = request.env['reservation.schedule'].sudo().search([]) + active_custom_fields = request.env['reservation.custom.field'].sudo().search([('is_active', '=', True)]) + website = request.website - if not email: + # Extract standard data + customer_name = post.get('customer_name') + phone = post.get('phone', '') + email = post.get('email', '') + num_people = int(post.get('num_people', 2) or 2) + start_time_str = post.get('start_time') + + # Extract optional fields + 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 customer_name: return request.render("dine360_reservation.reservation_page_template", { - 'error': 'Email is required.', + 'error': _('Please provide your name.'), '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) restaurant_tz = pytz.timezone('America/Toronto') try: @@ -212,15 +261,17 @@ class TableReservationController(http.Controller): return request.render("dine360_reservation.reservation_page_template", { 'error': _("Invalid date or time format."), 'post': post, + 'schedule': schedule, + 'custom_fields': active_custom_fields, }) 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) + schedule_day = 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) + duration = self._get_slot_duration(schedule_day, time_float) end_time = start_time + datetime.timedelta(hours=duration) # FIND TABLES (Nearest Merge) @@ -252,22 +303,42 @@ class TableReservationController(http.Controller): 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.'}) + 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 try: - reservation = request.env['restaurant.reservation'].sudo().create({ + reservation_vals = { 'customer_name': customer_name, 'phone': phone, 'email': email, '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_id': assigned_tables[0].id, + 'table_id': assigned_tables[0].id if assigned_tables else False, 'start_time': start_time, 'end_time': end_time, '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 try: diff --git a/addons/dine360_reservation/models/__init__.py b/addons/dine360_reservation/models/__init__.py index efba3da..d97bf8e 100644 --- a/addons/dine360_reservation/models/__init__.py +++ b/addons/dine360_reservation/models/__init__.py @@ -3,3 +3,4 @@ from . import restaurant_table from . import reservation_schedule from . import reservation_holiday from . import reservation_peak_hour +from . import reservation_custom_field diff --git a/addons/dine360_reservation/models/reservation_custom_field.py b/addons/dine360_reservation/models/reservation_custom_field.py new file mode 100644 index 0000000..fd20292 --- /dev/null +++ b/addons/dine360_reservation/models/reservation_custom_field.py @@ -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') diff --git a/addons/dine360_reservation/models/restaurant_reservation.py b/addons/dine360_reservation/models/restaurant_reservation.py index 17a1875..17c9e29 100644 --- a/addons/dine360_reservation/models/restaurant_reservation.py +++ b/addons/dine360_reservation/models/restaurant_reservation.py @@ -10,10 +10,23 @@ class RestaurantReservation(models.Model): 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) - phone = fields.Char(string='Phone Number', required=True) - email = fields.Char(string='Email', required=True) + phone = fields.Char(string='Phone Number', required=False) + email = fields.Char(string='Email', required=False) 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') table_id = fields.Many2one('restaurant.table', string='Primary Table') table_ids = fields.Many2many('restaurant.table', string='Tables') diff --git a/addons/dine360_reservation/security/ir.model.access.csv b/addons/dine360_reservation/security/ir.model.access.csv index aab549d..e261697 100644 --- a/addons/dine360_reservation/security/ir.model.access.csv +++ b/addons/dine360_reservation/security/ir.model.access.csv @@ -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_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_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 diff --git a/addons/dine360_reservation/views/reservation_custom_field_views.xml b/addons/dine360_reservation/views/reservation_custom_field_views.xml new file mode 100644 index 0000000..568d039 --- /dev/null +++ b/addons/dine360_reservation/views/reservation_custom_field_views.xml @@ -0,0 +1,72 @@ + + + + + reservation.custom.field.tree + reservation.custom.field + + + + + + + + + + + + + + + + reservation.custom.field.form + reservation.custom.field + +
+ +
+
+ + + + + + + + + + + + + +
+
+
+
+ + + + Additional Custom Fields + reservation.custom.field + tree,form + +

+ Add an additional field or question to your Table Reservation form! +

+

+ You can create custom questions such as Allergies, Company Name, Seating Preferences, or High Chair requirements. +

+
+
+ + + +
diff --git a/addons/dine360_reservation/views/reservation_templates.xml b/addons/dine360_reservation/views/reservation_templates.xml index bb9e773..6dba7e0 100644 --- a/addons/dine360_reservation/views/reservation_templates.xml +++ b/addons/dine360_reservation/views/reservation_templates.xml @@ -128,33 +128,133 @@ -
- - -
-
- - -
-
- - -
-
- - -
-
- -
-
- - Select a date and guest count to see available slots -
-
- -
+ + +
+ + +
+
+ + + +
+ + +
+
+ + + +
+ + +
+
+ + + + +
+ + +
+ + + +
+ + +
+
+ + + +
+ + +
+
+ + + +
+ + +
+
+ + + +
+ + +
+
+ + + + +
+ + + + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+
+ + +
+ +
+
+ + Select a date and guest count to see available slots +
+
+ + +
@@ -172,7 +272,7 @@ function fetchSlots() { const date = dateInput.value; - const guests = guestsInput.value; + const guests = guestsInput ? (guestsInput.value || 2) : 2; if (!date || !guests) return; @@ -224,7 +324,7 @@ } dateInput.addEventListener('change', fetchSlots); - guestsInput.addEventListener('change', fetchSlots); + if (guestsInput) guestsInput.addEventListener('change', fetchSlots); if(dateInput.value) fetchSlots(); }); diff --git a/addons/dine360_reservation/views/reservation_views.xml b/addons/dine360_reservation/views/reservation_views.xml index ed315dd..aac0222 100644 --- a/addons/dine360_reservation/views/reservation_views.xml +++ b/addons/dine360_reservation/views/reservation_views.xml @@ -58,6 +58,28 @@ + + + + + + + + + + + + + + + + + + + + + + diff --git a/addons/dine360_saas_master/models/saas_restaurant.py b/addons/dine360_saas_master/models/saas_restaurant.py index 09613e7..080f751 100644 --- a/addons/dine360_saas_master/models/saas_restaurant.py +++ b/addons/dine360_saas_master/models/saas_restaurant.py @@ -181,7 +181,10 @@ class SaasRestaurant(models.Model): def action_create_database(self): self.ensure_one() 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 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): self.ensure_one() # Suspend by disabling login in target database diff --git a/addons/dine360_saas_master/views/saas_restaurant_views.xml b/addons/dine360_saas_master/views/saas_restaurant_views.xml index 9e62d09..9d69b8a 100644 --- a/addons/dine360_saas_master/views/saas_restaurant_views.xml +++ b/addons/dine360_saas_master/views/saas_restaurant_views.xml @@ -25,6 +25,7 @@
+
+ Click to create, activate, or reorder dynamic custom fields. +
+ +