From d6d7f0096040053538619bd11ddb45e4ebab4241 Mon Sep 17 00:00:00 2001 From: amdi-odoo Date: Fri, 28 Feb 2025 13:29:11 +0100 Subject: [PATCH 1/6] [IMP] event: Add multi slots & seats management per slot --- addons/event/controllers/main.py | 10 +- addons/event/data/mail_template_data.xml | 60 ++++---- addons/event/models/__init__.py | 1 + addons/event/models/event_event.py | 76 ++++++++-- addons/event/models/event_registration.py | 12 ++ addons/event/models/event_slot.py | 141 ++++++++++++++++++ addons/event/report/event_event_templates.xml | 56 +++++-- addons/event/security/ir.model.access.csv | 2 + addons/event/views/event_event_views.xml | 15 +- .../event/views/event_registration_views.xml | 3 + 10 files changed, 319 insertions(+), 57 deletions(-) create mode 100644 addons/event/models/event_slot.py diff --git a/addons/event/controllers/main.py b/addons/event/controllers/main.py index 5827d9f48184e..1d07569cffdb1 100644 --- a/addons/event/controllers/main.py +++ b/addons/event/controllers/main.py @@ -17,7 +17,8 @@ def event_ics_file(self, event, **kwargs): if request.env.user._is_public(): lang = request.cookies.get('frontend_lang') event = event.with_context(lang=lang) - files = event._get_ics_file() + slot_id = int(kwargs['slot_id']) if kwargs.get('slot_id') else False + files = event._get_ics_file(slot=request.env['event.slot'].browse(slot_id)) if not event.id in files: return NotFound() content = files[event.id] @@ -52,7 +53,12 @@ def event_my_tickets(self, event_id, registration_ids, tickets_hash, badge_mode= event_registrations_sudo = event_sudo.registration_ids.filtered(lambda reg: reg.id in registration_ids) report_name_prefix = _("Ticket") if responsive_html else _("Badges") if badge_mode else _("Tickets") - report_date = format_datetime(request.env, event_sudo.date_begin, tz=event_sudo.date_tz, dt_format='medium') + report_date = format_datetime( + request.env, + event_registrations_sudo.slot_id.start_datetime if event_registrations_sudo.slot_id else event_sudo.date_begin, + tz=event_sudo.date_tz, + dt_format='medium' + ) report_name = f"{report_name_prefix} - {event_sudo.name} ({report_date})" if len(event_registrations_sudo) == 1: report_name += f" - {event_registrations_sudo[0].name}" diff --git a/addons/event/data/mail_template_data.xml b/addons/event/data/mail_template_data.xml index 56b853be54df9..81b3b5e42072a 100644 --- a/addons/event/data/mail_template_data.xml +++ b/addons/event/data/mail_template_data.xml @@ -12,8 +12,10 @@ Sent automatically to someone after they registered to an event @@ -259,8 +261,10 @@ Sent to attendees after registering to an event
- - + + + + @@ -67,8 +69,8 @@
Add this event to your calendar Google - iCal/Outlook - + iCal/Outlook + Yahoo

@@ -104,16 +106,16 @@
- May 4, 2021 - 7:00 AM - - 5:00 PM + May 4, 2021 + 7:00 AM + - 5:00 PM
From - May 4, 2021 - 7:00 AM + May 4, 2021 - 7:00 AM
To - May 6, 2021 - 5:00 PM + May 6, 2021 - 5:00 PM
(Europe/Brussels)
@@ -528,8 +532,10 @@ Sent automatically to attendees if there is a reminder defined on the event
- - + + + + @@ -337,8 +341,8 @@
Add this event to your calendar Google - iCal/Outlook - + iCal/Outlook + Yahoo

@@ -374,16 +378,16 @@
- May 4, 2021 - 7:00 AM - - 5:00 PM + May 4, 2021 + 7:00 AM + - 5:00 PM
From - May 4, 2021 - 7:00 AM + May 4, 2021 - 7:00 AM
To - May 6, 2021 - 5:00 PM + May 6, 2021 - 5:00 PM
(Europe/Brussels)
diff --git a/addons/event/models/__init__.py b/addons/event/models/__init__.py index a23b919654a98..6b7eb03a2a32f 100644 --- a/addons/event/models/__init__.py +++ b/addons/event/models/__init__.py @@ -5,6 +5,7 @@ from . import event_mail from . import event_mail_registration from . import event_registration +from . import event_slot from . import event_stage from . import event_tag from . import event_ticket diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py index ca2e601189f88..1bc655c89f74e 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( @@ -221,6 +222,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 +324,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) @@ -615,6 +632,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 +671,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 +798,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 +810,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_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..f961670f95932 --- /dev/null +++ b/addons/event/models/event_slot.py @@ -0,0 +1,141 @@ +import pytz +from math import modf + +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.") + + @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): + 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.UTC).replace(tzinfo=None) + slot.end_datetime = event_tz.localize(end).astimezone(pytz.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") + name = f"{weekday}, {date}, {start} - {end}" + + if slot.is_sold_out: + slot.name = _('%(slot_name)s (Sold out)', slot_name=name) + elif slot.event_id.seats_limited and slot.event_id.seats_max: + slot.name = _( + '%(slot_name)s (%(count)s seats remaining)', + slot_name=name, + count=formatLang(self.env, slot.seats_available, digits=0), + ) + else: + slot.name = name + + @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/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 @@
-
+
+
+ to +
+

