- Add this event to your calendar @@ -104,16 +106,16 @@ |
-
From
-
To - (
|
@@ -259,8 +261,10 @@
- Add this event to your calendar @@ -374,16 +378,16 @@ |
-
From
-
To - (
|
@@ -528,8 +532,10 @@
- Add this event to your calendar @@ -631,16 +637,16 @@ |
-
From
-
To - (
|
diff --git a/addons/event/models/__init__.py b/addons/event/models/__init__.py
index a23b919654a98..936cd71e96dc5 100644
--- a/addons/event/models/__init__.py
+++ b/addons/event/models/__init__.py
@@ -4,10 +4,13 @@
from . import event_event
from . import event_mail
from . import event_mail_registration
+from . import event_mail_slot
from . import event_registration
+from . import event_slot
from . import event_stage
from . import event_tag
from . import event_ticket
+from . import event_slot_ticket # Init required after the event_event_ticket table.
from . import mail_template
from . import res_config_settings
from . import res_partner
diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py
index ca2e601189f88..937a313925107 100644
--- a/addons/event/models/event_event.py
+++ b/addons/event/models/event_event.py
@@ -174,7 +174,8 @@ def _default_question_ids(self):
seats_max = fields.Integer(
string='Maximum Attendees',
compute='_compute_seats_max', readonly=False, store=True,
- help="For each event you can define a maximum registration of seats(number of attendees), above this numbers the registrations are not accepted.")
+ help="""For each event you can define a maximum registration of seats(number of attendees), above this numbers the registrations are not accepted.
+ If the event has multiple slots, this maximum number is applied per slot.""")
seats_limited = fields.Boolean('Limit Attendees', required=True, compute='_compute_seats_limited',
precompute=True, readonly=False, store=True)
seats_reserved = fields.Integer(
@@ -190,6 +191,7 @@ def _default_question_ids(self):
string='Number of Taken Seats',
store=False, readonly=True, compute='_compute_seats')
# Registration fields
+ slot_ticket_limitation_ids = fields.One2many("event.slot.ticket", inverse_name="event_id")
registration_ids = fields.One2many('event.registration', 'event_id', string='Attendees')
event_ticket_ids = fields.One2many(
'event.event.ticket', 'event_id', string='Event Ticket', copy=True,
@@ -221,6 +223,10 @@ def _default_question_ids(self):
is_ongoing = fields.Boolean('Is Ongoing', compute='_compute_is_ongoing', search='_search_is_ongoing')
is_one_day = fields.Boolean(compute='_compute_field_is_one_day')
is_finished = fields.Boolean(compute='_compute_is_finished', search='_search_is_finished')
+ # Slots
+ is_multi_slots = fields.Boolean("Is Multi Slots", default=False, help="Allow multiple time slots.")
+ slot_ids = fields.One2many("event.slot", "event_id", "Slots", copy=True,
+ compute="_compute_slot_ids", store=True, readonly=False)
# Location and communication
address_id = fields.Many2one(
'res.partner', string='Venue', default=lambda self: self.env.company.partner_id.id,
@@ -319,33 +325,45 @@ def _compute_kanban_state_label(self):
else:
event.kanban_state_label = event.stage_id.legend_done
- @api.depends('seats_max', 'registration_ids.state', 'registration_ids.active')
+ @api.depends('seats_max', 'registration_ids.state', 'registration_ids.active',
+ 'is_multi_slots', 'slot_ids', 'slot_ids.seats_reserved', 'slot_ids.seats_used',
+ 'slot_ids.seats_available', 'slot_ids.seats_taken')
def _compute_seats(self):
""" Determine available, reserved, used and taken seats. """
- # initialize fields to 0
+ # initialize fields to 0 and compute seats for multi-slots events
for event in self:
event.seats_reserved = event.seats_used = event.seats_available = 0
- # aggregate registrations by event and by state
+ if event.is_multi_slots:
+ event.seats_reserved = sum(event.slot_ids.mapped("seats_reserved"))
+ event.seats_used = sum(event.slot_ids.mapped("seats_used"))
+ event.seats_available = sum(event.slot_ids.mapped("seats_available"))
+ event.seats_taken = sum(event.slot_ids.mapped("seats_taken"))
+
+ events_without_slots = self.filtered(lambda event: not event.is_multi_slots)
+ if not events_without_slots:
+ return
+
+ # For event without slots, aggregate registrations by event and by state
state_field = {
'open': 'seats_reserved',
'done': 'seats_used',
}
- base_vals = dict((fname, 0) for fname in state_field.values())
- results = dict((event_id, dict(base_vals)) for event_id in self.ids)
- if self.ids:
+ base_vals = dict.fromkeys(state_field.values(), 0)
+ results = {event_id: dict(base_vals) for event_id in events_without_slots.ids}
+ if events_without_slots.ids:
query = """ SELECT event_id, state, count(event_id)
FROM event_registration
WHERE event_id IN %s AND state IN ('open', 'done') AND active = true
GROUP BY event_id, state
"""
self.env['event.registration'].flush_model(['event_id', 'state', 'active'])
- self._cr.execute(query, (tuple(self.ids),))
+ self._cr.execute(query, (tuple(events_without_slots.ids),))
res = self._cr.fetchall()
for event_id, state, num in res:
results[event_id][state_field[state]] = num
# compute seats_available and expected
- for event in self:
+ for event in events_without_slots:
event.update(results.get(event._origin.id or event.id, base_vals))
if event.seats_max > 0:
event.seats_available = event.seats_max - (event.seats_reserved + event.seats_used)
@@ -381,7 +399,11 @@ def _compute_event_registrations_open(self):
event.event_registrations_open = event.event_registrations_started and \
(date_end_tz >= current_datetime if date_end_tz else True) and \
(not event.seats_limited or not event.seats_max or event.seats_available) and \
- (not event.event_ticket_ids or any(ticket.sale_available for ticket in event.event_ticket_ids))
+ (not event.event_ticket_ids or
+ any(ticket.sale_available for ticket in (
+ event.slot_ticket_limitation_ids if event.is_multi_slots else event.event_ticket_ids
+ ))
+ )
@api.depends('event_ticket_ids.start_sale_datetime')
def _compute_start_sale_date(self):
@@ -402,7 +424,11 @@ def _compute_event_registrations_sold_out(self):
for event in self:
event.event_registrations_sold_out = (
(event.seats_limited and event.seats_max and not event.seats_available)
- or (event.event_ticket_ids and all(ticket.is_sold_out for ticket in event.event_ticket_ids))
+ or (event.event_ticket_ids and
+ all(ticket.is_sold_out for ticket in (
+ event.slot_ticket_limitation_ids if event.is_multi_slots else event.event_ticket_ids
+ ))
+ )
)
@api.depends('date_begin', 'date_end')
@@ -615,6 +641,12 @@ def _compute_event_url(self):
"""Reset url field as it should only be used for events with no physical location."""
self.filtered('address_id').event_url = ''
+ @api.depends("is_multi_slots")
+ def _compute_slot_ids(self):
+ for event in self:
+ if not event.is_multi_slots and event.slot_ids:
+ event.slot_ids = False
+
@api.constrains('seats_max', 'seats_limited', 'registration_ids')
def _check_seats_availability(self, minimal_availability=0):
sold_out_events = []
@@ -648,6 +680,32 @@ def _onchange_event_url(self):
if parsed_url.scheme not in ('http', 'https'):
event.event_url = 'https://' + event.event_url
+ @api.constrains("date_tz", "date_begin", "date_end")
+ def _check_slots_dates(self):
+ for event in self:
+ slots_outside_event_bounds = self.slot_ids.filtered(lambda slot:
+ not (event.date_begin <= slot.start_datetime <= event.date_end) or
+ not (event.date_begin <= slot.end_datetime <= event.date_end)
+ )
+ if slots_outside_event_bounds:
+ raise ValidationError(_(
+ "The event slots cannot be scheduled outside of the event time range.\n\n"
+ "Event (%(tz)s):\n"
+ "%(event_start)s - %(event_end)s\n\n"
+ "Slots (%(tz)s):\n"
+ "%(slots)s",
+ tz=event.date_tz,
+ event_start=format_datetime(self.env, event.date_begin, tz=event.date_tz, dt_format='medium'),
+ event_end=format_datetime(self.env, event.date_end, tz=event.date_tz, dt_format='medium'),
+ slots="\n".join(f"- {slot.name}" for slot in slots_outside_event_bounds)
+ ))
+
+ @api.constrains("is_multi_slots", "slot_ids")
+ def _check_slots_number(self):
+ for event in self:
+ if event.is_multi_slots and len(event.slot_ids) == 0:
+ raise ValidationError(_("A multi-slots event should have at least one slot (Dates & Slots tab)."))
+
@api.model_create_multi
def create(self, vals_list):
events = super(EventEvent, self).create(vals_list)
@@ -749,8 +807,9 @@ def _get_external_description(self):
description += textwrap.shorten(html_to_inner_content(self.description), 1900)
return description
- def _get_ics_file(self):
+ def _get_ics_file(self, slot=False):
""" Returns iCalendar file for the event invitation.
+ :param slot: If a slot is given, schedule with the given slot datetimes
:returns a dict of .ics file content for each event
"""
result = {}
@@ -760,10 +819,12 @@ def _get_ics_file(self):
for event in self:
cal = vobject.iCalendar()
cal_event = cal.add('vevent')
+ start = slot.start_datetime if slot else event.date_begin
+ end = slot.end_datetime if slot else event.date_end
cal_event.add('created').value = fields.Datetime.now().replace(tzinfo=pytz.timezone('UTC'))
- cal_event.add('dtstart').value = event.date_begin.astimezone(pytz.timezone(event.date_tz))
- cal_event.add('dtend').value = event.date_end.astimezone(pytz.timezone(event.date_tz))
+ cal_event.add('dtstart').value = start.astimezone(pytz.timezone(event.date_tz))
+ cal_event.add('dtend').value = end.astimezone(pytz.timezone(event.date_tz))
cal_event.add('summary').value = event.name
cal_event.add('description').value = event._get_external_description()
if event.address_id:
diff --git a/addons/event/models/event_mail.py b/addons/event/models/event_mail.py
index 53189762562dc..5f440b744b156 100644
--- a/addons/event/models/event_mail.py
+++ b/addons/event/models/event_mail.py
@@ -5,7 +5,7 @@
from dateutil.relativedelta import relativedelta
from markupsafe import Markup
-from odoo import api, fields, models, modules, tools
+from odoo import api, fields, models, modules, tools, SUPERUSER_ID
from odoo.addons.base.models.ir_qweb import QWebException
from odoo.tools import exception_to_unicode
from odoo.tools.translate import _
@@ -84,10 +84,10 @@ class EventMail(models.Model):
interval_type = fields.Selection([
# attendee based
('after_sub', 'After each registration'),
- # event based: start date
+ # event based: start date (attendee based if multi-slots: slot start date)
('before_event', 'Before the event starts'),
('after_event_start', 'After the event started'),
- # event based: end date
+ # event based: end date (attendee based if multi-slots: slot end date)
('after_event', 'After the event ended'),
('before_event_end', 'Before the event ends')],
string='Trigger ', default="before_event", required=True)
@@ -98,6 +98,9 @@ class EventMail(models.Model):
mail_registration_ids = fields.One2many(
'event.mail.registration', 'scheduler_id',
help='Communication related to event registrations')
+ mail_slot_ids = fields.One2many(
+ 'event.mail.slot', 'scheduler_id',
+ help='Slot-based communication')
mail_done = fields.Boolean("Sent", copy=False, readonly=True)
mail_state = fields.Selection(
[('running', 'Running'), ('scheduled', 'Scheduled'), ('sent', 'Sent'), ('error', 'Error')],
@@ -147,6 +150,9 @@ def execute(self):
for scheduler in self._filter_template_ref():
if scheduler.interval_type == 'after_sub':
scheduler._execute_attendee_based()
+ elif scheduler.event_id.is_multi_slots:
+ # determine which slots to do
+ scheduler._execute_slot_based()
else:
# before or after event -> one shot communication, once done skip
if scheduler.mail_done:
@@ -157,11 +163,17 @@ def execute(self):
scheduler.error_datetime = False
return True
- def _execute_event_based(self):
+ def _execute_event_based(self, mail_slot=False):
""" Main scheduler method when running in event-based mode aka
'after_event' or 'before_event' (and their negative counterparts).
This is a global communication done once i.e. we do not track each
- registration individually. """
+ registration individually.
+
+ :param mail_slot: optional slot-specific event communication, when
+ event uses slots. In that case, it works like the classic event
+ communication (iterative, ...) but information is specific to each
+ slot (last registration, scheduled datetime, ...)
+ """
auto_commit = not modules.module.current_test
batch_size = int(
self.env['ir.config_parameter'].sudo().get_param('mail.batch_size')
@@ -169,19 +181,22 @@ def _execute_event_based(self):
cron_limit = int(
self.env['ir.config_parameter'].sudo().get_param('mail.render.cron.limit')
) or 1000 # be sure to not have 0, as otherwise we will loop
+ scheduler_record = mail_slot or self
# fetch registrations to contact
registration_domain = [
('event_id', '=', self.event_id.id),
('state', 'not in', ["draft", "cancel"]),
]
- if self.last_registration_id:
+ if mail_slot:
+ registration_domain += [('slot_id', '=', mail_slot.event_slot_id.id)]
+ if scheduler_record.last_registration_id:
registration_domain += [('id', '>', self.last_registration_id.id)]
registrations = self.env["event.registration"].search(registration_domain, limit=(cron_limit + 1), order="id ASC")
# no registrations -> done
if not registrations:
- self.mail_done = True
+ scheduler_record.mail_done = True
return
# there are more than planned for the cron -> reschedule
@@ -191,9 +206,9 @@ def _execute_event_based(self):
for registrations_chunk in tools.split_every(batch_size, registrations.ids, self.env["event.registration"].browse):
self._execute_event_based_for_registrations(registrations_chunk)
- self.last_registration_id = registrations_chunk[-1]
+ scheduler_record.last_registration_id = registrations_chunk[-1]
- self._refresh_mail_count_done()
+ self._refresh_mail_count_done(mail_slot=mail_slot)
if auto_commit:
self.env.cr.commit()
# invalidate cache, no need to keep previous content in memory
@@ -210,6 +225,29 @@ def _execute_event_based_for_registrations(self, registrations):
self._send_mail(registrations)
return True
+ def _execute_slot_based(self):
+ """ Main scheduler method when running in slot-based mode aka
+ 'after_event' or 'before_event' (and their negative counterparts) on
+ events with slots. This is a global communication done once i.e. we do
+ not track each registration individually. """
+ # create slot-specific schedulers if not existing
+ missing_slots = self.event_id.slot_ids - self.mail_slot_ids.event_slot_id
+ if missing_slots:
+ self.write({'mail_slot_ids': [
+ (0, 0, {'event_slot_id': slot.id})
+ for slot in missing_slots
+ ]})
+
+ # filter slots to contact
+ now = fields.Datetime.now()
+ for mail_slot in self.mail_slot_ids:
+ # before or after event -> one shot communication, once done skip
+ if mail_slot.mail_done:
+ continue
+ # do not send emails if the mailing was scheduled before the event but the event is over
+ if mail_slot.scheduled_date <= now and (self.interval_type not in ('before_event', 'after_event_start') or self.event_id.date_end > now):
+ self._execute_event_based(mail_slot=mail_slot)
+
def _execute_attendee_based(self):
""" Main scheduler method when running in attendee-based mode aka
'after_sub'. This relies on a sub model allowing to know which
@@ -297,7 +335,7 @@ def _create_missing_mail_registrations(self, registrations):
} for registration in registrations])
return new
- def _refresh_mail_count_done(self):
+ def _refresh_mail_count_done(self, mail_slot=False):
for scheduler in self:
if scheduler.interval_type == "after_sub":
total_sent = self.env["event.mail.registration"].search_count([
@@ -305,6 +343,17 @@ def _refresh_mail_count_done(self):
("mail_sent", "=", True),
])
self.mail_count_done = total_sent
+ elif mail_slot and mail_slot.last_registration_id:
+ total_sent = self.env["event.registration"].search_count([
+ ("id", "<=", mail_slot.last_registration_id.id),
+ ("event_id", "=", self.event_id.id),
+ ("slot_id", "=", mail_slot.event_slot_id.id),
+ ("state", "not in", ["draft", "cancel"]),
+ ])
+ mail_slot.mail_count_done = total_sent
+ mail_slot.mail_done = total_sent >= mail_slot.event_slot_id.seats_taken
+ self.mail_count_done = sum(self.mail_slot_ids.mapped('mail_count_done'))
+ self.mail_done = total_sent >= self.event_id.seats_taken
elif scheduler.last_registration_id:
total_sent = self.env["event.registration"].search_count([
("id", "<=", self.last_registration_id.id),
diff --git a/addons/event/models/event_mail_slot.py b/addons/event/models/event_mail_slot.py
new file mode 100644
index 0000000000000..78a652a3b3018
--- /dev/null
+++ b/addons/event/models/event_mail_slot.py
@@ -0,0 +1,35 @@
+import logging
+
+from odoo import api, fields, models
+from odoo.addons.event.models.event_mail import _INTERVALS
+
+_logger = logging.getLogger(__name__)
+
+
+class EventMailRegistration(models.Model):
+ _name = 'event.mail.slot'
+ _description = 'Slot Mail Scheduler'
+ _rec_name = 'scheduler_id'
+ _order = 'scheduled_date DESC, id ASC'
+
+ scheduler_id = fields.Many2one('event.mail', 'Mail Scheduler', ondelete='cascade', required=True)
+ event_slot_id = fields.Many2one('event.slot', 'Slot', ondelete='cascade', required=True)
+ scheduled_date = fields.Datetime('Schedule Date', compute='_compute_scheduled_date', store=True)
+ error_datetime = fields.Datetime('Last Error')
+ # contact and status
+ last_registration_id = fields.Many2one('event.registration', 'Last Attendee')
+ mail_done = fields.Boolean("Sent", copy=False, readonly=True)
+ mail_state = fields.Selection(
+ [('running', 'Running'), ('scheduled', 'Scheduled'), ('sent', 'Sent'), ('error', 'Error')],
+ string='Global communication Status', compute='_compute_mail_state')
+ mail_count_done = fields.Integer('# Sent', copy=False, readonly=True)
+
+ @api.depends('event_slot_id.start_datetime', 'event_slot_id.end_datetime', 'scheduler_id.interval_unit', 'scheduler_id.interval_type')
+ def _compute_scheduled_date(self):
+ for mail_slot in self.filtered('event_slot_id'):
+ scheduler = mail_slot.scheduler_id
+ if scheduler.interval_type in ('before_event', 'after_event_start'):
+ date, sign = mail_slot.event_slot_id.start_datetime, (scheduler.interval_type == 'before_event' and -1) or 1
+ else:
+ date, sign = mail_slot.event_slot_id.end_datetime, (scheduler.interval_type == 'after_event' and 1) or -1
+ mail_slot.scheduled_date = date.replace(microsecond=0) + _INTERVALS[scheduler.interval_unit](sign *scheduler.interval_nbr) if date else False
diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py
index fdbfc74ba4ee0..b9cede54e66b0 100644
--- a/addons/event/models/event_registration.py
+++ b/addons/event/models/event_registration.py
@@ -50,6 +50,12 @@ def _get_random_barcode(self):
phone = fields.Char(string='Phone', compute='_compute_phone', readonly=False, store=True, tracking=4)
company_name = fields.Char(
string='Company Name', compute='_compute_company_name', readonly=False, store=True, tracking=5)
+ # slots
+ is_multi_slots = fields.Boolean(string="Is Event Multi Slots", related="event_id.is_multi_slots")
+ slot_id = fields.Many2one(
+ "event.slot", string="Slot", ondelete="cascade",
+ compute="_compute_slot_id", store=True, readonly=False,
+ domain="[('event_id', '=', event_id)]")
# organization
date_closed = fields.Datetime(
string='Attended Date', compute='_compute_date_closed',
@@ -159,6 +165,12 @@ def _compute_date_range(self):
for registration in self:
registration.event_date_range = registration.event_id._get_date_range_str(registration.partner_id.lang)
+ @api.depends('event_id')
+ def _compute_slot_id(self):
+ for registration in self:
+ if registration.event_id != registration.slot_id.event_id:
+ registration.slot_id = False
+
@api.constrains('event_id', 'event_ticket_id')
def _check_event_ticket(self):
if any(registration.event_id != registration.event_ticket_id.event_id for registration in self if registration.event_ticket_id):
diff --git a/addons/event/models/event_slot.py b/addons/event/models/event_slot.py
new file mode 100644
index 0000000000000..d6d762d7224f0
--- /dev/null
+++ b/addons/event/models/event_slot.py
@@ -0,0 +1,160 @@
+import pytz
+from math import modf
+
+from collections import defaultdict
+from datetime import datetime, time
+
+from odoo import _, api, fields, models
+from odoo.exceptions import ValidationError
+from odoo.tools import format_date, format_datetime, format_time
+from odoo.tools.float_utils import float_round
+from odoo.tools.misc import formatLang
+
+
+class EventSlot(models.Model):
+ _name = "event.slot"
+ _description = "Event Slot"
+ _order = "date, start_hour, end_hour, id"
+
+ name = fields.Char("Name", compute="_compute_name", store=True)
+ event_id = fields.Many2one("event.event", "Event", ondelete="cascade")
+ date = fields.Date("Date", required=True)
+ start_hour = fields.Float("Starting Hour", required=True, default=8.0, help="Expressed in the event timezone.")
+ end_hour = fields.Float("Ending Hour", required=True, default=12.0, help="Expressed in the event timezone.")
+ start_datetime = fields.Datetime("Start Datetimes", compute="_compute_datetimes")
+ end_datetime = fields.Datetime("End Datetimes", compute="_compute_datetimes")
+
+ # Registrations
+ registration_ids = fields.One2many("event.registration", "slot_id", string="Attendees")
+ seats_reserved = fields.Integer(
+ string="Number of Registrations",
+ store=False, readonly=True, compute="_compute_seats")
+ seats_available = fields.Integer(
+ string="Available Seats",
+ store=False, readonly=True, compute="_compute_seats")
+ seats_used = fields.Integer(
+ string="Number of Attendees",
+ store=False, readonly=True, compute="_compute_seats")
+ seats_taken = fields.Integer(
+ string="Number of Taken Seats",
+ store=False, readonly=True, compute="_compute_seats")
+ is_sold_out = fields.Boolean(
+ "Sold Out", compute="_compute_is_sold_out", store=True,
+ help="Whether seats are not available for this slot.")
+ # Seats per Ticket
+ ticket_limitation_ids = fields.One2many("event.slot.ticket", "slot_id", string="Tickets Limitations")
+
+ @api.constrains("start_hour", "end_hour")
+ def _check_hours(self):
+ for slot in self:
+ if not (0 <= slot.start_hour <= 23.99 and 0 <= slot.end_hour <= 23.99):
+ raise ValidationError(_("A slot hour must be between 0:00 and 23:59."))
+ elif slot.end_hour <= slot.start_hour:
+ raise ValidationError(_("A slot end hour must be later than its start hour.\n%s", slot.name))
+
+ @api.constrains("date", "start_hour", "end_hour")
+ def _check_time_range(self):
+ for slot in self:
+ event_start = slot.event_id.date_begin
+ event_end = slot.event_id.date_end
+ if not (event_start <= slot.start_datetime <= event_end) or not (event_start <= slot.end_datetime <= event_end):
+ raise ValidationError(_(
+ "A slot cannot be scheduled outside of the event time range.\n\n"
+ "Event (%(tz)s):\t%(event_start)s - %(event_end)s\n"
+ "Slot (%(tz)s):\t\t%(slot_name)s",
+ tz=slot.event_id.date_tz,
+ event_start=format_datetime(self.env, event_start, tz=slot.event_id.date_tz, dt_format='medium'),
+ event_end=format_datetime(self.env, event_end, tz=slot.event_id.date_tz, dt_format='medium'),
+ slot_name=slot.name,
+ ))
+
+ @api.depends("event_id.date_tz", "date", "start_hour", "end_hour")
+ def _compute_datetimes(self):
+ """ Start / End is expressed in 'event timezone'. We have to localize
+ the datetime in that timezone, then express it in the current user
+ timezone as it is how he expects it to be displayed. """
+ for slot in self:
+ event_tz = pytz.timezone(slot.event_id.date_tz)
+ start = datetime.combine(slot.date, EventSlot._float_to_time(slot.start_hour))
+ end = datetime.combine(slot.date, EventSlot._float_to_time(slot.end_hour))
+ slot.start_datetime = event_tz.localize(start).astimezone(pytz.timezone(self.env.user.tz or 'UTC')).replace(tzinfo=None)
+ slot.end_datetime = event_tz.localize(end).astimezone(pytz.timezone(self.env.user.tz or 'UTC')).replace(tzinfo=None)
+
+ @api.depends("event_id", "event_id.seats_limited", "seats_available")
+ def _compute_is_sold_out(self):
+ for slot in self:
+ slot.is_sold_out = slot.event_id.seats_limited and not slot.seats_available
+
+ @api.depends("date", "start_hour", "end_hour", "is_sold_out", "event_id", "event_id.seats_max")
+ def _compute_name(self):
+ for slot in self:
+ weekday = format_date(self.env, slot.date, date_format="EEE")
+ date = format_date(self.env, slot.date, date_format="long")
+ start = format_time(self.env, EventSlot._float_to_time(slot.start_hour), time_format="short")
+ end = format_time(self.env, EventSlot._float_to_time(slot.end_hour), time_format="short")
+ slot.name = f"{weekday}, {date}, {start} - {end}"
+
+ @api.depends("name")
+ @api.depends_context('name_with_seats_availability', 'availability_for_ticket')
+ def _compute_display_name(self):
+ name_with_availability = self.env.context.get('name_with_seats_availability')
+ selected_ticket = self.env.context.get('availability_for_ticket')
+ if selected_ticket:
+ availability_per_slot = defaultdict(int)
+ for slot_ticket in self.ticket_limitation_ids.filtered(lambda slot_ticket: slot_ticket.ticket_id.id == selected_ticket):
+ availability_per_slot[slot_ticket.slot_id] += slot_ticket.seats_available
+
+ for slot in self:
+ if not name_with_availability or not slot.event_id.seats_limited:
+ slot.display_name = slot.name
+ continue
+ seats_available = availability_per_slot.get(slot) if selected_ticket else slot.seats_available
+ if not seats_available:
+ slot.display_name = _('%(slot_name)s (Sold out)', slot_name=slot.name)
+ else:
+ slot.display_name = _(
+ '%(slot_name)s (%(count)s seats remaining)',
+ slot_name=slot.name,
+ count=formatLang(self.env, seats_available, digits=0),
+ )
+
+ @api.depends("event_id", "event_id.seats_max", "registration_ids.state", "registration_ids.active")
+ def _compute_seats(self):
+ # initialize fields to 0
+ for slot in self:
+ slot.seats_reserved = slot.seats_used = slot.seats_available = 0
+ # aggregate registrations by slot and by state
+ state_field = {
+ 'open': 'seats_reserved',
+ 'done': 'seats_used',
+ }
+ base_vals = dict.fromkeys(state_field.values(), 0)
+ results = {slot_id: dict(base_vals) for slot_id in self.ids}
+ if self.ids:
+ query = """ SELECT slot_id, state, count(slot_id)
+ FROM event_registration
+ WHERE slot_id IN %s AND state IN ('open', 'done') AND active = true
+ GROUP BY slot_id, state
+ """
+ self.env['event.registration'].flush_model(['slot_id', 'state', 'active'])
+ self._cr.execute(query, (tuple(self.ids),))
+ res = self._cr.fetchall()
+ for slot_id, state, num in res:
+ results[slot_id][state_field[state]] = num
+
+ # compute seats_available and expected
+ for slot in self:
+ slot.update(results.get(slot._origin.id or slot.id, base_vals))
+ if slot.event_id.seats_max > 0:
+ slot.seats_available = slot.event_id.seats_max - (slot.seats_reserved + slot.seats_used)
+ slot.seats_taken = slot.seats_reserved + slot.seats_used
+
+ # --------------------------------------
+ # Utils
+ # --------------------------------------
+
+ @staticmethod
+ def _float_to_time(float_time):
+ """ Convert the float to an actual datetime time. """
+ fractional, integral = modf(float_time)
+ return time(int(integral), int(float_round(60 * fractional, precision_digits=0)), 0)
diff --git a/addons/event/models/event_slot_ticket.py b/addons/event/models/event_slot_ticket.py
new file mode 100644
index 0000000000000..efe60c40ed797
--- /dev/null
+++ b/addons/event/models/event_slot_ticket.py
@@ -0,0 +1,98 @@
+from odoo import api, fields, models
+from odoo.tools.sql import drop_view_if_exists, SQL
+
+
+class EventSlotTicket(models.Model):
+ """ Used to keep track of the registrations per slot and per ticket for multi-slots events.
+ I.e. if the seats_max for a specific ticket is 5, keeping track of the ticket registrations for each slot
+ to ensure that it doesn't exceed 5. If the number of registrations reaches 5 for a specific slot,
+ the ticket will be considered sold out for that slot.
+ """
+ _name = "event.slot.ticket"
+ _description = "Event Slot Ticket"
+ _auto = False
+
+ event_id = fields.Many2one("event.event", string="Event", related="slot_id.event_id")
+ slot_id = fields.Many2one("event.slot", string="Slot", required=True, ondelete="cascade")
+ ticket_id = fields.Many2one("event.event.ticket", string="Ticket", required=True, ondelete="cascade")
+
+ # Limitations for this slot and this ticket
+ registration_ids = fields.One2many("event.registration", string="Attendees", compute="_compute_registration_ids", search="_search_registration_ids")
+ seats_reserved = fields.Integer(string="Reserved Seats", compute="_compute_seats", store=False)
+ seats_available = fields.Integer(string="Available Seats", compute="_compute_seats", store=False)
+ seats_used = fields.Integer(string="Used Seats", compute="_compute_seats", store=False)
+ seats_taken = fields.Integer(string="Taken Seats", compute="_compute_seats", store=False)
+ is_sold_out = fields.Boolean(
+ "Sold Out", compute="_compute_is_sold_out", help="Whether seats are not available for this slot and ticket.")
+ sale_available = fields.Boolean(
+ string='Is Ticket Available', compute='_compute_sale_available', compute_sudo=True,
+ help='Whether it is possible to sell this ticket for this slot.')
+
+ def init(self):
+ """ Create a line for each event slot and ticket combinations. """
+ query = """
+ SELECT ROW_NUMBER() OVER () AS id, slot.id AS slot_id, ticket.id AS ticket_id
+ FROM event_slot AS slot
+ CROSS JOIN event_event_ticket AS ticket
+ WHERE slot.event_id = ticket.event_id
+ """
+ drop_view_if_exists(self.env.cr, self._table)
+ self.env.cr.execute(SQL("""CREATE or REPLACE VIEW %s as (%s)""", SQL.identifier(self._table), SQL(query)))
+
+ @api.depends("event_id", "event_id.seats_limited", "seats_available")
+ def _compute_is_sold_out(self):
+ for slot_ticket in self:
+ slot_ticket.is_sold_out = slot_ticket.event_id.seats_limited and not slot_ticket.seats_available
+
+ @api.depends("event_id", "event_id.registration_ids", "slot_id", "ticket_id")
+ def _compute_registration_ids(self):
+ for slot_ticket in self:
+ slot_ticket.registration_ids = slot_ticket.event_id.registration_ids.filtered(
+ lambda registration: registration.slot_id == slot_ticket.slot_id and registration.event_ticket_id == slot_ticket.ticket_id)
+
+ @api.model
+ def _search_registration_ids(self, operator, value):
+ return [('event_id.registration_ids', operator, value)]
+
+ @api.depends("is_sold_out", "seats_available", "ticket_id", "event_id.date_tz", "ticket_id.is_expired", "ticket_id.seats_max", "ticket_id.start_sale_datetime")
+ def _compute_sale_available(self):
+ for slot_ticket in self:
+ slot_ticket.sale_available = slot_ticket.ticket_id.is_launched and not slot_ticket.ticket_id.is_expired and not slot_ticket.is_sold_out
+
+ @api.depends("event_id", "event_id.seats_max", "slot_id", "ticket_id", "registration_ids.state", "registration_ids.active")
+ def _compute_seats(self):
+ """ Determine available, reserved, used and taken seats. """
+ # initialize fields to 0
+ for slot_ticket in self:
+ slot_ticket.seats_reserved = slot_ticket.seats_used = slot_ticket.seats_available = 0
+ # aggregate registrations by slot, by ticket and by state
+ results = {}
+ if self.ids:
+ state_field = {
+ 'open': 'seats_reserved',
+ 'done': 'seats_used',
+ }
+ query = """ SELECT slot_id, event_ticket_id, state, count(event_id)
+ FROM event_registration
+ WHERE slot_id IN %s AND event_ticket_id IN %s AND state IN ('open', 'done') AND active = true
+ GROUP BY slot_id, event_ticket_id, state
+ """
+ self.env['event.registration'].flush_model(['event_id', 'slot_id', 'event_ticket_id', 'state', 'active'])
+ self.env.cr.execute(query, (tuple(self.mapped("slot_id").ids), tuple(self.mapped("ticket_id").ids)))
+ for slot_id, event_ticket_id, state, num in self.env.cr.fetchall():
+ results.setdefault(slot_id, {}).setdefault(event_ticket_id, {})[state_field[state]] = num
+
+ # compute seats_available
+ for slot_ticket in self:
+ slot_ticket.update(
+ results.get(slot_ticket.slot_id._origin.id or slot_ticket.slot_id.id, {}).get(slot_ticket.ticket_id._origin.id or slot_ticket.ticket_id.id, {})
+ )
+ event_seats_max = slot_ticket.event_id.seats_max
+ ticket_seats_max = slot_ticket.ticket_id.seats_max
+ if event_seats_max > 0 or ticket_seats_max > 0:
+ seats_max = event_seats_max if ticket_seats_max == 0 else \
+ ticket_seats_max if event_seats_max == 0 else \
+ min(event_seats_max, ticket_seats_max)
+ seats_available = seats_max - (slot_ticket.seats_reserved + slot_ticket.seats_used)
+ slot_ticket.seats_available = min(seats_available, slot_ticket.slot_id.seats_available)
+ slot_ticket.seats_taken = slot_ticket.seats_reserved + slot_ticket.seats_used
diff --git a/addons/event/models/event_ticket.py b/addons/event/models/event_ticket.py
index 91540d450203d..a883feb1a24c5 100644
--- a/addons/event/models/event_ticket.py
+++ b/addons/event/models/event_ticket.py
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
+from collections import defaultdict
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError, UserError
from odoo.tools.misc import formatLang
@@ -80,6 +81,8 @@ def default_get(self, fields):
seats_taken = fields.Integer(string="Taken Seats", compute="_compute_seats", store=False)
is_sold_out = fields.Boolean(
'Sold Out', compute='_compute_is_sold_out', help='Whether seats are not available for this ticket.')
+ # Seats per slot
+ slot_limitation_ids = fields.One2many("event.slot.ticket", "ticket_id", string="Slots Limitations")
# reports
color = fields.Char('Color', default="#875A7B")
@@ -138,7 +141,8 @@ def _compute_seats(self):
for ticket in self:
ticket.update(results.get(ticket._origin.id or ticket.id, {}))
if ticket.seats_max > 0:
- ticket.seats_available = ticket.seats_max - (ticket.seats_reserved + ticket.seats_used)
+ seats_max = ticket.seats_max * len(ticket.event_id.slot_ids) if ticket.event_id.is_multi_slots else ticket.seats_max
+ ticket.seats_available = seats_max - (ticket.seats_reserved + ticket.seats_used)
ticket.seats_taken = ticket.seats_reserved + ticket.seats_used
@api.depends('seats_limited', 'seats_available')
@@ -166,21 +170,28 @@ def _check_seats_availability(self, minimal_availability=0):
+ '\n%s\n' % '\n'.join(sold_out_tickets))
@api.depends('seats_max', 'seats_available')
- @api.depends_context('name_with_seats_availability')
+ @api.depends_context('name_with_seats_availability', 'availability_for_slot')
def _compute_display_name(self):
- """Adds ticket seats availability if requested by context."""
+ """Adds ticket seats availability if requested by context.
+ For a specific slot if slot passed by context. """
if not self.env.context.get('name_with_seats_availability'):
return super()._compute_display_name()
+ selected_slot = self.env.context.get('availability_for_slot')
+ if selected_slot:
+ availability_per_ticket = defaultdict(int)
+ for slot_ticket in self.slot_limitation_ids.filtered(lambda slot_ticket: slot_ticket.slot_id.id == selected_slot):
+ availability_per_ticket[slot_ticket.ticket_id] += slot_ticket.seats_available
for ticket in self:
+ seats_available = availability_per_ticket.get(ticket) if selected_slot else ticket.seats_available
if not ticket.seats_max:
name = ticket.name
- elif not ticket.seats_available:
+ elif not seats_available:
name = _('%(ticket_name)s (Sold out)', ticket_name=ticket.name)
else:
name = _(
'%(ticket_name)s (%(count)s seats remaining)',
ticket_name=ticket.name,
- count=formatLang(self.env, ticket.seats_available, digits=0),
+ count=formatLang(self.env, seats_available, digits=0),
)
ticket.display_name = name
diff --git a/addons/event/report/event_event_templates.xml b/addons/event/report/event_event_templates.xml
index 49681f13186d8..bab17efda5a5f 100644
--- a/addons/event/report/event_event_templates.xml
+++ b/addons/event/report/event_event_templates.xml
@@ -193,7 +193,13 @@