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..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 @@
-
+
+
+ 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..4ef61e6145b58 100644 --- a/addons/event/security/ir.model.access.csv +++ b/addons/event/security/ir.model.access.csv @@ -16,8 +16,14 @@ access_event_mail_registration,event.mail.registration,model_event_mail,event.gr access_event_mail_user,event.mail.user,model_event_mail,event.group_event_user,1,1,1,1 access_event_mail_registration_registration,event.mail.registration.registration,model_event_mail_registration,event.group_event_registration_desk,1,0,0,0 access_event_mail_registration_manager,event.mail.registration.manager,model_event_mail_registration,event.group_event_manager,1,1,1,1 +access_event_mail_slot_registration,event.mail.slot.registration,model_event_mail_slot,event.group_event_registration_desk,1,0,0,0 +access_event_mail_slot_manager,event.mail.slot.manager,model_event_mail_slot,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_slot_ticket_user,event.slot.ticket.user,model_event_slot_ticket,event.group_event_user,1,1,1,1 +access_event_slot_ticket_manager,event.slot.ticket.manager,model_event_slot_ticket,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/tests/test_event_mail_schedule.py b/addons/event/tests/test_event_mail_schedule.py index 06b03cee0e4c9..6e0918ea32ada 100644 --- a/addons/event/tests/test_event_mail_schedule.py +++ b/addons/event/tests/test_event_mail_schedule.py @@ -92,7 +92,7 @@ def execute_event_cron(self, *, freeze_date=None): return capture -@tagged('event_mail', 'post_install', '-at_install') +@tagged('event_mail', 'post_install', '-at_install', 'coincoin') class TestMailSchedule(EventMailCommon): def test_assert_initial_values(self): @@ -101,6 +101,9 @@ def test_assert_initial_values(self): # event data self.assertEqual(test_event.create_date, self.reference_now) + self.assertEqual(test_event.date_begin, self.event_date_begin, 'Expressed in current user TZ') + self.assertEqual(test_event.date_end, self.event_date_end, 'Expressed in current user TZ') + self.assertEqual(test_event.date_tz, 'Europe/Brussels') self.assertEqual(test_event.organizer_id, self.user_eventmanager.company_id.partner_id) self.assertEqual(test_event.user_id, self.user_eventmanager) @@ -596,6 +599,116 @@ def test_event_mail_schedule_fail_registration_template_removed(self): self.template_subscription.sudo().unlink() self.assertFalse(after_sub_scheduler.exists(), "When removing template, scheduler should be removed") + @users('user_eventmanager') + def test_event_mail_schedule_on_slot(self): + """ Test emails sent globally on slots, notaby to test iterative job + + Expected behavior + - event date_begin: 22 08AM + - event date_end: 24 18AM + - schedulers: 1 day before start, immediately after end + - slots begin: 23 08AM and 24 08AM + - Nothing happens before (23 - 1) 08AM, as what matters are the slots, not the event + - Two executions: on 22 08 AM and on 23 08 AM + """ + test_event = self.test_event.with_env(self.env) + + # check iterative work, update params to check call count + batch_size, render_limit = 2, 4 + self.env['ir.config_parameter'].sudo().set_param('mail.batch_size', batch_size) + self.env['ir.config_parameter'].sudo().set_param('mail.render.cron.limit', render_limit) + + # find slot-based schedulers, remove other to avoid noise + event_prev_scheduler = self.env['event.mail'].search([('event_id', '=', test_event.id), ('interval_type', '=', 'before_event')]) + event_after_scheduler = self.env['event.mail'].search([('event_id', '=', test_event.id), ('interval_type', '=', 'after_event')]) + (test_event.event_mail_ids - (event_prev_scheduler + event_after_scheduler)).unlink() + + reference_now = self.reference_now + with self.mock_datetime_and_now(reference_now): + test_event = self.test_event.with_env(self.env) + test_event.write({ + 'is_multi_slots': True, + 'slot_ids': [ + (0, 0, { + 'date': self.event_date_end.date() - relativedelta(days=1), + 'end_hour': 18, + 'start_hour': 8, + }), + (0, 0, { + 'date': self.event_date_end.date(), + 'end_hour': 18, + 'start_hour': 8, + }), + ], + }) + self.assertEqual( + test_event.slot_ids.mapped('start_datetime'), + [datetime(2021, 3, 23, 8, 0, 0), datetime(2021, 3, 24, 8, 0, 0)]) + self.assertEqual( + test_event.slot_ids.mapped('end_datetime'), + [datetime(2021, 3, 23, 18, 0, 0), datetime(2021, 3, 24, 18, 0, 0)]) + + # create some registrations (even wrong registrations without slot) + with self.mock_datetime_and_now(self.reference_now): + registrations = self.env['event.registration'].with_user(self.user_eventuser).create([ + { + 'email': f'reg.{idx}.{slot.id}@test.example.com', + 'event_id': test_event.id, + 'name': f'Reg-{idx} in {slot.id}', + 'slot_id': slot.id, + } + for slot in [test_event.slot_ids[0], test_event.slot_ids[1], self.env['event.slot']] + for idx in range(5) + ]) + self.assertEqual(len(registrations), 15) + registrations_slot_1 = registrations.filtered(lambda r: r.slot_id == test_event.slot_ids[0]) + _registrations_slot_2 = registrations.filtered(lambda r: r.slot_id == test_event.slot_ids[1]) + _registrations_noslot = registrations.filtered(lambda r: not r.slot_id) + + # simulate cron: ok for event-begin, but not for slots -> should not send communication + current = self.event_date_begin - relativedelta(hours=2) + self.execute_event_cron(freeze_date=current) + self.assertFalse(event_prev_scheduler.mail_done) + self.assertEqual(event_prev_scheduler.mail_state, 'scheduled') + self.assertEqual(event_prev_scheduler.mail_count_done, 0) + self.assertEqual(len(self._new_mails), 0) + + # created missing mail.slot + self.assertEqual(len(event_prev_scheduler.mail_slot_ids), 2) + for mail_slot in event_prev_scheduler.mail_slot_ids: + self.assertFalse(mail_slot.mail_count_done) + self.assertFalse(mail_slot.mail_done) + mail_slot_1 = event_prev_scheduler.mail_slot_ids.filtered(lambda s: s.event_slot_id.date == self.event_date_end.date() - relativedelta(days=1)) + self.assertEqual(mail_slot_1.scheduled_date, datetime(2021, 3, 22, 8, 0, 0)) + mail_slot_2 = event_prev_scheduler.mail_slot_ids.filtered(lambda s: s.event_slot_id.date == self.event_date_end.date()) + self.assertEqual(mail_slot_2.scheduled_date, datetime(2021, 3, 23, 8, 0, 0)) + + # execute cron to run scheduler on first slot + slot1_before_oneday = datetime(2021, 3, 23, 8, 0, 0) - relativedelta(days=1) + EventMail = type(self.env['event.mail']) + exec_origin = EventMail._execute_event_based_for_registrations + with patch.object( + EventMail, '_execute_event_based_for_registrations', autospec=True, wraps=EventMail, side_effect=exec_origin, + ) as mock_exec: + capture = self.execute_event_cron(freeze_date=slot1_before_oneday) + # produced content + self.assertEqual(len(self._new_mails), 4, 'Cron limited to size of 2x2') + self.assertEqual(mock_exec.call_count, 2, '2 calls: 2x2registrations, limit of 4') + self.assertMailMailWEmails( + [formataddr((reg.name, reg.email)) for reg in registrations_slot_1[:4]], + 'outgoing', + content=None, + fields_values={ + 'email_from': self.user_eventmanager.company_id.email_formatted, + 'subject': f'Reminder for {test_event.name}: today', + }) + # updated info + self.assertEqual(mail_slot_1.mail_count_done, 4) + self.assertFalse(mail_slot_1.mail_done) + self.assertEqual(event_prev_scheduler.mail_count_done, 4) + self.assertFalse(event_prev_scheduler.mail_done) + self.assertSchedulerCronTriggers(capture, [slot1_before_oneday]) + @mute_logger('odoo.addons.base.models.ir_model', 'odoo.models') @users('user_eventmanager') @warmup diff --git a/addons/event/views/event_event_views.xml b/addons/event/views/event_event_views.xml index 1cc0dcedabd62..d5d527c264565 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)