From 9540dc7772bcf2019415b4769769b9a52719c42e Mon Sep 17 00:00:00 2001 From: Florian Charlier Date: Tue, 5 Oct 2021 15:43:13 +0000 Subject: [PATCH] [IMP] event: Improve logic and appearence of archived registrations When an attendee's registration was archived, their seat was still considered taken, which could be problematic such as in cases of limited seat availability. Only non-archived registrations are now counted as seats. The same error as with regular registrations will be raised if there are not enough seats available to un-archive a registration. These ValidationError messages now show the name of the fully booked event. A few python tests are included to verify the impact of (un)archiving on seats availability for events and for event tickets. The appearence of archived registrations was also not different in form and kanban views, which is somewhat confusing and inconsistent with the aspect of archived records in Odoo. Actions buttons are not available on archived records. Filtering in the archived records needed to be simplified from a "Custom Filter" to a one-click feature, already available for many models. Task-2646298 closes odoo/odoo#77715 Signed-off-by: Thibault Delavallee (tde) --- addons/event/models/event_event.py | 13 ++- addons/event/models/event_registration.py | 5 +- addons/event/models/event_ticket.py | 14 ++- addons/event/static/src/scss/event.scss | 26 +++++ addons/event/tests/test_event_internals.py | 110 +++++++++++++++++++-- addons/event/views/event_views.xml | 89 ++++++++++------- 6 files changed, 204 insertions(+), 53 deletions(-) diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py index 8ba70e498f8..cd4b33c864e 100644 --- a/addons/event/models/event_event.py +++ b/addons/event/models/event_event.py @@ -208,7 +208,7 @@ class EventEvent(models.Model): else: event.kanban_state_label = event.stage_id.legend_done - @api.depends('seats_max', 'registration_ids.state') + @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 @@ -225,10 +225,10 @@ class EventEvent(models.Model): if self.ids: query = """ SELECT event_id, state, count(event_id) FROM event_registration - WHERE event_id IN %s AND state IN ('draft', 'open', 'done') + WHERE event_id IN %s AND state IN ('draft', 'open', 'done') AND active = true GROUP BY event_id, state """ - self.env['event.registration'].flush(['event_id', 'state']) + self.env['event.registration'].flush(['event_id', 'state', 'active']) self._cr.execute(query, (tuple(self.ids),)) res = self._cr.fetchall() for event_id, state, num in res: @@ -496,8 +496,11 @@ class EventEvent(models.Model): @api.constrains('seats_max', 'seats_available', 'seats_limited') def _check_seats_limit(self): - if any(event.seats_limited and event.seats_max and event.seats_available < 0 for event in self): - raise ValidationError(_('No more available seats.')) + for event in self: + if event.seats_limited and event.seats_max and event.seats_available < 0: + raise ValidationError(_('No more available seats for %s. ' + 'Raise the limit or remove some other confirmed registrations first.', + event.name)) @api.constrains('date_begin', 'date_end') def _check_closing_date(self): diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index e104434459f..18fea786c42 100644 --- a/addons/event/models/event_registration.py +++ b/addons/event/models/event_registration.py @@ -96,8 +96,9 @@ class EventRegistration(models.Model): @api.constrains('event_id', 'state') def _check_seats_limit(self): for registration in self: - if registration.event_id.seats_limited and registration.event_id.seats_max and registration.event_id.seats_available < (1 if registration.state == 'draft' else 0): - raise ValidationError(_('No more seats available for this event.')) + event_id = registration.event_id + if event_id.seats_limited and event_id.seats_max and event_id.seats_available < (1 if registration.state == 'draft' and event_id.auto_confirm else 0): + raise ValidationError(_('There are no more available seats for %s. Raise the limit or remove some other confirmed registrations first.', event_id.name)) @api.constrains('event_ticket_id', 'state') def _check_ticket_seats_limit(self): diff --git a/addons/event/models/event_ticket.py b/addons/event/models/event_ticket.py index 61ef734e96c..d5207f61f08 100644 --- a/addons/event/models/event_ticket.py +++ b/addons/event/models/event_ticket.py @@ -91,7 +91,7 @@ class EventTicket(models.Model): else: ticket.sale_available = True - @api.depends('seats_max', 'registration_ids.state') + @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 @@ -107,10 +107,10 @@ class EventTicket(models.Model): } query = """ SELECT event_ticket_id, state, count(event_id) FROM event_registration - WHERE event_ticket_id IN %s AND state IN ('draft', 'open', 'done') + 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(['event_id', 'event_ticket_id', 'state']) + self.env['event.registration'].flush(['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 @@ -129,8 +129,12 @@ class EventTicket(models.Model): @api.constrains('seats_available', 'seats_max') def _constrains_seats_available(self): - if any(record.seats_max and record.seats_available < 0 for record in self): - raise ValidationError(_('No more available seats for this ticket.')) + for record in self: + if record.seats_max and record.seats_available < 0: + raise ValidationError( + _('No more available seats for the ticket %s (%s). ' + 'Raise the limit or remove some other confirmed registrations first.', + record.name, record.event_id.name)) def _get_ticket_multiline_description(self): """ Compute a multiline description of this ticket. It is used when ticket diff --git a/addons/event/static/src/scss/event.scss b/addons/event/static/src/scss/event.scss index 703f90377e8..439b7a125da 100644 --- a/addons/event/static/src/scss/event.scss +++ b/addons/event/static/src/scss/event.scss @@ -23,6 +23,32 @@ min-height: 80px; } } + .oe_kanban_card_ribbon { + min-height: 95px; + .ribbon { + &::before, &::after { + display: none; + } + span { + padding: 5px; + font-size: small; + z-index: 0; + } + } + .ribbon-top-right { + margin-top: -1px; + span { + left: 7px; + right: 30px; + height: 25px; + top: 18px; + } + } + // Used for "Group By" + div.row { + min-height: 95px; + } + } } .o_event_registration_view_tree { diff --git a/addons/event/tests/test_event_internals.py b/addons/event/tests/test_event_internals.py index e02a42aedf5..2b1b3b9b546 100644 --- a/addons/event/tests/test_event_internals.py +++ b/addons/event/tests/test_event_internals.py @@ -486,12 +486,13 @@ class TestEventData(TestEventInternalsCommon): # create registration in order to check the seats computation self.assertTrue(event.auto_confirm) - for x in range(5): - reg = self.env['event.registration'].create({ - 'event_id': event.id, - 'name': 'reg_open', - }) - self.assertEqual(reg.state, 'open') + reg_open_multiple = self.env['event.registration'].create([{ + 'event_id': event.id, + 'name': 'reg_open', + } for _ in range(5)]) + self.assertEqual(set(reg_open_multiple.mapped('state')), {'open'}) + reg_open = reg_open_multiple[0] + reg_draft = self.env['event.registration'].create({ 'event_id': event.id, 'name': 'reg_draft', @@ -508,6 +509,38 @@ class TestEventData(TestEventInternalsCommon): self.assertEqual(event.seats_used, 1) self.assertEqual(event.seats_expected, 7) + # ------------------------------------------------------------ + # (UN-)ARCHIVING REGISTRATIONS + # ------------------------------------------------------------ + + # Archiving and seats availability + reg_open.action_archive() + self.assertEqual(event.seats_reserved, 4) + self.assertEqual(event.seats_available, event.event_type_id.seats_max - 5) + self.assertEqual(event.seats_expected, 6) + + reg_draft.action_archive() + self.assertEqual(event.seats_unconfirmed, 0) + self.assertEqual(event.seats_available, event.event_type_id.seats_max - 5) + self.assertEqual(event.seats_expected, 5) + + # Un-archiving confirmed seats requires available seat(s) + reg_open.action_unarchive() + self.assertEqual(event.seats_reserved, 5) + self.assertEqual(event.seats_available, event.event_type_id.seats_max - 6) + self.assertEqual(event.seats_expected, 6) + + reg_draft.action_unarchive() + self.assertEqual(event.seats_unconfirmed, 1) + self.assertEqual(event.seats_available, event.event_type_id.seats_max - 6) + self.assertEqual(event.seats_expected, 7) + + reg_open.action_archive() + event.write({'seats_max': 5}) + # It is not possible to unarchive confirmed seat if event is full + with self.assertRaises(exceptions.ValidationError): + reg_open.action_unarchive() + class TestEventRegistrationData(TestEventInternalsCommon): @@ -609,19 +642,21 @@ class TestEventTicketData(TestEventInternalsCommon): @users('user_eventmanager') def test_event_ticket_fields(self): """ Test event ticket fields synchronization """ + INITIAL_TICKET_SEATS_MAX = 30 event = self.event_0.with_user(self.env.user) event.write({ 'event_ticket_ids': [ (5, 0), (0, 0, { 'name': 'First Ticket', - 'seats_max': 30, + 'seats_max': INITIAL_TICKET_SEATS_MAX, }), (0, 0, { # limited in time, available (01/10 (start) < 01/31 (today) < 02/10 (end)) 'name': 'Second Ticket', 'start_sale_datetime': datetime(2020, 1, 10, 0, 0, 0), 'end_sale_datetime': datetime(2020, 2, 10, 23, 59, 59), }) ], + 'auto_confirm': False # to interact with registrations states }) first_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'First Ticket') second_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'Second Ticket') @@ -677,6 +712,67 @@ class TestEventTicketData(TestEventInternalsCommon): self.assertFalse(second_ticket.sale_available) self.assertTrue(second_ticket.is_expired) + # ------------------------------------------------------------ + # (UN-)ARCHIVING TICKET REGISTRATIONS + # ------------------------------------------------------------ + + # Archiving and seats availability + reg_draft_multiple = self.env['event.registration'].create([{ + 'event_id': event.id, + 'name': f'reg_draft #{idx}', + 'event_ticket_id': first_ticket.id, + } for idx in range(3)]) + reg_draft = reg_draft_multiple[0] + + reg_open = self.env['event.registration'].create({ + 'event_id': event.id, + 'name': 'reg_open', + 'event_ticket_id': first_ticket.id, + 'state': 'open' + }) + + reg_done = self.env['event.registration'].create({ + 'event_id': event.id, + 'name': 'reg_done', + 'event_ticket_id': first_ticket.id, + 'state': 'done' + }) + + self.assertEqual(first_ticket.seats_unconfirmed, 3) + self.assertEqual(first_ticket.seats_reserved, 1) + self.assertEqual(first_ticket.seats_used, 1) + self.assertEqual(first_ticket.seats_available, INITIAL_TICKET_SEATS_MAX - 2) + + reg_done.action_archive() + self.assertEqual(first_ticket.seats_used, 0) + self.assertEqual(first_ticket.seats_available, INITIAL_TICKET_SEATS_MAX - 1) + + reg_open.action_archive() + self.assertEqual(first_ticket.seats_reserved, 0) + self.assertEqual(first_ticket.seats_available, INITIAL_TICKET_SEATS_MAX) + + reg_draft.action_archive() + self.assertEqual(first_ticket.seats_unconfirmed, 2) + + # Un-archiving confirmed/done seats requires available seat(s) + reg_open.action_unarchive() + self.assertEqual(first_ticket.seats_reserved, 1) + self.assertEqual(first_ticket.seats_available, INITIAL_TICKET_SEATS_MAX - 1) + + reg_done.action_unarchive() + self.assertEqual(first_ticket.seats_used, 1) + self.assertEqual(first_ticket.seats_available, INITIAL_TICKET_SEATS_MAX - 2) + + reg_draft.action_unarchive() + self.assertEqual(first_ticket.seats_unconfirmed, 3) + self.assertEqual(first_ticket.seats_available, INITIAL_TICKET_SEATS_MAX - 2) + + reg_open.action_archive() + first_ticket.write({'seats_max': 1}) + # It is not possible to unarchive confirmed seat if ticket is fully booked + with self.assertRaises(exceptions.ValidationError): + reg_open.action_unarchive() + class TestEventTypeData(TestEventInternalsCommon): diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index 2dfb2c4c691..83fe16fc936 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -30,7 +30,7 @@ to - Attendees + Confirmed Attendees
@@ -206,6 +206,7 @@
+ @@ -238,7 +239,7 @@
@@ -445,7 +446,7 @@ - + + @@ -533,11 +535,13 @@ decoration-muted="state == 'cancel'" widget="badge"/> -