From 89279003c753dc3aec0a7a017af9d59ae36ab29e Mon Sep 17 00:00:00 2001 From: metatroncubeswdev Date: Mon, 27 Apr 2026 11:32:36 -0400 Subject: [PATCH] Add community appointment booking addon --- addons/mcs_appointment_booking/__init__.py | 2 + .../mcs_appointment_booking/__manifest__.py | 19 +++ .../controllers/__init__.py | 1 + .../controllers/main.py | 129 ++++++++++++++ .../models/__init__.py | 2 + .../models/appointment_booking.py | 114 +++++++++++++ .../models/appointment_type.py | 159 ++++++++++++++++++ .../security/ir.model.access.csv | 5 + .../security/security.xml | 10 ++ .../views/appointment_booking_views.xml | 75 +++++++++ .../views/appointment_templates.xml | 119 +++++++++++++ .../views/appointment_type_views.xml | 66 ++++++++ addons/mcs_appointment_booking/views/menu.xml | 5 + 13 files changed, 706 insertions(+) create mode 100644 addons/mcs_appointment_booking/__init__.py create mode 100644 addons/mcs_appointment_booking/__manifest__.py create mode 100644 addons/mcs_appointment_booking/controllers/__init__.py create mode 100644 addons/mcs_appointment_booking/controllers/main.py create mode 100644 addons/mcs_appointment_booking/models/__init__.py create mode 100644 addons/mcs_appointment_booking/models/appointment_booking.py create mode 100644 addons/mcs_appointment_booking/models/appointment_type.py create mode 100644 addons/mcs_appointment_booking/security/ir.model.access.csv create mode 100644 addons/mcs_appointment_booking/security/security.xml create mode 100644 addons/mcs_appointment_booking/views/appointment_booking_views.xml create mode 100644 addons/mcs_appointment_booking/views/appointment_templates.xml create mode 100644 addons/mcs_appointment_booking/views/appointment_type_views.xml create mode 100644 addons/mcs_appointment_booking/views/menu.xml diff --git a/addons/mcs_appointment_booking/__init__.py b/addons/mcs_appointment_booking/__init__.py new file mode 100644 index 0000000..91c5580 --- /dev/null +++ b/addons/mcs_appointment_booking/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/addons/mcs_appointment_booking/__manifest__.py b/addons/mcs_appointment_booking/__manifest__.py new file mode 100644 index 0000000..efabfc3 --- /dev/null +++ b/addons/mcs_appointment_booking/__manifest__.py @@ -0,0 +1,19 @@ +{ + "name": "Metatroncube Appointment Booking", + "version": "17.0.1.0.0", + "category": "Appointments", + "summary": "Community appointment booking with website forms and calendar events", + "author": "Metatroncube Software Solutions", + "license": "AGPL-3", + "depends": ["calendar", "website"], + "data": [ + "security/security.xml", + "security/ir.model.access.csv", + "views/appointment_booking_views.xml", + "views/appointment_type_views.xml", + "views/appointment_templates.xml", + "views/menu.xml", + ], + "installable": True, + "application": True, +} diff --git a/addons/mcs_appointment_booking/controllers/__init__.py b/addons/mcs_appointment_booking/controllers/__init__.py new file mode 100644 index 0000000..12a7e52 --- /dev/null +++ b/addons/mcs_appointment_booking/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/addons/mcs_appointment_booking/controllers/main.py b/addons/mcs_appointment_booking/controllers/main.py new file mode 100644 index 0000000..f3778e5 --- /dev/null +++ b/addons/mcs_appointment_booking/controllers/main.py @@ -0,0 +1,129 @@ +from datetime import datetime, timedelta + +import pytz + +from odoo import fields, http +from odoo.http import request + + +class McsAppointmentController(http.Controller): + def _type_or_404(self, appointment_type_id): + appointment_type = ( + request.env["mcs.appointment.type"] + .sudo() + .search( + [ + ("id", "=", appointment_type_id), + ("active", "=", True), + ("website_published", "=", True), + ], + limit=1, + ) + ) + if not appointment_type: + return request.not_found() + return appointment_type + + def _display_slots(self, appointment_type, day): + tz = appointment_type._timezone() + slots = [] + for slot in appointment_type.get_available_slots(day): + start_utc = pytz.UTC.localize(slot["start"]) + stop_utc = pytz.UTC.localize(slot["stop"]) + slots.append( + { + "start": fields.Datetime.to_string(slot["start"]), + "stop": fields.Datetime.to_string(slot["stop"]), + "label": "%s - %s" + % ( + start_utc.astimezone(tz).strftime("%I:%M %p"), + stop_utc.astimezone(tz).strftime("%I:%M %p"), + ), + } + ) + return slots + + @http.route(["/appointments"], type="http", auth="public", website=True) + def appointments(self, **kwargs): + types = request.env["mcs.appointment.type"].sudo().search( + request.env["mcs.appointment.type"]._public_domain() + ) + return request.render( + "mcs_appointment_booking.appointment_type_list", + {"appointment_types": types}, + ) + + @http.route( + ["/appointments/"], + type="http", + auth="public", + website=True, + ) + def appointment_type(self, appointment_type_id, date=None, **kwargs): + appointment_type = self._type_or_404(appointment_type_id) + if hasattr(appointment_type, "status_code"): + return appointment_type + today = fields.Date.context_today(request.env.user) + selected_day = fields.Date.to_date(date) if date else today + max_day = today + timedelta(days=appointment_type.max_schedule_days) + if selected_day < today: + selected_day = today + if selected_day > max_day: + selected_day = max_day + day_options = [today + timedelta(days=index) for index in range(appointment_type.max_schedule_days)] + return request.render( + "mcs_appointment_booking.appointment_type_page", + { + "appointment_type": appointment_type, + "selected_day": selected_day, + "day_options": day_options, + "slots": self._display_slots(appointment_type, selected_day), + }, + ) + + @http.route( + ["/appointments//book"], + type="http", + auth="public", + website=True, + methods=["POST"], + ) + def appointment_book(self, appointment_type_id, **post): + appointment_type = self._type_or_404(appointment_type_id) + if hasattr(appointment_type, "status_code"): + return appointment_type + + start = fields.Datetime.from_string(post.get("start")) + stop = fields.Datetime.from_string(post.get("stop")) + booking = request.env["mcs.appointment.booking"].sudo().create( + { + "appointment_type_id": appointment_type.id, + "customer_name": (post.get("customer_name") or "").strip(), + "customer_email": (post.get("customer_email") or "").strip(), + "customer_phone": (post.get("customer_phone") or "").strip(), + "start": start, + "stop": stop, + "notes": (post.get("notes") or "").strip(), + } + ) + booking.action_confirm() + return request.redirect("/appointments/confirmed/%s" % booking.access_token) + + @http.route( + ["/appointments/confirmed/"], + type="http", + auth="public", + website=True, + ) + def appointment_confirmed(self, token, **kwargs): + booking = ( + request.env["mcs.appointment.booking"] + .sudo() + .search([("access_token", "=", token)], limit=1) + ) + if not booking: + return request.not_found() + return request.render( + "mcs_appointment_booking.appointment_confirmed", + {"booking": booking}, + ) diff --git a/addons/mcs_appointment_booking/models/__init__.py b/addons/mcs_appointment_booking/models/__init__.py new file mode 100644 index 0000000..9b870d0 --- /dev/null +++ b/addons/mcs_appointment_booking/models/__init__.py @@ -0,0 +1,2 @@ +from . import appointment_booking +from . import appointment_type diff --git a/addons/mcs_appointment_booking/models/appointment_booking.py b/addons/mcs_appointment_booking/models/appointment_booking.py new file mode 100644 index 0000000..1b63760 --- /dev/null +++ b/addons/mcs_appointment_booking/models/appointment_booking.py @@ -0,0 +1,114 @@ +import secrets + +from odoo import api, fields, models +from odoo.exceptions import UserError, ValidationError + + +class McsAppointmentBooking(models.Model): + _name = "mcs.appointment.booking" + _description = "Appointment Booking" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "start desc" + + name = fields.Char(compute="_compute_name", store=True) + appointment_type_id = fields.Many2one( + "mcs.appointment.type", required=True, ondelete="restrict", tracking=True + ) + company_id = fields.Many2one( + related="appointment_type_id.company_id", store=True, readonly=True + ) + user_id = fields.Many2one( + related="appointment_type_id.user_id", string="Host", store=True, readonly=True + ) + partner_id = fields.Many2one("res.partner", string="Customer") + customer_name = fields.Char(required=True, tracking=True) + customer_email = fields.Char(required=True, tracking=True) + customer_phone = fields.Char() + start = fields.Datetime(required=True, tracking=True) + stop = fields.Datetime(required=True, tracking=True) + duration = fields.Float(related="appointment_type_id.duration", readonly=True) + state = fields.Selection( + [ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("cancelled", "Cancelled"), + ], + default="draft", + required=True, + tracking=True, + ) + notes = fields.Text() + calendar_event_id = fields.Many2one("calendar.event", readonly=True, copy=False) + access_token = fields.Char(default=lambda self: secrets.token_urlsafe(24), copy=False) + + @api.depends("appointment_type_id", "customer_name", "start") + def _compute_name(self): + for booking in self: + parts = [ + booking.appointment_type_id.name or "Appointment", + booking.customer_name or "Customer", + ] + if booking.start: + parts.append(fields.Datetime.to_string(booking.start)) + booking.name = " - ".join(parts) + + @api.constrains("start", "stop") + def _check_dates(self): + for booking in self: + if booking.start and booking.stop and booking.start >= booking.stop: + raise ValidationError("Appointment end must be after start.") + + def _ensure_partner(self): + self.ensure_one() + if self.partner_id: + return self.partner_id + partner = self.env["res.partner"].sudo().search( + [("email", "=", self.customer_email)], limit=1 + ) + if not partner: + partner = self.env["res.partner"].sudo().create( + { + "name": self.customer_name, + "email": self.customer_email, + "phone": self.customer_phone, + "company_id": self.company_id.id, + } + ) + self.partner_id = partner.id + return partner + + def action_confirm(self): + for booking in self: + if booking.state == "confirmed": + continue + busy = booking.appointment_type_id._busy_intervals(booking.start, booking.stop) + busy = [ + interval + for interval in busy + if not booking.calendar_event_id + or interval != (booking.calendar_event_id.start, booking.calendar_event_id.stop) + ] + if not booking.appointment_type_id._is_free(booking.start, booking.stop, busy): + raise UserError("This time slot is no longer available.") + + partner = booking._ensure_partner() + event_vals = { + "name": booking.name, + "start": booking.start, + "stop": booking.stop, + "user_id": booking.user_id.id, + "partner_ids": [(6, 0, [partner.id, booking.user_id.partner_id.id])], + "description": booking.notes or "", + } + if booking.calendar_event_id: + booking.calendar_event_id.write(event_vals) + else: + booking.calendar_event_id = self.env["calendar.event"].sudo().create(event_vals).id + booking.state = "confirmed" + + def action_cancel(self): + for booking in self: + booking.state = "cancelled" + if booking.calendar_event_id: + booking.calendar_event_id.sudo().unlink() + booking.calendar_event_id = False diff --git a/addons/mcs_appointment_booking/models/appointment_type.py b/addons/mcs_appointment_booking/models/appointment_type.py new file mode 100644 index 0000000..7244568 --- /dev/null +++ b/addons/mcs_appointment_booking/models/appointment_type.py @@ -0,0 +1,159 @@ +from datetime import datetime, time, timedelta + +import pytz + +from odoo import api, fields, models +from odoo.exceptions import ValidationError + + +class McsAppointmentType(models.Model): + _name = "mcs.appointment.type" + _description = "Appointment Type" + _inherit = ["mail.thread", "mail.activity.mixin"] + _order = "sequence, name" + + name = fields.Char(required=True, tracking=True) + sequence = fields.Integer(default=10) + active = fields.Boolean(default=True) + website_published = fields.Boolean(string="Published on Website", default=True) + company_id = fields.Many2one( + "res.company", default=lambda self: self.env.company, required=True + ) + user_id = fields.Many2one( + "res.users", + string="Host", + default=lambda self: self.env.user, + required=True, + tracking=True, + ) + calendar_id = fields.Many2one( + "resource.calendar", + string="Availability Calendar", + required=True, + default=lambda self: self.env.company.resource_calendar_id, + ) + duration = fields.Float( + string="Duration", + default=1.0, + required=True, + help="Appointment duration in hours.", + ) + slot_step = fields.Float( + string="Slot Step", + default=0.5, + required=True, + help="Distance between proposed slots in hours.", + ) + min_schedule_hours = fields.Float( + string="Minimum Notice", + default=2.0, + help="Minimum hours before a visitor can book.", + ) + max_schedule_days = fields.Integer( + string="Book Up To", + default=30, + help="Maximum number of future days shown for booking.", + ) + description = fields.Html() + booking_ids = fields.One2many("mcs.appointment.booking", "appointment_type_id") + booking_count = fields.Integer(compute="_compute_booking_count") + + @api.depends("booking_ids") + def _compute_booking_count(self): + grouped = self.env["mcs.appointment.booking"].read_group( + [("appointment_type_id", "in", self.ids)], + ["appointment_type_id"], + ["appointment_type_id"], + ) + counts = {row["appointment_type_id"][0]: row["appointment_type_id_count"] for row in grouped} + for record in self: + record.booking_count = counts.get(record.id, 0) + + @api.constrains("duration", "slot_step", "max_schedule_days") + def _check_positive_values(self): + for record in self: + if record.duration <= 0: + raise ValidationError("Appointment duration must be positive.") + if record.slot_step <= 0: + raise ValidationError("Slot step must be positive.") + if record.max_schedule_days <= 0: + raise ValidationError("Book up to must be positive.") + + def action_view_bookings(self): + self.ensure_one() + return { + "name": "Bookings", + "type": "ir.actions.act_window", + "res_model": "mcs.appointment.booking", + "view_mode": "tree,form,calendar", + "domain": [("appointment_type_id", "=", self.id)], + "context": {"default_appointment_type_id": self.id}, + } + + def _public_domain(self): + return [("active", "=", True), ("website_published", "=", True)] + + def _timezone(self): + return pytz.timezone(self.calendar_id.tz or self.env.user.tz or "UTC") + + def _week_type_for_date(self, day): + return "1" if day.isocalendar()[1] % 2 == 0 else "0" + + def _float_to_time(self, value): + hours = int(value) + minutes = int(round((value - hours) * 60)) + if minutes == 60: + hours += 1 + minutes = 0 + return time(hour=hours, minute=minutes) + + def _localize(self, day, hour_float): + tz = self._timezone() + local_dt = datetime.combine(day, self._float_to_time(hour_float)) + return tz.localize(local_dt).astimezone(pytz.UTC).replace(tzinfo=None) + + def _busy_intervals(self, day_start, day_end): + events = self.env["calendar.event"].sudo().search( + [ + ("user_id", "=", self.user_id.id), + ("start", "<", fields.Datetime.to_string(day_end)), + ("stop", ">", fields.Datetime.to_string(day_start)), + ] + ) + return [(event.start, event.stop) for event in events if event.start and event.stop] + + def _is_free(self, start, stop, busy_intervals): + for busy_start, busy_stop in busy_intervals: + if start < busy_stop and stop > busy_start: + return False + return True + + def get_available_slots(self, day, limit=40): + self.ensure_one() + now = fields.Datetime.now() + timedelta(hours=self.min_schedule_hours) + if day < fields.Date.context_today(self): + return [] + + week_type = self._week_type_for_date(day) + attendances = self.calendar_id.attendance_ids.filtered( + lambda line: line.dayofweek == str(day.weekday()) + and line.day_period != "lunch" + and (not self.calendar_id.two_weeks_calendar or line.week_type == week_type) + ) + day_start = self._localize(day, 0) + day_end = self._localize(day, 23.99) + busy_intervals = self._busy_intervals(day_start, day_end) + slots = [] + step = timedelta(hours=self.slot_step) + duration = timedelta(hours=self.duration) + for attendance in attendances.sorted("hour_from"): + cursor = self._localize(day, attendance.hour_from) + end_limit = self._localize(day, attendance.hour_to) + while cursor + duration <= end_limit: + stop = cursor + duration + if cursor >= now and self._is_free(cursor, stop, busy_intervals): + slots.append({"start": cursor, "stop": stop}) + if len(slots) >= limit: + return slots + cursor += step + return slots diff --git a/addons/mcs_appointment_booking/security/ir.model.access.csv b/addons/mcs_appointment_booking/security/ir.model.access.csv new file mode 100644 index 0000000..d6bf9ee --- /dev/null +++ b/addons/mcs_appointment_booking/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_mcs_appointment_type_user,mcs.appointment.type.user,model_mcs_appointment_type,mcs_appointment_booking.group_mcs_appointment_user,1,0,0,0 +access_mcs_appointment_type_manager,mcs.appointment.type.manager,model_mcs_appointment_type,mcs_appointment_booking.group_mcs_appointment_manager,1,1,1,1 +access_mcs_appointment_booking_user,mcs.appointment.booking.user,model_mcs_appointment_booking,mcs_appointment_booking.group_mcs_appointment_user,1,1,1,0 +access_mcs_appointment_booking_manager,mcs.appointment.booking.manager,model_mcs_appointment_booking,mcs_appointment_booking.group_mcs_appointment_manager,1,1,1,1 diff --git a/addons/mcs_appointment_booking/security/security.xml b/addons/mcs_appointment_booking/security/security.xml new file mode 100644 index 0000000..17a24de --- /dev/null +++ b/addons/mcs_appointment_booking/security/security.xml @@ -0,0 +1,10 @@ + + + Appointment User + + + + Appointment Manager + + + diff --git a/addons/mcs_appointment_booking/views/appointment_booking_views.xml b/addons/mcs_appointment_booking/views/appointment_booking_views.xml new file mode 100644 index 0000000..ec29cb7 --- /dev/null +++ b/addons/mcs_appointment_booking/views/appointment_booking_views.xml @@ -0,0 +1,75 @@ + + + mcs.appointment.booking.tree + mcs.appointment.booking + + + + + + + + + + + + + + mcs.appointment.booking.form + mcs.appointment.booking + +
+
+
+ + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ + + mcs.appointment.booking.calendar + mcs.appointment.booking + + + + + + + + + + Bookings + mcs.appointment.booking + tree,calendar,form + +
diff --git a/addons/mcs_appointment_booking/views/appointment_templates.xml b/addons/mcs_appointment_booking/views/appointment_templates.xml new file mode 100644 index 0000000..ae68fd6 --- /dev/null +++ b/addons/mcs_appointment_booking/views/appointment_templates.xml @@ -0,0 +1,119 @@ + + + +