Skip to content

Commit c1a4eb3

Browse files
[IMP] slot: tests, availability check
1 parent 543a43c commit c1a4eb3

File tree

6 files changed

+358
-130
lines changed

6 files changed

+358
-130
lines changed

addons/event/models/event_event.py

+78-81
Original file line numberDiff line numberDiff line change
@@ -572,87 +572,6 @@ def _check_slots_dates(self):
572572
slots="\n".join(f"- {slot.name}" for slot in slots_outside_event_bounds)
573573
))
574574

575-
# @api.constrains('seats_max', 'seats_limited', 'registration_ids')
576-
# def _check_seats_availability(self, minimal_availability=0):
577-
# sold_out_events = []
578-
# for event in self:
579-
# if event.seats_limited and event.seats_max and event.seats_available < minimal_availability:
580-
# sold_out_events.append(_(
581-
# '- "%(event_name)s": Missing %(nb_too_many)i seats.',
582-
# event_name=event.name,
583-
# nb_too_many=minimal_availability - event.seats_available,
584-
# ))
585-
# if sold_out_events:
586-
# raise ValidationError(_('There are not enough seats available for:')
587-
# + '\n%s\n' % '\n'.join(sold_out_events))
588-
589-
def _verify_seats_availability(self, slot_id=False, ticket_id=False, minimal_availability=0):
590-
""" Check the number of seats available for the event.
591-
:slot_id: for the specified slot if specified.
592-
:ticket_id: for the specified ticket if specified.
593-
:minimal_availability: A minimal availability can be specified to ensure there is
594-
at least this number of seats left. Useful in the sale flow.
595-
Raises:
596-
ValidationError: If the event / slot / ticket / slot ticket do not have enough seats available.
597-
"""
598-
sold_out_records = []
599-
slot_tickets_nb_registrations = {
600-
(slot.id, ticket.id): count
601-
for (slot, ticket, count) in self.env['event.registration']._read_group(
602-
domain=[('event_id', 'in', self.ids), ('state', 'in', ['open', 'done']), ('active', '=', True)],
603-
groupby=['event_slot_id', 'event_ticket_id'],
604-
aggregates=['__count']
605-
)
606-
}
607-
for event in self:
608-
slot = False
609-
ticket = False
610-
if slot_id:
611-
slot = self.env['event.slot'].browse(slot_id)
612-
if ticket_id:
613-
ticket = self.env['event.event.ticket'].browse(ticket_id)
614-
# Event / slot seats_max
615-
if event.seats_limited and event.seats_max:
616-
# TODO: ne fonctionne pas parce que ça verifie les valeurs avant le write et on ne sait pas
617-
# cb de registrations sont impactée, on pourrait vouloir write sur 6 registrations
618-
if slot and slot.seats_available < minimal_availability:
619-
sold_out_records.append(_(
620-
'- the slot "%(slot_name)s" (%(event_name)s): Missing %(nb_too_many)i seats.',
621-
slot_name=slot.name,
622-
event_name=event.name,
623-
nb_too_many=minimal_availability - slot.seats_available,
624-
))
625-
# TODO: same
626-
elif event.seats_available < minimal_availability:
627-
sold_out_records.append(_(
628-
'- the event "%(event_name)s": Missing %(nb_too_many)i seats.',
629-
event_name=event.name,
630-
nb_too_many=minimal_availability - event.seats_available,
631-
))
632-
# Ticket seats_max
633-
if ticket and ticket.seats_max:
634-
if slot:
635-
# TODO: same
636-
slot_ticket_seats_available = ticket.seats_max - slot_tickets_nb_registrations.get((slot.id, ticket.id), 0)
637-
if slot_ticket_seats_available <= minimal_availability:
638-
sold_out_records.append(_(
639-
'- the slot ticket "%(slot_ticket_name)s" (%(event_name)s): Missing %(nb_too_many)i seats.',
640-
slot_ticket_name=f'{ticket.name} - {slot.name}',
641-
event_name=event.name,
642-
nb_too_many=minimal_availability - slot_ticket_seats_available,
643-
))
644-
# TODO: same
645-
elif ticket.seats_available <= minimal_availability:
646-
sold_out_records.append(_(
647-
'- the ticket "%(ticket_name)s" (%(event_name)s): Missing %(nb_too_many)i seats.',
648-
ticket_name=ticket.name,
649-
event_name=event.name,
650-
nb_too_many=minimal_availability - ticket.seats_available,
651-
))
652-
if sold_out_records:
653-
raise ValidationError(_('There are not enough seats available for:\n%(sold_out_records)s\n',
654-
sold_out_records='\n'.join(sold_out_records)))
655-
656575
@api.constrains('date_begin', 'date_end')
657576
def _check_closing_date(self):
658577
for event in self:
@@ -721,6 +640,84 @@ def _set_tz_context(self):
721640
self.ensure_one()
722641
return self.with_context(tz=self.date_tz or 'UTC')
723642

