With stored computed seat attributes, the database can be flooded with update
queries for the stored values for the event (ticket) seats computations (such
as reserved, expected, and available seats).
This can especially occur when a communication is sent to many people about
an event with a registration link, many users may want to register at the same
time, possibly resulting in concurrent_update errors.
In this commit, we remove the `store=True` attribute of those fields, and
therefore remove the Reporting/Event feature depending on them and rewrite some
domain searches and _compute fields in the event and event_sale modules.
This also impacts the way constraints are enforced on the number of
registrations vs defined maximum as no stored value is directly available.
For performance reasons, all events and tickets are now shown on backend form
views, with seat availability added in their displayed name.
Misc
To avoid delaying the inevitable, the Event configurator modal/wizard now
validates event/ticket consistency at closing.
The UI of the RegistrationEditor wizard is also improved:
* A warning alert will tell users that free registrations were not confirmed
because of insufficient seat availability.
* A first step to better explain the consequences of the actions taken on the
modal was to be taken, here via the description and buttons wording.
Tests
Query counts are (indeed reduced) and updated. However, as local testing with
`test-tags=/test_event_full` ("tef_only") is currently not reliable, these
values were updated by applying the same change from the commit as the one seen
for the runbots, while a "?" is appended to show this uncertainty.
Task-2654816
See odoo/upgrade#3118
Part-of: odoo/odoo#81583
198 lines
9.6 KiB
Python
198 lines
9.6 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
from odoo import api, fields, models, _
|
|
from odoo.exceptions import ValidationError, UserError
|
|
from odoo.tools.misc import formatLang
|
|
|
|
|
|
class EventTemplateTicket(models.Model):
|
|
_name = 'event.type.ticket'
|
|
_description = 'Event Template Ticket'
|
|
|
|
# description
|
|
name = fields.Char(
|
|
string='Name', default=lambda self: _('Registration'),
|
|
required=True, translate=True)
|
|
description = fields.Text(
|
|
'Description', translate=True,
|
|
help="A description of the ticket that you want to communicate to your customers.")
|
|
event_type_id = fields.Many2one(
|
|
'event.type', string='Event Category', ondelete='cascade', required=True)
|
|
# seats
|
|
seats_limited = fields.Boolean(string='Limit Attendees', readonly=True, store=True,
|
|
compute='_compute_seats_limited')
|
|
seats_max = fields.Integer(
|
|
string='Maximum Attendees',
|
|
help="Define the number of available tickets. If you have too many registrations you will "
|
|
"not be able to sell tickets anymore. Set 0 to ignore this rule set as unlimited.")
|
|
|
|
@api.depends('seats_max')
|
|
def _compute_seats_limited(self):
|
|
for ticket in self:
|
|
ticket.seats_limited = ticket.seats_max
|
|
|
|
@api.model
|
|
def _get_event_ticket_fields_whitelist(self):
|
|
""" Whitelist of fields that are copied from event_type_ticket_ids to event_ticket_ids when
|
|
changing the event_type_id field of event.event """
|
|
return ['name', 'description', 'seats_max']
|
|
|
|
|
|
class EventTicket(models.Model):
|
|
""" Ticket model allowing to have differnt kind of registrations for a given
|
|
event. Ticket are based on ticket type as they share some common fields
|
|
and behavior. Those models come from <= v13 Odoo event.event.ticket that
|
|
modeled both concept: tickets for event templates, and tickets for events. """
|
|
_name = 'event.event.ticket'
|
|
_inherit = 'event.type.ticket'
|
|
_description = 'Event Ticket'
|
|
|
|
@api.model
|
|
def default_get(self, fields):
|
|
res = super(EventTicket, self).default_get(fields)
|
|
if 'name' in fields and (not res.get('name') or res['name'] == _('Registration')) and self.env.context.get('default_event_name'):
|
|
res['name'] = _('Registration for %s', self.env.context['default_event_name'])
|
|
return res
|
|
|
|
# description
|
|
event_type_id = fields.Many2one(ondelete='set null', required=False)
|
|
event_id = fields.Many2one(
|
|
'event.event', string="Event",
|
|
ondelete='cascade', required=True)
|
|
company_id = fields.Many2one('res.company', related='event_id.company_id')
|
|
# sale
|
|
start_sale_datetime = fields.Datetime(string="Registration Start")
|
|
end_sale_datetime = fields.Datetime(string="Registration End")
|
|
is_launched = fields.Boolean(string='Are sales launched', compute='_compute_is_launched')
|
|
is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired')
|
|
sale_available = fields.Boolean(
|
|
string='Is Available', compute='_compute_sale_available', compute_sudo=True,
|
|
help='Whether it is possible to sell these tickets')
|
|
registration_ids = fields.One2many('event.registration', 'event_ticket_id', string='Registrations')
|
|
# seats
|
|
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_unconfirmed = fields.Integer(string='Unconfirmed Seats', compute='_compute_seats', store=False)
|
|
seats_used = fields.Integer(string='Used 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.')
|
|
|
|
@api.depends('end_sale_datetime', 'event_id.date_tz')
|
|
def _compute_is_expired(self):
|
|
for ticket in self:
|
|
ticket = ticket._set_tz_context()
|
|
current_datetime = fields.Datetime.context_timestamp(ticket, fields.Datetime.now())
|
|
if ticket.end_sale_datetime:
|
|
end_sale_datetime = fields.Datetime.context_timestamp(ticket, ticket.end_sale_datetime)
|
|
ticket.is_expired = end_sale_datetime < current_datetime
|
|
else:
|
|
ticket.is_expired = False
|
|
|
|
@api.depends('start_sale_datetime', 'event_id.date_tz')
|
|
def _compute_is_launched(self):
|
|
now = fields.Datetime.now()
|
|
for ticket in self:
|
|
if not ticket.start_sale_datetime:
|
|
ticket.is_launched = True
|
|
else:
|
|
ticket = ticket._set_tz_context()
|
|
current_datetime = fields.Datetime.context_timestamp(ticket, now)
|
|
start_sale_datetime = fields.Datetime.context_timestamp(ticket, ticket.start_sale_datetime)
|
|
ticket.is_launched = start_sale_datetime <= current_datetime
|
|
|
|
@api.depends('is_expired', 'start_sale_datetime', 'event_id.date_tz', 'seats_available', 'seats_max')
|
|
def _compute_sale_available(self):
|
|
for ticket in self:
|
|
ticket.sale_available = ticket.is_launched and not ticket.is_expired and not ticket.is_sold_out
|
|
|
|
@api.depends('seats_max', 'registration_ids.state', 'registration_ids.active')
|
|
def _compute_seats(self):
|
|
""" Determine reserved, available, reserved but unconfirmed and used seats. """
|
|
# initialize fields to 0 + compute seats availability
|
|
for ticket in self:
|
|
ticket.seats_unconfirmed = ticket.seats_reserved = ticket.seats_used = ticket.seats_available = 0
|
|
# aggregate registrations by ticket and by state
|
|
results = {}
|
|
if self.ids:
|
|
state_field = {
|
|
'draft': 'seats_unconfirmed',
|
|
'open': 'seats_reserved',
|
|
'done': 'seats_used',
|
|
}
|
|
query = """ SELECT event_ticket_id, state, count(event_id)
|
|
FROM event_registration
|
|
WHERE event_ticket_id IN %s AND state IN ('draft', 'open', 'done') AND active = true
|
|
GROUP BY event_ticket_id, state
|
|
"""
|
|
self.env['event.registration'].flush_model(['event_id', 'event_ticket_id', 'state', 'active'])
|
|
self.env.cr.execute(query, (tuple(self.ids),))
|
|
for event_ticket_id, state, num in self.env.cr.fetchall():
|
|
results.setdefault(event_ticket_id, {})[state_field[state]] = num
|
|
|
|
# compute seats_available
|
|
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)
|
|
|
|
@api.depends('seats_limited', 'seats_available')
|
|
def _compute_is_sold_out(self):
|
|
for ticket in self:
|
|
ticket.is_sold_out = ticket.seats_limited and not ticket.seats_available
|
|
|
|
@api.constrains('start_sale_datetime', 'end_sale_datetime')
|
|
def _constrains_dates_coherency(self):
|
|
for ticket in self:
|
|
if ticket.start_sale_datetime and ticket.end_sale_datetime and ticket.start_sale_datetime > ticket.end_sale_datetime:
|
|
raise UserError(_('The stop date cannot be earlier than the start date. '
|
|
'Please check ticket %(ticket_name)s', ticket_name=ticket.name))
|
|
|
|
@api.constrains('registration_ids', 'seats_max')
|
|
def _check_seats_availability(self, minimal_availability=0):
|
|
sold_out_tickets = []
|
|
for ticket in self:
|
|
if ticket.seats_max and ticket.seats_available < minimal_availability:
|
|
sold_out_tickets.append((_(
|
|
'- the ticket "%(ticket_name)s" (%(event_name)s): Missing %(nb_too_many)i seats.',
|
|
ticket_name=ticket.name, event_name=ticket.event_id.name, nb_too_many=-ticket.seats_available)))
|
|
if sold_out_tickets:
|
|
raise ValidationError(_('There are not enough seats available for:')
|
|
+ '\n%s\n' % '\n'.join(sold_out_tickets))
|
|
|
|
def name_get(self):
|
|
"""Adds ticket seats availability if requested by context."""
|
|
if not self.env.context.get('name_with_seats_availability'):
|
|
return super().name_get()
|
|
res = []
|
|
for ticket in self:
|
|
if not ticket.seats_max:
|
|
name = ticket.name
|
|
elif not ticket.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),
|
|
)
|
|
res.append((ticket.id, name))
|
|
return res
|
|
|
|
def _get_ticket_multiline_description(self):
|
|
""" Compute a multiline description of this ticket. It is used when ticket
|
|
description are necessary without having to encode it manually, like sales
|
|
information. """
|
|
return '%s\n%s' % (self.display_name, self.event_id.display_name)
|
|
|
|
def _set_tz_context(self):
|
|
self.ensure_one()
|
|
return self.with_context(tz=self.event_id.date_tz or 'UTC')
|
|
|
|
@api.ondelete(at_uninstall=False)
|
|
def _unlink_except_if_registrations(self):
|
|
if self.registration_ids:
|
|
raise UserError(_(
|
|
"The following tickets cannot be deleted while they have one or more registrations linked to them:\n- %s",
|
|
'\n- '.join(self.mapped('name'))))
|