Add community appointment booking addon

This commit is contained in:
metatroncubeswdev 2026-04-27 11:32:36 -04:00
parent 3ba6725c04
commit 89279003c7
13 changed files with 706 additions and 0 deletions

View File

@ -0,0 +1,2 @@
from . import controllers
from . import models

View File

@ -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,
}

View File

@ -0,0 +1 @@
from . import main

View File

@ -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/<int:appointment_type_id>"],
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/<int:appointment_type_id>/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/<string:token>"],
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},
)

View File

@ -0,0 +1,2 @@
from . import appointment_booking
from . import appointment_type

View File

@ -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

View File

@ -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

View File

@ -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
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_mcs_appointment_type_user mcs.appointment.type.user model_mcs_appointment_type mcs_appointment_booking.group_mcs_appointment_user 1 0 0 0
3 access_mcs_appointment_type_manager mcs.appointment.type.manager model_mcs_appointment_type mcs_appointment_booking.group_mcs_appointment_manager 1 1 1 1
4 access_mcs_appointment_booking_user mcs.appointment.booking.user model_mcs_appointment_booking mcs_appointment_booking.group_mcs_appointment_user 1 1 1 0
5 access_mcs_appointment_booking_manager mcs.appointment.booking.manager model_mcs_appointment_booking mcs_appointment_booking.group_mcs_appointment_manager 1 1 1 1

View File

@ -0,0 +1,10 @@
<odoo>
<record id="group_mcs_appointment_user" model="res.groups">
<field name="name">Appointment User</field>
</record>
<record id="group_mcs_appointment_manager" model="res.groups">
<field name="name">Appointment Manager</field>
<field name="implied_ids" eval="[(4, ref('group_mcs_appointment_user'))]"/>
</record>
</odoo>

View File

@ -0,0 +1,75 @@
<odoo>
<record id="view_mcs_appointment_booking_tree" model="ir.ui.view">
<field name="name">mcs.appointment.booking.tree</field>
<field name="model">mcs.appointment.booking</field>
<field name="arch" type="xml">
<tree decoration-muted="state == 'cancelled'">
<field name="start"/>
<field name="appointment_type_id"/>
<field name="customer_name"/>
<field name="customer_email"/>
<field name="user_id"/>
<field name="state"/>
</tree>
</field>
</record>
<record id="view_mcs_appointment_booking_form" model="ir.ui.view">
<field name="name">mcs.appointment.booking.form</field>
<field name="model">mcs.appointment.booking</field>
<field name="arch" type="xml">
<form>
<header>
<button name="action_confirm" type="object" string="Confirm" class="btn-primary" invisible="state == 'confirmed'"/>
<button name="action_cancel" type="object" string="Cancel" invisible="state == 'cancelled'"/>
<field name="state" widget="statusbar" statusbar_visible="draft,confirmed,cancelled"/>
</header>
<sheet>
<group>
<group>
<field name="appointment_type_id"/>
<field name="user_id"/>
<field name="partner_id"/>
<field name="calendar_event_id"/>
</group>
<group>
<field name="start"/>
<field name="stop"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
</group>
<group string="Customer">
<field name="customer_name"/>
<field name="customer_email"/>
<field name="customer_phone"/>
</group>
<group string="Notes">
<field name="notes" nolabel="1"/>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="view_mcs_appointment_booking_calendar" model="ir.ui.view">
<field name="name">mcs.appointment.booking.calendar</field>
<field name="model">mcs.appointment.booking</field>
<field name="arch" type="xml">
<calendar date_start="start" date_stop="stop" color="appointment_type_id">
<field name="customer_name"/>
<field name="appointment_type_id"/>
</calendar>
</field>
</record>
<record id="action_mcs_appointment_booking" model="ir.actions.act_window">
<field name="name">Bookings</field>
<field name="res_model">mcs.appointment.booking</field>
<field name="view_mode">tree,calendar,form</field>
</record>
</odoo>

View File