643+
def _get_seats_availability(self, slot_tickets):
644+
""" Get availabilities for given combinations of slot / ticket. Returns
645+
a list following input order. None denotes no limit. """
646+
self.ensure_one()
647+
if not (all(len(item) == 2 for item in slot_tickets)):
648+
raise ValueError('Input should be a list of tuples containing slot, ticket')
649+
650+
slot_tickets_nb_registrations = {
651+
(slot.id, ticket.id): count
652+
for (slot, ticket, count) in self.env['event.registration']._read_group(
653+
domain=[('event_id', 'in', self.ids), ('state', 'in', ['open', 'done']), ('active', '=', True)],
654+
groupby=['event_slot_id', 'event_ticket_id'],
655+
aggregates=['__count']
656+
)
657+
}
658+
659+
availabilities = []
660+
for slot, ticket in slot_tickets:
661+
available = None
662+
# event is constrained: max stands for either each slot, either global (no slots)
663+
if self.seats_max:
664+
if slot:
665+
available = slot.seats_available
666+
else:
667+
available = self.seats_available
668+
# ticket is constrained: max standard for either each slot / ticket, either global (no slots)
669+
if available != 0 and ticket and ticket.seats_max:
670+
if slot:
671+
ticket_available = ticket.seats_max - slot_tickets_nb_registrations.get((slot.id, ticket.id), 0)
672+
else:
673+
ticket_available = ticket.seats_available
674+
available = ticket_available if available == None else min(available, ticket_available)
675+
availabilities.append(available)
676+
return availabilities
677+
678+
def _verify_seats_availability(self, slot_tickets):
679+
""" Check event seats availability, for combinations of slot / ticket.
680+
681+
:slot_tickets: a list of tuples(slot, ticket, count). SLot and ticket
682+
are optional, depending on event configuration. If count is 0
683+
it is a simple check current values do not overflow limit. If count
684+
is given, it serves as a check there are enough remaining seats.
685+
686+
Raises:
687+
ValidationError: if the event / slot / ticket do not have enough
688+
available seats
689+
"""
690+
self.ensure_one()
691+
if not (all(len(item) == 3 for item in slot_tickets)):
692+
raise ValueError('Input should be a list of tuples containing slot, ticket, count')
693+
694+
sold_out = []
695+
availabilities = self._get_seats_availability([(item[0], item[1]) for item in slot_tickets])
696+
for (slot, ticket, count), available in zip(slot_tickets, availabilities, strict=True):
697+
if available is None: # unconstrained
698+
continue
699+
if available < count:
700+
if slot and ticket:
701+
name = f'{ticket.name} - {slot.name}'
702+
elif slot:
703+
name = slot.name
704+
elif ticket:
705+
name = ticket.name
706+
else:
707+
name = self.name
708+
sold_out.append((name, count - available))
709+
710+
if sold_out:
711+
info = [] # note: somehow using list comprehension make translate.py crash in default lang
712+
for item in sold_out:
713+
info.append(_('%(slot_name)s: missing %(count)s seats', slot_name=item[0], count=item[1]))
714+
raise ValidationError(
715+
_('There are not enough seats available for %(event_name)s:\n%(sold_out_info)s',
716+
event_name=self.name,
717+
sold_out_info='\n'.join(info),
718+
)
719+
)
720+
724721
# ------------------------------------------------------------
725722
# ACTIONS
726723
# ------------------------------------------------------------

