Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[REF] event: allow slot-based mail scheduler #4185

Open
wants to merge 6 commits into
base: master-event-multi-slot-events-amdi
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 8 additions & 2 deletions addons/event/controllers/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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}"
Expand Down
60 changes: 33 additions & 27 deletions addons/event/data/mail_template_data.xml

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions addons/event/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
89 changes: 75 additions & 14 deletions addons/event/models/event_event.py
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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,
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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):
Expand All @@ -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')
Expand Down Expand Up @@ -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 = []
Expand Down Expand Up @@ -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)
Expand Down Expand Up @@ -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 = {}
Expand All @@ -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:
Expand Down
69 changes: 59 additions & 10 deletions addons/event/models/event_mail.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 _
Expand Down Expand Up @@ -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)
Expand All @@ -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')],
Expand Down Expand Up @@ -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:
Expand All @@ -157,31 +163,40 @@ 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')
) or 50 # be sure to not have 0, as otherwise no iteration is done
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
Expand All @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -297,14 +335,25 @@ 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([
("scheduler_id", "=", self.id),
("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),
Expand Down
Loading