@ -0,0 +1,119 @@
<odoo>
<template id="appointment_type_list" name="Appointment Booking List">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="container py-5">
<h1 class="mb-4">Book an Appointment</h1>
<div t-if="not appointment_types" class="alert alert-info">
No appointment types are available right now.
</div>
<div class="row g-3">
<t t-foreach="appointment_types" t-as="appointment_type">
<div class="col-12 col-md-6 col-lg-4">
<div class="card h-100">
<div class="card-body">
<h2 class="h5 card-title" t-esc="appointment_type.name"/>
<div class="text-muted mb-3">
<span t-esc="appointment_type.duration"/> hour appointment
</div>
<div t-if="appointment_type.description" class="mb-3">
<t t-out="appointment_type.description"/>
</div>
<a class="btn btn-primary" t-att-href="'/appointments/%s' % appointment_type.id">View Times</a>
</div>
</div>
</div>
</t>
</div>
</section>
</div>
</t>
</template>
<template id="appointment_type_page" name="Appointment Booking Page">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="container py-5">
<a href="/appointments" class="btn btn-link px-0 mb-3">Back to appointments</a>
<h1 class="mb-2" t-esc="appointment_type.name"/>
<p class="text-muted mb-4">
<span t-esc="appointment_type.duration"/> hour appointment
</p>
<div class="mb-4 d-flex flex-wrap gap-2">
<t t-foreach="day_options" t-as="day">
<a t-attf-class="btn #{'btn-primary' if day == selected_day else 'btn-outline-primary'}"
t-att-href="'/appointments/%s?date=%s' % (appointment_type.id, day.isoformat())">
<t t-esc="day.strftime('%a %d %b')"/>
</a>
</t>
</div>
<div t-if="not slots" class="alert alert-info">
No free slots are available on this day.
</div>
<div class="row g-3">
<t t-foreach="slots" t-as="slot">
<div class="col-12 col-lg-6">
<form t-att-action="'/appointments/%s/book' % appointment_type.id" method="post" class="card">
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<input type="hidden" name="start" t-att-value="slot['start']"/>
<input type="hidden" name="stop" t-att-value="slot['stop']"/>
<div class="card-body">
<h2 class="h5 mb-3" t-esc="slot['label']"/>
<div class="row g-2">
<div class="col-12">
<label class="form-label">Name</label>
<input class="form-control" name="customer_name" required="required"/>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Email</label>
<input class="form-control" name="customer_email" type="email" required="required"/>
</div>
<div class="col-12 col-md-6">
<label class="form-label">Phone</label>
<input class="form-control" name="customer_phone"/>
</div>
<div class="col-12">
<label class="form-label">Notes</label>
<textarea class="form-control" name="notes" rows="2"/>
</div>
</div>
<button class="btn btn-primary mt-3" type="submit">Book Appointment</button>
</div>
</form>
</div>
</t>
</div>
</section>
</div>
</t>
</template>
<template id="appointment_confirmed" name="Appointment Confirmed">
<t t-call="website.layout">
<div id="wrap" class="oe_structure">
<section class="container py-5">
<div class="alert alert-success">
Your appointment has been booked.
</div>
<h1 class="mb-3" t-esc="booking.appointment_type_id.name"/>
<p>
<strong>Name:</strong>
<span t-esc="booking.customer_name"/>
</p>
<p>
<strong>Email:</strong>
<span t-esc="booking.customer_email"/>
</p>
<p>
<strong>Start:</strong>
<span t-field="booking.start"/>
</p>
<a href="/appointments" class="btn btn-primary">Book another appointment</a>
</section>
</div>
</t>
</template>
</odoo>

View File

@ -0,0 +1,66 @@
<odoo>
<record id="view_mcs_appointment_type_tree" model="ir.ui.view">
<field name="name">mcs.appointment.type.tree</field>
<field name="model">mcs.appointment.type</field>
<field name="arch" type="xml">
<tree>
<field name="sequence" widget="handle"/>
<field name="name"/>
<field name="user_id"/>
<field name="calendar_id"/>
<field name="duration"/>
<field name="slot_step"/>
<field name="website_published"/>
<field name="company_id" groups="base.group_multi_company"/>
</tree>
</field>
</record>
<record id="view_mcs_appointment_type_form" model="ir.ui.view">
<field name="name">mcs.appointment.type.form</field>
<field name="model">mcs.appointment.type</field>
<field name="arch" type="xml">
<form>
<sheet>
<div class="oe_button_box" name="button_box">
<button name="action_view_bookings" type="object" class="oe_stat_button" icon="fa-calendar">
<field name="booking_count" widget="statinfo" string="Bookings"/>
</button>
</div>
<group>
<group>
<field name="name"/>
<field name="active"/>
<field name="website_published"/>
<field name="company_id" groups="base.group_multi_company"/>
</group>
<group>
<field name="user_id"/>
<field name="calendar_id"/>
<field name="duration"/>
<field name="slot_step"/>
<field name="min_schedule_hours"/>
<field name="max_schedule_days"/>
</group>
</group>
<notebook>
<page string="Description">
<field name="description"/>
</page>
</notebook>
</sheet>
<div class="oe_chatter">
<field name="message_follower_ids"/>
<field name="activity_ids"/>
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="action_mcs_appointment_type" model="ir.actions.act_window">
<field name="name">Appointment Types</field>
<field name="res_model">mcs.appointment.type</field>
<field name="view_mode">tree,form</field>
</record>
</odoo>

View File

@ -0,0 +1,5 @@
<odoo>
<menuitem id="menu_mcs_appointments_root" name="Appointments" sequence="45" groups="mcs_appointment_booking.group_mcs_appointment_user"/>
<menuitem id="menu_mcs_appointment_booking" name="Bookings" parent="menu_mcs_appointments_root" action="action_mcs_appointment_booking" sequence="10"/>
<menuitem id="menu_mcs_appointment_type" name="Appointment Types" parent="menu_mcs_appointments_root" action="action_mcs_appointment_type" sequence="20" groups="mcs_appointment_booking.group_mcs_appointment_manager"/>
</odoo>