addons/event/models/event_registration.py

+14-32
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,8 @@
33
import logging
44
import os
55

6+
from collections import defaultdict
7+
68
from odoo import _, api, fields, models, SUPERUSER_ID
79
from odoo.addons.event.tools.esc_label_tools import print_event_attendees, setup_printer, layout_96x82
810
from odoo.tools import email_normalize, email_normalize_all, formataddr
@@ -95,11 +97,16 @@ def _get_random_barcode(self):
9597
'Barcode should be unique',
9698
)
9799

98-
# @api.constrains('state', 'event_id', 'event_ticket_id')
99-
# def _check_seats_availability(self):
100-
# registrations_confirmed = self.filtered(lambda registration: registration.state in ('open', 'done'))
101-
# registrations_confirmed.event_id._check_seats_availability()
102-
# registrations_confirmed.event_ticket_id._check_seats_availability()
100+
@api.constrains('active', 'state', 'event_id', 'event_slot_id', 'event_ticket_id')
101+
def _check_seats_availability(self):
102+
tocheck = self.filtered(lambda registration: registration.state in ('open', 'done') and registration.active)
103+
for event, registrations in tocheck.grouped('event_id').items():
104+
classified = defaultdict(lambda: self.env['event.registration'])
105+
for reg in registrations:
106+
classified[(reg.event_slot_id, reg.event_ticket_id)] += reg
107+
event._verify_seats_availability(
108+
[(key[0], key[1], 0) for key, regs in classified.items()],
109+
)
103110

104111
def default_get(self, fields):
105112
ret_vals = super().default_get(fields)
@@ -243,8 +250,6 @@ def create(self, vals_list):
243250
all_partner_ids = set(values['partner_id'] for values in vals_list if values.get('partner_id'))
244251
all_event_ids = set(values['event_id'] for values in vals_list if values.get('event_id'))
245252
for values in vals_list:
246-
event = self.env['event.event'].browse(values['event_id'])
247-
event._verify_seats_availability(slot_id=values.get('event_slot_id'), ticket_id=values.get('event_ticket_id'))
248253
if not values.get('phone'):
249254
continue
250255

@@ -257,49 +262,26 @@ def create(self, vals_list):
257262
related_country = self.env.company.country_id
258263
values['phone'] = self._phone_format(number=values['phone'], country=related_country) or values['phone']
259264

260-
registrations = super(EventRegistration, self).create(vals_list)
265+
registrations = super().create(vals_list)
261266
registrations._update_mail_schedulers()
262267
return registrations
263268

264269
def write(self, vals):
265-
if any(field in ['active', 'state', 'event_id', 'event_slot_id', 'event_ticket_id'] for field in vals):
266-
self._verify_seats_availability()
267270
confirming = vals.get('state') in {'open', 'done'}
268271
to_confirm = (self.filtered(lambda registration: registration.state in {'draft', 'cancel'})
269272
if confirming else None)
270-
ret = super(EventRegistration, self).write(vals)
273+
ret = super().write(vals)
271274
if confirming:
272275
to_confirm._update_mail_schedulers()
273276

274277
return ret
275278

276-
def _verify_seats_availability(self):
277-
""" Check event, slot and ticket seats availability for the registrations.
278-
Raise Validation Error if not enough seats available.
279-
"""
280-
regs_by_event = self._read_group([], groupby=['event_id'], aggregates=["id:recordset"])
281-
for (event, regs) in regs_by_event:
282-
grouped_regs = regs._read_group(
283-
domain=[],
284-
groupby=['event_slot_id', 'event_ticket_id']
285-
)
286-
for (slot, ticket) in grouped_regs:
287-
event._verify_seats_availability(slot_id=slot.id, ticket_id=ticket.id)
288-
289279
def _compute_display_name(self):
290280
""" Custom display_name in case a registration is nott linked to an attendee
291281
"""
292282
for registration in self:
293283
registration.display_name = registration.name or f"#{registration.id}"
294284

