Add community appointment booking addon
This commit is contained in:
parent
3ba6725c04
commit
89279003c7
2
addons/mcs_appointment_booking/__init__.py
Normal file
2
addons/mcs_appointment_booking/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
19
addons/mcs_appointment_booking/__manifest__.py
Normal file
19
addons/mcs_appointment_booking/__manifest__.py
Normal 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,
|
||||
}
|
||||
1
addons/mcs_appointment_booking/controllers/__init__.py
Normal file
1
addons/mcs_appointment_booking/controllers/__init__.py
Normal file
@ -0,0 +1 @@
|
||||
from . import main
|
||||
129
addons/mcs_appointment_booking/controllers/main.py
Normal file
129
addons/mcs_appointment_booking/controllers/main.py
Normal 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},
|
||||
)
|
||||
2
addons/mcs_appointment_booking/models/__init__.py
Normal file
2
addons/mcs_appointment_booking/models/__init__.py
Normal file
@ -0,0 +1,2 @@
|
||||
from . import appointment_booking
|
||||
from . import appointment_type
|
||||
114
addons/mcs_appointment_booking/models/appointment_booking.py
Normal file
114
addons/mcs_appointment_booking/models/appointment_booking.py
Normal 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
|
||||
159
addons/mcs_appointment_booking/models/appointment_type.py
Normal file
159
addons/mcs_appointment_booking/models/appointment_type.py
Normal 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
|
||||
@ -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
|
||||
|
10
addons/mcs_appointment_booking/security/security.xml
Normal file
10
addons/mcs_appointment_booking/security/security.xml
Normal 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>
|
||||
@ -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>
|
||||
119
addons/mcs_appointment_booking/views/appointment_templates.xml
Normal file
119
addons/mcs_appointment_booking/views/appointment_templates.xml
Normal 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>
|
||||
@ -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>
|
||||
5
addons/mcs_appointment_booking/views/menu.xml
Normal file
5
addons/mcs_appointment_booking/views/menu.xml
Normal 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>
|
||||
Loading…
x
Reference in New Issue
Block a user