from + @@ -297,6 +304,7 @@ + @@ -312,20 +320,38 @@

- - - - - + + + + + + + + + + + + + +
diff --git a/addons/event/security/ir.model.access.csv b/addons/event/security/ir.model.access.csv index 98cb174e2d0f5..04e2db7ba0b30 100644 --- a/addons/event/security/ir.model.access.csv +++ b/addons/event/security/ir.model.access.csv @@ -18,6 +18,8 @@ access_event_mail_registration_registration,event.mail.registration.registration access_event_mail_registration_manager,event.mail.registration.manager,model_event_mail_registration,event.group_event_manager,1,1,1,1 access_event_type_mail_registration,event.type.mail.registration,model_event_type_mail,event.group_event_registration_desk,1,0,0,0 access_event_type_mail_manager,event.type.mail.manager,model_event_type_mail,event.group_event_manager,1,1,1,1 +access_event_slot_user,event.slot.user,model_event_slot,event.group_event_user,1,1,1,1 +access_event_slot_manager,event.slot.manager,model_event_slot,event.group_event_manager,1,1,1,1 access_event_stage_registration,event.stage.registration,model_event_stage,event.group_event_registration_desk,1,0,0,0 access_event_stage_manager,event.stage.manager,model_event_stage,event.group_event_manager,1,1,1,1 access_event_tag_category_registration,event.tag.category.registration,model_event_tag_category,event.group_event_registration_desk,1,0,0,0 diff --git a/addons/event/views/event_event_views.xml b/addons/event/views/event_event_views.xml index 1cc0dcedabd62..38c8c1e6ee82a 100644 --- a/addons/event/views/event_event_views.xml +++ b/addons/event/views/event_event_views.xml @@ -58,6 +58,7 @@ + @@ -71,7 +72,9 @@ + + + + + + +

- - + + + + @@ -594,8 +600,8 @@
Add this event to your calendar Google - iCal/Outlook - + iCal/Outlook + Yahoo

@@ -631,16 +637,16 @@
- May 4, 2021 - 7:00 AM - - 5:00 PM + May 4, 2021 + 7:00 AM + - 5:00 PM
From - May 4, 2021 - 7:00 AM + May 4, 2021 - 7:00 AM
To - May 6, 2021 - 5:00 PM + May 6, 2021 - 5:00 PM
(Europe/Brussels)