295-
def action_unarchive(self):
296-
res = super().action_unarchive()
297-
# Necessary triggers as changing registration states cannot be used as triggers for the
298-
# Event(Ticket) models constraints.
299-
if unarchived := self.filtered(self._active_name):
300-
unarchived._verify_seats_availability()
301-
return res
302-
303285
# ------------------------------------------------------------
304286
# ACTIONS / BUSINESS
305287
# ------------------------------------------------------------

addons/event/models/event_ticket.py

-15
Original file line numberDiff line numberDiff line change
@@ -117,21 +117,6 @@ def _constrains_dates_coherency(self):
117117
raise UserError(_('The stop date cannot be earlier than the start date. '
118118
'Please check ticket %(ticket_name)s', ticket_name=ticket.name))
119119

120-
# @api.constrains('registration_ids', 'seats_max')
121-
# def _check_seats_availability(self, minimal_availability=0):
122-
# sold_out_tickets = []
123-
# for ticket in self:
124-
# if ticket.seats_max and ticket.seats_available < minimal_availability:
125-
# sold_out_tickets.append(_(
126-
# '- the ticket "%(ticket_name)s" (%(event_name)s): Missing %(nb_too_many)i seats.',
127-
# ticket_name=ticket.name,
128-
# event_name=ticket.event_id.name,
129-
# nb_too_many=minimal_availability - ticket.seats_available,
130-
# ))
131-
# if sold_out_tickets:
132-
# raise ValidationError(_('There are not enough seats available for:')
133-
# + '\n%s\n' % '\n'.join(sold_out_tickets))
134-
135120
@api.depends('seats_max', 'seats_available')
136121
@api.depends_context('name_with_seats_availability')
137122
def _compute_display_name(self):

addons/event/tests/__init__.py

+1
Original file line numberDiff line numberDiff line change
@@ -3,4 +3,5 @@
33

44
from . import test_event_internals
55
from . import test_event_mail_schedule
6+
from . import test_event_slot
67
from . import test_mailing

addons/event/tests/common.py

+26-2
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
1-
# -*- coding: utf-8 -*-
2-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
1+
from contextlib import contextmanager
2+
from freezegun import freeze_time
3+
from unittest.mock import patch
34

45
from odoo import fields
56
from odoo.addons.mail.tests.common import mail_new_test_user
@@ -157,6 +158,20 @@ def _create_registrations(cls, event, reg_count):
157158
} for idx in range(0, reg_count)])
158159
return registrations
159160

161+
@classmethod
162+
def _create_registrations_for_slot_and_ticket(cls, event, slot, ticket, count, **add_values):
163+
return cls.env['event.registration'].create([
164+
dict(
165+
{
166+
'email': f'{slot.id if slot else "NoSlot"}.{ticket.id if ticket else "NoTicket"}@test.example.com',
167+
'event_id': event.id,
168+
'event_slot_id': slot.id if slot else False,
169+
'event_ticket_id': ticket.id if ticket else False,
170+
'name': f'{slot.id if slot else "NoSlot"}.{ticket.id if ticket else "NoTicket"}',
171+
}, **add_values
172+
) for idx in range(0, count)
173+
])
174+
160175
@classmethod
161176
def _setup_test_reports(cls):
162177
cls.test_report_view = cls.env["ir.ui.view"].create({
@@ -215,3 +230,12 @@ def assertSchedulerCronTriggers(self, capture, call_at_list):
215230
for record, call_at in zip(capture.records, call_at_list):
216231
self.assertEqual(record.call_at, call_at.replace(microsecond=0))
217232
self.assertEqual(record.cron_id, self.env.ref('event.event_mail_scheduler'))
233+
234+
@contextmanager
235+
def mock_datetime_and_now(self, mock_dt):
236+
""" Used when synchronization date (using env.cr.now()) is important
237+
in addition to standard datetime mocks. Used mainly to detect sync
238+
issues. """
239+
with freeze_time(mock_dt), \
240+
patch.object(self.env.cr, 'now', lambda: mock_dt):
241+
yield

0 commit comments

Comments
 (0)