From 623a226beb3f432446e4373e39e5511eec7401a7 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 28 Jan 2020 11:32:29 +0000 Subject: [PATCH 1/7] [MOV][IMP] event: improve some views and event main menus definitions MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit PURPOSE This commit is part of ticket model support directly in event application. SPECIFICATIONS In this commit we move menu definitions in their own file. It eases views split and move, as otherwise there are dependencies between files that are complicated to solve. Better to have menus defined in a main file, and updating them at will afterwards. We also improve menus sequences and groups, as some were missing or badly defined. Registration tree view is slightly updated to include phone and mobile numbers as those are used notably for communication. They are hidden by default like email. Finally a small tweak of event kanban view is performed, notably to have a better display with dependencies installed. We ensure all cards have the same height. LINKS Task ID 2177281 Community PR #43488 Co-Authored-By: Thibault Delavallée Co-Authored-By: Stéphane Debauche --- addons/event/__manifest__.py | 1 + addons/event/static/src/scss/event.scss | 5 +-- addons/event/views/event_menu_views.xml | 41 +++++++++++++++++++++++++ addons/event/views/event_views.xml | 24 ++++++++------- 4 files changed, 58 insertions(+), 13 deletions(-) create mode 100644 addons/event/views/event_menu_views.xml diff --git a/addons/event/__manifest__.py b/addons/event/__manifest__.py index 5ad98513c17..f58fc32fb75 100644 --- a/addons/event/__manifest__.py +++ b/addons/event/__manifest__.py @@ -21,6 +21,7 @@ Key Features 'data': [ 'security/event_security.xml', 'security/ir.model.access.csv', + 'views/event_menu_views.xml', 'views/event_views.xml', 'views/event_stage_views.xml', 'report/event_event_templates.xml', diff --git a/addons/event/static/src/scss/event.scss b/addons/event/static/src/scss/event.scss index ec7ff14cc77..0fed82448a8 100644 --- a/addons/event/static/src/scss/event.scss +++ b/addons/event/static/src/scss/event.scss @@ -1,7 +1,8 @@ .o_kanban_view.o_event_kanban_view { + .o_kanban_record { + min-height: 140px; + } .o_kanban_content { - min-height: 130px; - .o_event_fontweight_500 { font-weight: 500; } diff --git a/addons/event/views/event_menu_views.xml b/addons/event/views/event_menu_views.xml new file mode 100644 index 00000000000..3f42322e9b3 --- /dev/null +++ b/addons/event/views/event_menu_views.xml @@ -0,0 +1,41 @@ + + + + + + + + + + + + + + + + + + diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index 919c4251c43..9d02e87f1c1 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -2,10 +2,6 @@ - - - - event.type.form @@ -158,9 +154,9 @@ event.type - - + + + @@ -348,8 +344,8 @@ -
-
+
+
@@ -475,7 +471,9 @@ - + + + + + @@ -726,6 +726,8 @@ event.mail - + + + From ec121565ccc5b9ab42bf381db15a08c98e76afba Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Tue, 28 Jan 2020 15:38:46 +0000 Subject: [PATCH 2/7] [IMP] event: clean code about synchronizing partner / sale order line PURPOSE This commit is part of ticket model support directly in event application. SPECIFICATIONS In this commit we make a quixotic attempt to clean call chain implied by making registrations online, either in event frontend (website_event) or with eCommerce inclusion (website_event_sale). Purpose is to get rid of some preparation and data management methods to handle most of the code directly at CRUD level whenever it makes sense. We notably * move at create and write level support of partner_id update: updating its name / email / phone / mobile; * move at create and write level support of sale_order-id update: updating event, ticket, partner; Using that and some code cleaning some methods are removed and call chain is a bit reduced. LINKS Task ID 2177281 Community PR #43488 --- addons/event/models/event_registration.py | 88 +++++++++++-------- .../event_sale/models/event_registration.py | 43 +++++---- addons/event_sale/models/sale_order.py | 9 +- addons/website_event/controllers/main.py | 9 +- addons/website_event_sale/controllers/main.py | 8 +- 5 files changed, 91 insertions(+), 66 deletions(-) diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index 3a85bee3e68..e8789776628 100644 --- a/addons/event/models/event_registration.py +++ b/addons/event/models/event_registration.py @@ -49,52 +49,53 @@ class EventRegistration(models.Model): if registration.event_id.seats_availability == '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.')) - def _check_auto_confirmation(self): - if self._context.get('registration_force_draft'): - return False - if any(not registration.event_id.auto_confirm or - (not registration.event_id.seats_available and registration.event_id.seats_availability == 'limited') for registration in self): - return False - return True + @api.onchange('partner_id') + def _onchange_partner(self): + if self.partner_id: + self.update(self._synchronize_partner_values(self.partner_id)) + + # ------------------------------------------------------------ + # CRUD + # ------------------------------------------------------------ @api.model def create(self, vals): + # update missing pieces of information from partner + if vals.get('partner_id'): + partner_vals = self._synchronize_partner_values( + self.env['res.partner'].browse(vals['partner_id']) + ) + vals = dict(partner_vals, **vals) + registration = super(EventRegistration, self).create(vals) if registration._check_auto_confirmation(): registration.sudo().action_confirm() + return registration def write(self, vals): + if vals.get('state') == 'done' and 'date_closed' not in vals: + vals['date_closed'] = fields.Datetime.now() + ret = super(EventRegistration, self).write(vals) + # update missing pieces of information from partner + if vals.get('partner_id'): + partner_vals = self._synchronize_partner_values( + self.env['res.partner'].browse(vals['partner_id']) + ) + for registration in self: + partner_info = dict((key, val) for key, val in partner_vals.items() if not registration[key]) + if partner_info: + registration.write(partner_info) + if vals.get('state') == 'open': # auto-trigger after_sub (on subscribe) mail schedulers, if needed onsubscribe_schedulers = self.mapped('event_id.event_mail_ids').filtered(lambda s: s.interval_type == 'after_sub') onsubscribe_schedulers.execute() - elif vals.get('state') == 'done' and 'date_closed' not in vals: - vals['date_closed'] = fields.Datetime.now() return ret - @api.model - def _prepare_attendee_values(self, registration): - """ Method preparing the values to create new attendees based on a - sales order line. It takes some registration data (dict-based) that are - optional values coming from an external input like a web page. This method - is meant to be inherited in various addons that sell events. """ - partner_id = registration.pop('partner_id', self.env.user.partner_id) - event_id = registration.pop('event_id', False) - data = { - 'name': registration.get('name', partner_id.name), - 'phone': registration.get('phone', partner_id.phone), - 'mobile': registration.get('mobile', partner_id.mobile), - 'email': registration.get('email', partner_id.email), - 'partner_id': partner_id.id, - 'event_id': event_id and event_id.id or False, - } - data.update({key: value for key, value in registration.items() if key in self._fields}) - return data - def name_get(self): """ Custom name_get implementation to better differentiate registrations linked to a given partner but with different name (one partner buying @@ -116,6 +117,26 @@ class EventRegistration(models.Model): ret_list.append((registration.id, name)) return ret_list + def _check_auto_confirmation(self): + if self._context.get('registration_force_draft'): + return False + if any(not registration.event_id.auto_confirm or + (not registration.event_id.seats_available and registration.event_id.seats_availability == 'limited') for registration in self): + return False + return True + + def _synchronize_partner_values(self, partner): + if partner: + contact_id = partner.address_get().get('contact', False) + if contact_id: + contact = self.env['res.partner'].browse(contact_id) + return dict((fname, contact[fname]) for fname in ['name', 'email', 'phone', 'mobile'] if contact[fname]) + return {} + + # ------------------------------------------------------------ + # ACTIONS / BUSINESS + # ------------------------------------------------------------ + def action_set_draft(self): self.write({'state': 'draft'}) @@ -129,17 +150,6 @@ class EventRegistration(models.Model): def action_cancel(self): self.write({'state': 'cancel'}) - @api.onchange('partner_id') - def _onchange_partner(self): - if self.partner_id: - contact_id = self.partner_id.address_get().get('contact', False) - if contact_id: - contact = self.env['res.partner'].browse(contact_id) - self.name = contact.name or self.name - self.email = contact.email or self.email - self.phone = contact.phone or self.phone - self.mobile = contact.mobile or self.mobile - def _message_get_suggested_recipients(self): recipients = super(EventRegistration, self)._message_get_suggested_recipients() public_users = self.env['res.users'].sudo() diff --git a/addons/event_sale/models/event_registration.py b/addons/event_sale/models/event_registration.py index bc9c2ae4917..88086102663 100644 --- a/addons/event_sale/models/event_registration.py +++ b/addons/event_sale/models/event_registration.py @@ -41,29 +41,38 @@ class EventRegistration(models.Model): @api.model def create(self, vals): + if vals.get('sale_order_line_id'): + so_line_vals = self._synchronize_so_line_values( + self.env['sale.order.line'].browse(vals['sale_order_line_id']) + ) + vals.update(so_line_vals) res = super(EventRegistration, self).create(vals) if res.origin or res.sale_order_id: - res.message_post_with_view('mail.message_origin_link', + res.message_post_with_view( + 'mail.message_origin_link', values={'self': res, 'origin': res.sale_order_id}, subtype_id=self.env.ref('mail.mt_note').id) return res - @api.model - def _prepare_attendee_values(self, registration): - """ Override to add sale related stuff """ - line_id = registration.get('sale_order_line_id') - if line_id: - registration.setdefault('partner_id', line_id.order_id.partner_id) - att_data = super(EventRegistration, self)._prepare_attendee_values(registration) - if line_id and line_id.event_ticket_id.sale_available: - att_data.update({ - 'event_id': line_id.event_id.id, - 'event_ticket_id': line_id.event_ticket_id.id, - 'origin': line_id.order_id.name, - 'sale_order_id': line_id.order_id.id, - 'sale_order_line_id': line_id.id, - }) - return att_data + def write(self, vals): + if vals.get('sale_order_line_id'): + so_line_vals = self._synchronize_so_line_values( + self.env['sale.order.line'].browse(vals['sale_order_line_id']) + ) + vals.update(so_line_vals) + return super(EventRegistration, self).write(vals) + + def _synchronize_so_line_values(self, so_line): + if so_line: + return { + 'partner_id': so_line.order_id.partner_id.id, + 'event_id': so_line.event_id.id, + 'event_ticket_id': so_line.event_ticket_id.id, + 'origin': so_line.order_id.name, + 'sale_order_id': so_line.order_id.id, + 'sale_order_line_id': so_line.id, + } + return {} def summary(self): res = super(EventRegistration, self).summary() diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index 10a8e0a5e2c..e1ff94f78b6 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -60,13 +60,12 @@ class SaleOrderLine(models.Model): existing_registrations.filtered(lambda self: self.state == 'cancel').action_set_draft() for count in range(int(so_line.product_uom_qty) - len(existing_registrations)): - registration = {} + registration_vals = {} if registration_data: - registration = registration_data.pop() + registration_vals = registration_data.pop() # TDE CHECK: auto confirmation - registration['sale_order_line_id'] = so_line - Registration.with_context(registration_force_draft=True).create( - Registration._prepare_attendee_values(registration)) + registration_vals['sale_order_line_id'] = so_line.id + Registration.with_context(registration_force_draft=True).create(registration_vals) return True @api.onchange('product_id') diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py index 15c83004bb2..d4d8e7f06ca 100644 --- a/addons/website_event/controllers/main.py +++ b/addons/website_event/controllers/main.py @@ -290,10 +290,11 @@ class WebsiteEventController(http.Controller): Attendees = request.env['event.registration'] registrations = self._process_registration_details(post) - for registration in registrations: - registration['event_id'] = event - Attendees += Attendees.sudo().create( - Attendees._prepare_attendee_values(registration)) + for registration_vals in registrations: + registration_vals['event_id'] = event.id + if not registration_vals.get('partner_id'): + registration_vals['partner_id'] = request.env.user.partner_id.id + Attendees += Attendees.sudo().create(registration_vals) urls = event._get_event_resource_urls() return request.render("website_event.registration_complete", { diff --git a/addons/website_event_sale/controllers/main.py b/addons/website_event_sale/controllers/main.py index 9dbb339cde9..b47d2f0f2cc 100644 --- a/addons/website_event_sale/controllers/main.py +++ b/addons/website_event_sale/controllers/main.py @@ -29,6 +29,12 @@ class WebsiteEventSaleController(WebsiteEventController): tickets = request.env['event.event.ticket'].browse(tuple(ticket_post)) return [{'id': ticket.id, 'name': ticket.name, 'quantity': ticket_post[ticket.id], 'price': ticket.price} for ticket in tickets if ticket_post[ticket.id]] + def _process_registration_details(self, details): + res = super(WebsiteEventSaleController, self)._process_registration_details(details) + for registration_vals in res: + registration_vals['event_ticket_id'] = int(registration_vals.pop('ticket_id')) if registration_vals.get('ticket_id') else False + return res + @http.route() def registration_confirm(self, event, **post): order = request.website.sale_get_order(force_create=1) @@ -36,7 +42,7 @@ class WebsiteEventSaleController(WebsiteEventController): registrations = self._process_registration_details(post) for registration in registrations: - ticket = request.env['event.event.ticket'].sudo().browse(int(registration['ticket_id'])) + ticket = request.env['event.event.ticket'].sudo().browse(int(registration['event_ticket_id'])) cart_values = order.with_context(event_ticket_id=ticket.id, fixed_price=True)._cart_update(product_id=ticket.product_id.id, add_qty=1, registration_data=[registration]) attendee_ids |= set(cart_values.get('attendee_ids', [])) From 6b09c1b8c62fc8b5bbd210d4759561ec67269155 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Fri, 17 Jan 2020 12:57:52 +0000 Subject: [PATCH 3/7] [REF] event: move ticket definition from event_sale to event and use ticket templates PURPOSE Integration between event and eCommerce is required only when users handle the entire selling process online. However users may require ticketing support while managing payments outside of Odoo. Purpose of this commit is to support tickets directly in event application without need of sales. RATIONALE Remove the need to have event_sale installed to manage basic multi ticket event type. Integration with eCommerce is needed only when one wants to handle the entire selling flow online, i.e. order, payment, ... Integration with Sales is needed only when one wants to create sale orders linked to attendees. Many event users do not need all of this. Their attendees pay through bank transfers or they simply manage payments outside of Odoo while still requiring tickets management. SPECIFICATIONS Remove the need to have event_sale installed to manage basic ticketing on events. Move ticket model (event.event.ticket) directly into event, copying most fields from event_sale. Only sale specific fields and behavior should be kept in event_sale : * keep product_id and price information in event_sale; * keep sales analysis in event_sale; We also split tickets model used for event type (event.type.ticket) and events (event.event.ticket). Indeed previously to this commit both are modeled in the same table, with the following issues : * tickets on templates use only a subset of fields: name, seats availability, product, price; * a ticket has either an event_id, either an event_type_id, and there are constraints to try to avoid having lost tickets. This leads to a strange model where m2o fields are required only in some cases with a dual behavior; * tickets are not shared between event.type and event.event. They are copied and having a single model is therefore not necessary; We therefore choose to have a light model for event.type.ticket. It is linked to event.type when configuring template tickets. They are copied in the onchange copying event template configuration to the event itself, leading to event.event.ticket creation. Some tests are moved / completed accordingly. Access rights are copied from website_event_sale to website_even concerning ticket access for public / portal. Currently they are kept as they are with some rewording as it is not the purpose of this commit to rewrite them. LINKS Task ID 2177281 Community PR odoo/odoo#43488 --- addons/event/__manifest__.py | 1 + addons/event/data/event_data.xml | 6 + addons/event/models/__init__.py | 1 + addons/event/models/event_event.py | 45 ++++- addons/event/models/event_registration.py | 15 ++ addons/event/models/event_ticket.py | 139 ++++++++++++++ addons/event/security/ir.model.access.csv | 4 + addons/event/tests/common.py | 7 + addons/event/tests/test_event_internals.py | 110 ++++++++++- addons/event/views/event_ticket_views.xml | 178 ++++++++++++++++++ addons/event/views/event_views.xml | 36 +++- .../event/views/res_config_settings_views.xml | 2 +- addons/event_sale/data/event_sale_data.xml | 7 - addons/event_sale/models/event_event.py | 57 +----- .../event_sale/models/event_registration.py | 14 -- addons/event_sale/models/event_ticket.py | 178 +++++++----------- addons/event_sale/models/sale_order.py | 2 +- .../event_sale/security/ir.model.access.csv | 2 - .../event_sale/tests/test_event_internals.py | 128 +++++++++---- .../views/event_registration_views.xml | 23 --- .../event_sale/views/event_ticket_views.xml | 141 +++++++------- addons/event_sale/views/event_views.xml | 38 +--- .../website_event/security/event_security.xml | 11 ++ .../security/ir.model.access.csv | 4 +- addons/website_event_sale/data/event_data.xml | 2 +- .../website_event_sale/models/sale_order.py | 2 +- .../security/ir.model.access.csv | 3 +- .../security/website_event_sale_security.xml | 11 -- 28 files changed, 778 insertions(+), 389 deletions(-) create mode 100644 addons/event/models/event_ticket.py create mode 100644 addons/event/views/event_ticket_views.xml diff --git a/addons/event/__manifest__.py b/addons/event/__manifest__.py index f58fc32fb75..b71e33db688 100644 --- a/addons/event/__manifest__.py +++ b/addons/event/__manifest__.py @@ -22,6 +22,7 @@ Key Features 'security/event_security.xml', 'security/ir.model.access.csv', 'views/event_menu_views.xml', + 'views/event_ticket_views.xml', 'views/event_views.xml', 'views/event_stage_views.xml', 'report/event_event_templates.xml', diff --git a/addons/event/data/event_data.xml b/addons/event/data/event_data.xml index 03df5f2923a..a9ab41942a6 100644 --- a/addons/event/data/event_data.xml +++ b/addons/event/data/event_data.xml @@ -27,6 +27,12 @@ + + Ticketing + + + + diff --git a/addons/event/models/__init__.py b/addons/event/models/__init__.py index 727b34645d7..943b57206f9 100644 --- a/addons/event/models/__init__.py +++ b/addons/event/models/__init__.py @@ -5,6 +5,7 @@ from . import event_event from . import event_mail from . import event_registration from . import event_stage +from . import event_ticket from . import res_config_settings from . import res_partner from . import ir_autovacuum diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py index 2a3a4597c9d..263b3153941 100644 --- a/addons/event/models/event_event.py +++ b/addons/event/models/event_event.py @@ -41,6 +41,12 @@ class EventType(models.Model): name = fields.Char('Event Category', required=True, translate=True) sequence = fields.Integer() + # tickets + use_ticket = fields.Boolean('Ticketing') + event_type_ticket_ids = fields.One2many( + 'event.type.ticket', 'event_type_id', + string='Tickets', compute='_compute_event_type_ticket_ids', + readonly=False, store=True) # registration has_seats_limitation = fields.Boolean('Limited Seats') default_registration_min = fields.Integer( @@ -69,6 +75,16 @@ class EventType(models.Model): copy=False, default=lambda self: self._get_default_event_type_mail_ids()) + @api.depends('use_ticket') + def _compute_event_type_ticket_ids(self): + for template in self: + if not template.use_ticket: + template.event_type_ticket_ids = [(5, 0)] + elif not template.event_type_ticket_ids: + template.event_type_ticket_ids = [(0, 0, { + 'name': _('Registration'), + })] + @api.onchange('has_seats_limitation') def _onchange_has_seats_limitation(self): if not self.has_seats_limitation: @@ -143,6 +159,9 @@ class EventEvent(models.Model): auto_confirm = fields.Boolean(string='Autoconfirm Registrations') registration_ids = fields.One2many('event.registration', 'event_id', string='Attendees') event_registrations_open = fields.Boolean('Registration open', compute='_compute_event_registrations_open') + event_ticket_ids = fields.One2many( + 'event.event.ticket', 'event_id', string='Event Ticket', + copy=True) # Date fields date_tz = fields.Selection( _tz_get, string='Timezone', required=True, @@ -152,6 +171,7 @@ class EventEvent(models.Model): date_begin_located = fields.Char(string='Start Date Located', compute='_compute_date_begin_tz') date_end_located = fields.Char(string='End Date Located', compute='_compute_date_end_tz') is_one_day = fields.Boolean(compute='_compute_field_is_one_day') + start_sale_date = fields.Date('Start sale date', compute='_compute_start_sale_date') # Location and communication is_online = fields.Boolean('Online Event') address_id = fields.Many2one( @@ -196,10 +216,12 @@ class EventEvent(models.Model): event.seats_available = event.seats_max - (event.seats_reserved + event.seats_used) event.seats_expected = event.seats_unconfirmed + event.seats_reserved + event.seats_used - @api.depends('date_end', 'seats_available', 'seats_availability') + @api.depends('date_end', 'seats_available', 'seats_availability', 'event_ticket_ids.sale_available') def _compute_event_registrations_open(self): for event in self: - event.event_registrations_open = event.date_end > fields.Datetime.now() and (event.seats_available or event.seats_availability == 'unlimited') + event.event_registrations_open = (event.date_end > fields.Datetime.now()) and \ + (event.seats_available or event.seats_availability == 'unlimited') and \ + (not event.event_ticket_ids or any(ticket.sale_available for ticket in event.event_ticket_ids)) @api.depends('stage_id', 'kanban_state') def _compute_kanban_state_label(self): @@ -239,6 +261,12 @@ class EventEvent(models.Model): end_tz = fields.Datetime.context_timestamp(event, event.date_end) event.is_one_day = (begin_tz.date() == end_tz.date()) + @api.depends('event_ticket_ids.start_sale_date') + def _compute_start_sale_date(self): + for event in self: + start_dates = [ticket.start_sale_date for ticket in event.event_ticket_ids if ticket.start_sale_date] + event.start_sale_date = min(start_dates) if start_dates else False + @api.onchange('is_online') def _onchange_is_online(self): if self.is_online: @@ -271,6 +299,19 @@ class EventEvent(models.Model): }) for line in self.event_type_id.event_type_mail_ids] + # compute tickets information + if self.event_type_id.use_ticket: + all_ticket_values = [] + for ticket in self.event_type_id.event_type_ticket_ids: + ticket_vals = dict( + (attribute_name, ticket[attribute_name] if not isinstance(ticket[attribute_name], models.BaseModel) else ticket[attribute_name].id) + for attribute_name in self.env['event.type.ticket']._get_event_ticket_fields_whitelist() + ) + ticket_vals['name'] = self.name and _('Registration for %s') % self.name or ticket.name + all_ticket_values.append(ticket_vals) + + self.event_ticket_ids = [(5, 0, 0)] + [(0, 0, item) for item in all_ticket_values] + @api.constrains('seats_min', 'seats_max', 'seats_availability') def _check_seats_min_max(self): if any(event.seats_availability == 'limited' and event.seats_min > event.seats_max for event in self): diff --git a/addons/event/models/event_registration.py b/addons/event/models/event_registration.py index e8789776628..575e3442786 100644 --- a/addons/event/models/event_registration.py +++ b/addons/event/models/event_registration.py @@ -22,6 +22,9 @@ class EventRegistration(models.Model): event_id = fields.Many2one( 'event.event', string='Event', required=True, readonly=True, states={'draft': [('readonly', False)]}) + event_ticket_id = fields.Many2one( + 'event.event.ticket', string='Event Ticket', readonly=True, + states={'draft': [('readonly', False)]}) # attendee partner_id = fields.Many2one( 'res.partner', string='Contact', @@ -43,12 +46,24 @@ class EventRegistration(models.Model): ('open', 'Confirmed'), ('done', 'Attended')], string='Status', default='draft', readonly=True, copy=False, tracking=True) + @api.onchange('event_id') + def _onchange_event_id(self): + # We reset the ticket when keeping it would lead to an inconstitent state. + if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id): + self.event_ticket_id = None + @api.constrains('event_id', 'state') def _check_seats_limit(self): for registration in self: if registration.event_id.seats_availability == '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.')) + @api.constrains('event_ticket_id', 'state') + def _check_ticket_seats_limit(self): + for record in self: + if record.event_ticket_id.seats_max and record.event_ticket_id.seats_available < 0: + raise ValidationError(_('No more available seats for this ticket')) + @api.onchange('partner_id') def _onchange_partner(self): if self.partner_id: diff --git a/addons/event/models/event_ticket.py b/addons/event/models/event_ticket.py new file mode 100644 index 00000000000..acdc899e3d1 --- /dev/null +++ b/addons/event/models/event_ticket.py @@ -0,0 +1,139 @@ +# -*- 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 + + +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) + event_type_id = fields.Many2one( + 'event.type', string='Event Category', ondelete='cascade', required=True) + # sale + start_sale_date = fields.Date(string="Registration Start") + end_sale_date = fields.Date(string="Registration End") + is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired') + sale_available = fields.Boolean(string='Is Available', compute='_compute_sale_available') + # seats + seats_availability = fields.Selection([ + ('limited', 'Limited'), ('unlimited', 'Unlimited')], string='Seats Limit', + readonly=True, store=True, compute='_compute_seats_availability') + seats_max = fields.Integer( + string='Maximum Seats', + 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('end_sale_date', 'event_type_id') + def _compute_is_expired(self): + for ticket in self: + if ticket.end_sale_date: + current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz())) + ticket.is_expired = ticket.end_sale_date < current_date + else: + ticket.is_expired = False + + @api.depends('start_sale_date', 'end_sale_date', 'event_type_id') + def _compute_sale_available(self): + for ticket in self: + current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz())) + if (ticket.start_sale_date and ticket.start_sale_date > current_date) or \ + ticket.end_sale_date and ticket.end_sale_date < current_date: + ticket.sale_available = False + else: + ticket.sale_available = True + + @api.depends('seats_max') + def _compute_seats_availability(self): + for ticket in self: + ticket.seats_availability = 'limited' if ticket.seats_max else 'unlimited' + + @api.constrains('start_sale_date', 'end_sale_date') + def _constrains_dates_coherency(self): + for ticket in self: + if ticket.start_sale_date and ticket.end_sale_date and ticket.start_sale_date > ticket.end_sale_date: + raise UserError(_('The stop date cannot be earlier than the start date.')) + + def _get_ticket_tz(self): + return self.event_type_id.use_timezone and self.event_type_id.default_timezone or self.env.user.tz + + @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'] + + +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' + + # 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 + registration_ids = fields.One2many('event.registration', 'event_ticket_id', string='Registrations') + # seats + seats_reserved = fields.Integer(string='Reserved Seats', compute='_compute_seats', store=True) + seats_available = fields.Integer(string='Available Seats', compute='_compute_seats', store=True) + seats_unconfirmed = fields.Integer(string='Unconfirmed Seats', compute='_compute_seats', store=True) + seats_used = fields.Integer(string='Used Seats', compute='_compute_seats', store=True) + + @api.depends('seats_max', 'registration_ids.state') + 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 + 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') + GROUP BY event_ticket_id, state + """ + self.env['event.registration'].flush(['event_id', 'event_ticket_id', 'state']) + self.env.cr.execute(query, (tuple(self.ids),)) + for event_ticket_id, state, num in self.env.cr.fetchall(): + ticket = self.browse(event_ticket_id) + ticket[state_field[state]] += num + # compute seats_available + for ticket in self: + if ticket.seats_max > 0: + ticket.seats_available = ticket.seats_max - (ticket.seats_reserved + ticket.seats_used) + + @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.')) + + 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. """ + self.ensure_one() + lines = [self.display_name] + if self.event_id: + lines.append(self.event_id.display_name) + return '\n'.join(lines) + + def _get_ticket_tz(self): + return self.event_id.date_tz or self.env.user.tz diff --git a/addons/event/security/ir.model.access.csv b/addons/event/security/ir.model.access.csv index d00f94350cc..9d680924f5d 100644 --- a/addons/event/security/ir.model.access.csv +++ b/addons/event/security/ir.model.access.csv @@ -1,9 +1,13 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_event_type,event.type,model_event_type,event.group_event_user,1,0,0,0 access_event_type_manager,event.type manager,model_event_type,event.group_event_manager,1,1,1,1 +access_event_type_ticket,event.type.ticket.user,model_event_type_ticket,event.group_event_user,1,0,0,0 +access_event_type_ticket_manager,event.type.ticket.manager,model_event_type_ticket,event.group_event_manager,1,1,1,1 access_event_event_portal,event.event.portal,model_event_event,,1,0,0,0 access_event_event_user,event.event.user,model_event_event,event.group_event_user,1,0,0,0 access_event_event_manager,event.event.manager,model_event_event,event.group_event_manager,1,1,1,1 +access_event_event_ticket_user,event.event.ticket.user,model_event_event_ticket,event.group_event_user,1,0,0,0 +access_event_event_ticket_manager,event.event.ticket.manager,model_event_event_ticket,event.group_event_manager,1,1,1,1 access_event_registration,event.registration,model_event_registration,event.group_event_user,1,1,1,1 access_event_registration_employee,event.registration,model_event_registration,base.group_user,1,0,0,0 access_event_registration_portal,event.registration,model_event_registration,base.group_portal,1,0,0,0 diff --git a/addons/event/tests/common.py b/addons/event/tests/common.py index 44aabb1d5d5..9dc01aa8011 100644 --- a/addons/event/tests/common.py +++ b/addons/event/tests/common.py @@ -59,6 +59,13 @@ class TestEventCommon(common.SavepointCase): 'default_timezone': 'Europe/Paris', 'use_hashtag': True, 'default_hashtag': 'amazing', + 'use_ticket': True, + 'event_type_ticket_ids': [(0, 0, { + 'name': 'First Ticket', + }), (0, 0, { + 'name': 'Second Ticket', + }) + ], 'use_mail_schedule': True, 'event_type_mail_ids': [ (0, 0, { # right at subscription diff --git a/addons/event/tests/test_event_internals.py b/addons/event/tests/test_event_internals.py index 1f388eee3d8..779f2ea3445 100644 --- a/addons/event/tests/test_event_internals.py +++ b/addons/event/tests/test_event_internals.py @@ -1,11 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from datetime import datetime, timedelta +from datetime import date, datetime, timedelta from unittest.mock import patch from odoo.addons.event.tests.common import TestEventCommon -from odoo.fields import Datetime as FieldsDatetime +from odoo import exceptions +from odoo.fields import Datetime as FieldsDatetime, Date as FieldsDate from odoo.tests.common import users from odoo.tools import mute_logger @@ -149,6 +150,105 @@ class TestEventData(TestEventCommon): self.assertEqual(event.event_mail_ids.interval_type, 'before_event') self.assertEqual(event.event_mail_ids.template_id, self.env.ref('event.event_reminder')) + @users('user_eventmanager') + def test_event_registrable(self): + """Test if `_compute_event_registrations_open` works properly.""" + event = self.event_0.with_user(self.env.user) + self.assertTrue(event.event_registrations_open) + + # ticket without dates boundaries -> ok + ticket = self.env['event.event.ticket'].create({ + 'name': 'TestTicket', + 'event_id': event.id, + }) + self.assertTrue(event.event_registrations_open) + + # even with valid tickets, date limits registrations + event.write({ + 'date_begin': datetime.now() - timedelta(days=3), + 'date_end': datetime.now() - timedelta(days=1), + }) + self.assertFalse(event.event_registrations_open) + + # no more seats available + registration = self.env['event.registration'].create({ + 'name': 'Albert Test', + 'event_id': event.id, + }) + registration.action_confirm() + event.write({ + 'date_end': datetime.now() + timedelta(days=3), + 'seats_max': 1, + 'seats_availability': 'limited', + }) + self.assertEqual(event.seats_available, 0) + self.assertFalse(event.event_registrations_open) + + # seats available are back + registration.unlink() + self.assertEqual(event.seats_available, 1) + self.assertTrue(event.event_registrations_open) + + # but tickets are expired + ticket.write({'end_sale_date': datetime.now() - timedelta(days=2)}) + self.assertTrue(ticket.is_expired) + self.assertFalse(event.event_registrations_open) + + +class TestEventTicketData(TestEventCommon): + + def setUp(self): + super(TestEventTicketData, self).setUp() + self.ticket_date_patcher = patch('odoo.addons.event.models.event_ticket.fields.Date', wraps=FieldsDate) + self.ticket_date_patcher_mock = self.ticket_date_patcher.start() + self.ticket_date_patcher_mock.context_today.return_value = date(2020, 1, 31) + + def tearDown(self): + super(TestEventTicketData, self).tearDown() + self.ticket_date_patcher.stop() + + @users('user_eventmanager') + def test_event_ticket_fields(self): + """ Test event ticket fields synchronization """ + self.event_type_complex.write({ + 'use_ticket': True, + 'event_type_ticket_ids': [ + (5, 0), + (0, 0, { + 'name': 'First Ticket', + 'seats_max': 30, + }), (0, 0, { # limited in time, available (01/10 (start) < 01/31 (today) < 02/10 (end)) + 'name': 'Second Ticket', + 'start_sale_date': date(2020, 1, 10), + 'end_sale_date': date(2020, 2, 10), + }) + ], + }) + first_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'First Ticket') + second_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'Second Ticket') + + self.assertEqual(first_ticket.seats_availability, 'limited') + self.assertTrue(first_ticket.sale_available) + self.assertFalse(first_ticket.is_expired) + + self.assertEqual(second_ticket.seats_availability, 'unlimited') + self.assertTrue(second_ticket.sale_available) + self.assertFalse(second_ticket.is_expired) + # sale is ended + second_ticket.write({'end_sale_date': date(2020, 1, 20)}) + self.assertFalse(second_ticket.sale_available) + self.assertTrue(second_ticket.is_expired) + # sale has not started + second_ticket.write({ + 'start_sale_date': date(2020, 2, 10), + 'end_sale_date': date(2020, 2, 20), + }) + self.assertFalse(second_ticket.sale_available) + self.assertFalse(second_ticket.is_expired) + # incoherent dates are invalid + with self.assertRaises(exceptions.UserError): + second_ticket.write({'end_sale_date': date(2020, 1, 20)}) + class TestEventTypeData(TestEventCommon): @@ -161,11 +261,13 @@ class TestEventTypeData(TestEventCommon): 'has_seats_limitation': True, 'default_registration_min': 5, 'default_registration_max': 30, + 'use_ticket': True, }) event_type._onchange_has_seats_limitation() self.assertTrue(event_type.has_seats_limitation) self.assertEqual(event_type.default_registration_min, 5) self.assertEqual(event_type.default_registration_max, 30) + self.assertEqual(event_type.event_type_ticket_ids.mapped('name'), ['Registration']) # reset seats limitation event_type.write({'has_seats_limitation': False}) @@ -173,3 +275,7 @@ class TestEventTypeData(TestEventCommon): self.assertFalse(event_type.has_seats_limitation) self.assertEqual(event_type.default_registration_min, 0) self.assertEqual(event_type.default_registration_max, 0) + + # reset tickets + event_type.write({'use_ticket': False}) + self.assertEqual(event_type.event_type_ticket_ids, self.env['event.type.ticket']) diff --git a/addons/event/views/event_ticket_views.xml b/addons/event/views/event_ticket_views.xml new file mode 100644 index 00000000000..e098b44a7ad --- /dev/null +++ b/addons/event/views/event_ticket_views.xml @@ -0,0 +1,178 @@ + + + + + + event.type.ticket.view.tree.from.type + event.type.ticket + + + + + + + + + + + + event.type.ticket.view.form.from.type + event.type.ticket + + +
+ + + + + + + +
+
+
+ + + event.type.ticket.view.tree + event.type.ticket + + primary + + + + + + + + + + + + + event.type.ticket.view.form + event.type.ticket + + primary + + + + + + + + + + + event.event.ticket.view.tree.from.event + event.event.ticket + + + + + + + + + + + + + + + event.event.ticket.view.form.from.event + event.event.ticket + + +
+ + + + + + + + + + + + + +
+
+
+ + + event.event.ticket.view.kanban.from.event + event.event.ticket + + + + + + + + + +
+
+
+ +
+
+
+ reserved + unconfirmed +
+
+
+
+
+
+
+ + + event.event.ticket.view.tree + event.event.ticket + + primary + + + + + + + + + + + + + event.event.ticket.view.form + event.event.ticket + +
+ +
+
+ + + + + + + + + + + + + + + + +
+
+
+
+
\ No newline at end of file diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index 9d02e87f1c1..e02bac699c9 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -107,6 +107,24 @@

Attendees

+
+
+ +
+
+
+
@@ -259,6 +277,13 @@ + + + @@ -494,7 +519,8 @@ - + + @@ -531,6 +557,13 @@ + @@ -643,6 +676,7 @@ + diff --git a/addons/event/views/res_config_settings_views.xml b/addons/event/views/res_config_settings_views.xml index 8636b17efa3..0e8c5455e8d 100644 --- a/addons/event/views/res_config_settings_views.xml +++ b/addons/event/views/res_config_settings_views.xml @@ -34,7 +34,7 @@
diff --git a/addons/event_sale/data/event_sale_data.xml b/addons/event_sale/data/event_sale_data.xml index ce5c21e136e..a7928ded17b 100644 --- a/addons/event_sale/data/event_sale_data.xml +++ b/addons/event_sale/data/event_sale_data.xml @@ -17,13 +17,6 @@ service - - - Ticketing - - - - diff --git a/addons/event_sale/models/event_event.py b/addons/event_sale/models/event_event.py index 1d7bc163a36..b4e45656a2d 100644 --- a/addons/event_sale/models/event_event.py +++ b/addons/event_sale/models/event_event.py @@ -1,42 +1,12 @@ # -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. -from odoo import api, fields, models, _ - - -class EventType(models.Model): - _inherit = 'event.type' - - @api.model - def _get_default_event_ticket_ids(self): - product = self.env.ref('event_sale.product_product_event', raise_if_not_found=False) - if not product: - return False - return [(0, 0, { - 'name': _('Registration'), - 'product_id': product.id, - 'price': 0, - })] - - use_ticketing = fields.Boolean('Ticketing') - event_ticket_ids = fields.One2many( - 'event.event.ticket', 'event_type_id', - string='Tickets', default=_get_default_event_ticket_ids) - - @api.onchange('name') - def _onchange_name(self): - if self.name: - self.event_ticket_ids.filtered(lambda ticket: ticket.name == _('Registration')).update({ - 'name': _('Registration for %s') % self.name - }) +from odoo import api, fields, models class Event(models.Model): _inherit = 'event.event' - event_ticket_ids = fields.One2many( - 'event.event.ticket', 'event_id', string='Event Ticket', - copy=True) sale_order_lines_ids = fields.One2many( 'sale.order.line', 'event_id', string='All sale order lines pointing to this event') @@ -44,25 +14,6 @@ class Event(models.Model): currency_id = fields.Many2one( 'res.currency', string='Currency', related='company_id.currency_id', readonly=True) - start_sale_date = fields.Date('Start sale date', compute='_compute_start_sale_date') - - @api.onchange('event_type_id') - def _onchange_type(self): - super(Event, self)._onchange_type() - if self.event_type_id.use_ticketing: - self.event_ticket_ids = [(5, 0, 0)] + [ - (0, 0, { - 'name': self.name and _('Registration for %s') % self.name or ticket.name, - 'product_id': ticket.product_id.id, - 'price': ticket.price, - }) - for ticket in self.event_type_id.event_ticket_ids] - - @api.depends('event_ticket_ids.start_sale_date') - def _compute_start_sale_date(self): - for event in self: - start_dates = [ticket.start_sale_date for ticket in event.event_ticket_ids if ticket.start_sale_date] - event.start_sale_date = min(start_dates) if start_dates else False @api.depends('company_id.currency_id', 'sale_order_lines_ids.price_subtotal', 'sale_order_lines_ids.currency_id', @@ -78,12 +29,6 @@ class Event(models.Model): for sale_order_line_id in event.sale_order_lines_ids ]) - @api.depends('event_ticket_ids.sale_available') - def _compute_event_registrations_open(self): - non_open_events = self.filtered(lambda event: not any(event.event_ticket_ids.mapped('sale_available'))) - non_open_events.event_registrations_open = False - super(Event, self - non_open_events)._compute_event_registrations_open() - def action_view_linked_orders(self): """ Redirects to the orders linked to the current events """ sale_order_action = self.env.ref('sale.action_orders').read()[0] diff --git a/addons/event_sale/models/event_registration.py b/addons/event_sale/models/event_registration.py index 88086102663..be96547f10e 100644 --- a/addons/event_sale/models/event_registration.py +++ b/addons/event_sale/models/event_registration.py @@ -2,14 +2,12 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from odoo import api, fields, models, _ -from odoo.exceptions import ValidationError from odoo.tools import float_is_zero class EventRegistration(models.Model): _inherit = 'event.registration' - event_ticket_id = fields.Many2one('event.event.ticket', string='Event Ticket', readonly=True, states={'draft': [('readonly', False)]}) # in addition to origin generic fields, add real relational fields to correctly # handle attendees linked to sales orders and their lines # TDE FIXME: maybe add an onchange on sale_order_id + origin @@ -19,18 +17,6 @@ class EventRegistration(models.Model): source_id = fields.Many2one('utm.source', 'Source', related="sale_order_id.source_id", store=True) medium_id = fields.Many2one('utm.medium', 'Medium', related="sale_order_id.medium_id", store=True) - @api.onchange('event_id') - def _onchange_event_id(self): - # We reset the ticket when keeping it would lead to an inconstitent state. - if self.event_ticket_id and (not self.event_id or self.event_id != self.event_ticket_id.event_id): - self.event_ticket_id = None - - @api.constrains('event_ticket_id', 'state') - def _check_ticket_seats_limit(self): - for record in self: - if record.event_ticket_id.seats_max and record.event_ticket_id.seats_available < 0: - raise ValidationError(_('No more available seats for this ticket')) - def _check_auto_confirmation(self): res = super(EventRegistration, self)._check_auto_confirmation() if res: diff --git a/addons/event_sale/models/event_ticket.py b/addons/event_sale/models/event_ticket.py index 06711ddd2a1..beaa73a7090 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -1,145 +1,95 @@ # -*- 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 +import logging + +from odoo import api, fields, models + +_logger = logging.getLogger(__name__) -class EventTicket(models.Model): - _name = 'event.event.ticket' - _description = 'Event Ticket' +class EventTemplateTicket(models.Model): + _inherit = 'event.type.ticket' def _default_product_id(self): return self.env.ref('event_sale.product_product_event', raise_if_not_found=False) - name = fields.Char(string='Name', required=True, translate=True) - event_type_id = fields.Many2one('event.type', string='Event Category', ondelete='cascade') - event_id = fields.Many2one('event.event', string="Event", ondelete='cascade') - company_id = fields.Many2one('res.company', related='event_id.company_id') # product - product_id = fields.Many2one('product.product', string='Product', - required=True, domain=[("event_ok", "=", True)], + product_id = fields.Many2one( + 'product.product', string='Product', required=True, domain=[("event_ok", "=", True)], default=_default_product_id) price = fields.Float(string='Price', digits='Product Price') price_reduce = fields.Float(string="Price Reduce", compute="_compute_price_reduce", digits='Product Price') - price_reduce_taxinc = fields.Float(compute='_get_price_reduce_tax', string='Price Reduce Tax inc') - # sale - start_sale_date = fields.Date(string="Sales Start") - end_sale_date = fields.Date(string="Sales End") - is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired') - sale_available = fields.Boolean(string='Is Available', compute='_compute_sale_available') - registration_ids = fields.One2many('event.registration', 'event_ticket_id', string='Registrations') - # seats fields - seats_availability = fields.Selection([('limited', 'Limited'), ('unlimited', 'Unlimited')], - string='Available Seat', required=True, store=True, compute='_compute_seats', default="limited") - seats_max = fields.Integer(string='Maximum Available Seats', - help="Define the number of available tickets. If you have too much registrations you will " - "not be able to sell tickets anymore. Set 0 to ignore this rule set as unlimited.") - seats_reserved = fields.Integer(string='Reserved Seats', compute='_compute_seats', store=True) - seats_available = fields.Integer(string='Available Seats', compute='_compute_seats', store=True) - seats_unconfirmed = fields.Integer(string='Unconfirmed Seat Reservations', compute='_compute_seats', store=True) - seats_used = fields.Integer(compute='_compute_seats', store=True) - - def _compute_is_expired(self): - for ticket in self: - if ticket.end_sale_date: - current_date = fields.Date.context_today(ticket.with_context(tz=ticket.event_id.date_tz)) - ticket.is_expired = ticket.end_sale_date < current_date - else: - ticket.is_expired = False - - @api.depends('product_id.active', 'start_sale_date', 'end_sale_date') - def _compute_sale_available(self): - for ticket in self: - current_date = fields.Date.context_today(ticket.with_context(tz=ticket.event_id.date_tz)) - if not ticket.product_id.active: - ticket.sale_available = False - elif ticket.start_sale_date and ticket.start_sale_date > current_date: - ticket.sale_available = False - elif ticket.end_sale_date and ticket.end_sale_date < current_date: - ticket.sale_available = False - else: - ticket.sale_available = True + @api.depends('product_id') def _compute_price_reduce(self): for record in self: product = record.product_id discount = product.lst_price and (product.lst_price - product.price) / product.lst_price or 0.0 record.price_reduce = (1.0 - discount) * record.price - def _get_price_reduce_tax(self): - for record in self: - # sudo necessary here since the field is most probably accessed through the website - tax_ids = record.sudo().product_id.taxes_id.filtered(lambda r: r.company_id == record.event_id.company_id) - taxes = tax_ids.compute_all(record.price_reduce, record.event_id.company_id.currency_id, 1.0, product=record.product_id) - record.price_reduce_taxinc = taxes['total_included'] - - @api.depends('seats_max', 'registration_ids.state') - 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_availability = 'unlimited' if ticket.seats_max == 0 else 'limited' - ticket.seats_unconfirmed = ticket.seats_reserved = ticket.seats_used = ticket.seats_available = 0 - # aggregate registrations by ticket and by state - 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') - GROUP BY event_ticket_id, state - """ - self.env['event.registration'].flush(['event_id', 'event_ticket_id', 'state']) - self.env.cr.execute(query, (tuple(self.ids),)) - for event_ticket_id, state, num in self.env.cr.fetchall(): - ticket = self.browse(event_ticket_id) - ticket[state_field[state]] += num - # compute seats_available - for ticket in self: - if ticket.seats_max > 0: - ticket.seats_available = ticket.seats_max - (ticket.seats_reserved + ticket.seats_used) - - @api.constrains('registration_ids', 'seats_max') - def _check_seats_limit(self): - for record in self: - if record.seats_max and record.seats_available < 0: - raise ValidationError(_('No more available seats for this ticket type.')) - - @api.constrains('event_type_id', 'event_id') - def _constrains_event(self): - if any(ticket.event_type_id and ticket.event_id for ticket in self): - raise UserError(_('Ticket cannot belong to both the event category and the event itself.')) + @api.depends('product_id.active') + def _compute_sale_available(self): + inactive_product_tickets = self.filtered(lambda ticket: not ticket.product_id.active) + for ticket in inactive_product_tickets: + ticket.sale_available = False + super(EventTemplateTicket, self - inactive_product_tickets)._compute_sale_available() @api.onchange('product_id') def _onchange_product_id(self): self.price = self.product_id.list_price or 0 - def _get_ticket_multiline_description_sale(self): - """ Compute a multiline description of this ticket, in the context of sales. - It will often be used as the default description of a sales order line referencing this ticket. + def _init_column(self, column_name): + if column_name != "product_id": + return super(EventTemplateTicket, self)._init_column(column_name) - 1. the first line is the ticket name - 2. the second line is the event name (if it exists, which should be the case with a normal workflow) or the product name (if it exists) + # fetch void columns + self.env.cr.execute("SELECT id FROM %s WHERE product_id IS NULL" % self._table) + ticket_type_ids = self.env.cr.fetchall() + if not ticket_type_ids: + return - We decided to ignore entirely the product name and the product description_sale because they are considered to be replaced by the ticket name and event name. - -> the workflow of creating a new event also does not lead to filling them correctly, as the product is created through the event interface - """ + # update existing columns + _logger.debug("Table '%s': setting default value of new column %s to unique values for each row", + self._table, column_name) + default_event_product = self.env.ref('event_sale.product_product_event', raise_if_not_found=False) + if default_event_product: + product_id = default_event_product.id + else: + product_id = self.env['product.product'].create({ + 'name': 'Generic Registration Product', + 'list_price': 0, + 'standard_price': 0, + 'type': 'service', + 'default_code': 'EVENT_REG', + 'type': 'service', + }).id + self.env['ir.model.data'].create({ + 'name': 'product_product_event', + 'module': 'event_sale', + 'model': 'product.product', + 'res_id': product_id, + }) + self.env.cr._obj.execute( + f'UPDATE {self._table} SET product_id = %s WHERE id IN %s;', + (product_id, tuple(ticket_type_ids)) + ) - name = self.display_name + @api.model + def _get_event_ticket_fields_whitelist(self): + """ Add sale specific fields to copy from template to ticket """ + return super(EventTemplateTicket, self)._get_event_ticket_fields_whitelist() + ['product_id', 'price'] - if self.event_id: - name += '\n' + self.event_id.display_name - elif self.product_id: - name += '\n' + self.product_id.display_name - return name +class EventTicket(models.Model): + _inherit = 'event.event.ticket' - @api.constrains('start_sale_date', 'end_sale_date') - def _check_start_sale_date_and_end_sale_date(self): - for ticket in self: - if ticket.start_sale_date and ticket.end_sale_date and ticket.start_sale_date > ticket.end_sale_date: - raise UserError(_('The stop date cannot be earlier than the start date.')) + # product + price_reduce_taxinc = fields.Float(string='Price Reduce Tax inc', compute='_compute_price_reduce_taxinc') + + def _compute_price_reduce_taxinc(self): + for record in self: + # sudo necessary here since the field is most probably accessed through the website + tax_ids = record.sudo().product_id.taxes_id.filtered(lambda r: r.company_id == record.event_id.company_id) + taxes = tax_ids.compute_all(record.price_reduce, record.event_id.company_id.currency_id, 1.0, product=record.product_id) + record.price_reduce_taxinc = taxes['total_included'] diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py index e1ff94f78b6..a46eeee1503 100644 --- a/addons/event_sale/models/sale_order.py +++ b/addons/event_sale/models/sale_order.py @@ -102,7 +102,7 @@ class SaleOrderLine(models.Model): lang=self.order_id.partner_id.lang, ) - return ticket._get_ticket_multiline_description_sale() + self._get_sale_order_line_multiline_description_variants() + return ticket._get_ticket_multiline_description() + self._get_sale_order_line_multiline_description_variants() else: return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product) diff --git a/addons/event_sale/security/ir.model.access.csv b/addons/event_sale/security/ir.model.access.csv index c8ea33e6655..fbbe3fbeb29 100644 --- a/addons/event_sale/security/ir.model.access.csv +++ b/addons/event_sale/security/ir.model.access.csv @@ -1,5 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_event_event_ticket_user,event.event.ticket.user,event_sale.model_event_event_ticket,event.group_event_user,1,0,0,0 -access_event_event_ticket_admin,event.event.ticket.admin,event_sale.model_event_event_ticket,event.group_event_manager,1,1,1,1 access_product_template_event_manager,product.template.event.manager,product.model_product_template,event.group_event_manager,1,1,1,1 access_product_product_event_manager,product.product.event.manager,product.model_product_product,event.group_event_manager,1,1,1,1 diff --git a/addons/event_sale/tests/test_event_internals.py b/addons/event_sale/tests/test_event_internals.py index 9f8539ec6bd..f740fb4643b 100644 --- a/addons/event_sale/tests/test_event_internals.py +++ b/addons/event_sale/tests/test_event_internals.py @@ -2,13 +2,14 @@ # Part of Odoo. See LICENSE file for full copyright and licensing details. from datetime import date, datetime, timedelta +from unittest.mock import patch from odoo.addons.event_sale.tests.common import TestEventSaleCommon -from odoo.fields import Datetime as FieldsDatetime +from odoo.fields import Datetime as FieldsDatetime, Date as FieldsDate from odoo.tests.common import users -class EventSaleTest(TestEventSaleCommon): +class TestEventData(TestEventSaleCommon): @users('user_eventmanager') def test_event_configuration_from_type(self): @@ -17,11 +18,9 @@ class EventSaleTest(TestEventSaleCommon): event_type = self.event_type_complex.with_user(self.env.user) event_type.write({ 'use_mail_schedule': False, - 'use_ticketing': False, + 'use_ticket': False, }) - # Event type does not use tickets but data is kept for compatibility and avoid recreating them - self.assertEqual(len(event_type.event_ticket_ids), 1) - self.assertEqual(event_type.event_ticket_ids.name, 'Registration') + self.assertEqual(event_type.event_type_ticket_ids, self.env['event.type.ticket']) event = self.env['event.event'].create({ 'name': 'Event Update Type', @@ -33,53 +32,112 @@ class EventSaleTest(TestEventSaleCommon): self.assertEqual(event.event_ticket_ids, self.env['event.event.ticket']) event_type.write({ - 'use_ticketing': True, - 'event_ticket_ids': [(5, 0), (0, 0, { + 'use_ticket': True, + 'event_type_ticket_ids': [(5, 0), (0, 0, { 'name': 'First Ticket', 'product_id': self.event_product.id, })] }) - event_type.event_ticket_ids._onchange_product_id() + event_type.event_type_ticket_ids._onchange_product_id() event._onchange_type() self.assertEqual(event.event_ticket_ids.name, 'Registration for %s' % event.name) self.assertEqual(event.event_ticket_ids.product_id, self.event_product) self.assertEqual(event.event_ticket_ids.price, self.event_product.list_price) def test_event_registrable(self): - """Test if `_compute_event_registrations_open` works properly.""" - test_event = self.env['event.event'].create({ - 'name': 'TestEvent', - 'date_begin': datetime.now() - timedelta(days=5), - 'date_end': datetime.now() + timedelta(days=5), - }) - test_event_ticket = self.env['event.event.ticket'].create({ + """Test if `_compute_event_registrations_open` works properly with additional + product active conditions compared to base tests (see event) """ + event = self.event_0.with_user(self.env.user) + self.assertTrue(event.event_registrations_open) + + # ticket without dates boundaries -> ok + ticket = self.env['event.event.ticket'].create({ 'name': 'TestTicket', - 'event_id': test_event.id, - 'product_id': self.env['product.product'].search([], limit=1).id, + 'event_id': event.id, + 'product_id': self.event_product.id, }) + self.assertTrue(event.event_registrations_open) - self.assertEqual(test_event.event_registrations_open, True) + # ticket has inactive product -> ko + ticket.product_id.action_archive() + self.assertFalse(event.event_registrations_open) - test_event.write({ - 'date_begin': datetime.now() - timedelta(days=5), - 'date_end': datetime.now() - timedelta(days=1), + # at least one valid ticket -> ok is back + event_product = self.env['product.product'].create({'name': 'Test Registration Product New',}) + new_ticket = self.env['event.event.ticket'].create({ + 'name': 'TestTicket 2', + 'event_id': event.id, + 'product_id': event_product.id, + 'end_sale_date': datetime.now() + timedelta(days=2), }) + self.assertTrue(new_ticket.sale_available) + self.assertTrue(event.event_registrations_open) - test_event.date_end = datetime.now() - timedelta(days=1) - self.assertEqual(test_event.event_registrations_open, False) - test_event.write({ - 'date_begin': datetime.now() - timedelta(days=1), - 'date_end': datetime.now() + timedelta(days=5), +class TestEventTicketData(TestEventSaleCommon): + + def setUp(self): + super(TestEventTicketData, self).setUp() + self.ticket_date_patcher = patch('odoo.addons.event.models.event_ticket.fields.Date', wraps=FieldsDate) + self.ticket_date_patcher_mock = self.ticket_date_patcher.start() + self.ticket_date_patcher_mock.context_today.return_value = date(2020, 1, 31) + + def tearDown(self): + super(TestEventTicketData, self).tearDown() + self.ticket_date_patcher.stop() + + @users('user_eventmanager') + def test_event_ticket_fields(self): + """ Test event ticket fields synchronization """ + self.event_type_complex.write({ + 'use_ticket': True, + 'event_type_ticket_ids': [ + (5, 0), + (0, 0, { + 'name': 'First Ticket', + 'product_id': self.event_product.id, + 'seats_max': 30, + }), (0, 0, { # limited in time, available (01/10 (start) < 01/31 (today) < 02/10 (end)) + 'name': 'Second Ticket', + 'product_id': self.event_product.id, + 'start_sale_date': date(2020, 1, 10), + 'end_sale_date': date(2020, 2, 10), + }) + ], }) + first_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'First Ticket') + first_ticket._onchange_product_id() + second_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'Second Ticket') + second_ticket._onchange_product_id() + # force second ticket price, after calling the onchange + second_ticket.write({'price': 8.0}) - test_event.write({'event_ticket_ids': [(5, 0)]}) - self.assertEqual(test_event.event_registrations_open, False, 'cannot register if no tickets') + # price coming from product + self.assertEqual(first_ticket.price, self.event_product.list_price) + self.assertEqual(second_ticket.price, 8.0) - test_event_ticket = self.env['event.event.ticket'].create({ - 'name': 'TestTicket', - 'event_id': test_event.id, - 'product_id': self.env['product.product'].search([], limit=1).id, + # default availability + self.assertEqual(first_ticket.seats_availability, 'limited') + self.assertTrue(first_ticket.sale_available) + self.assertFalse(first_ticket.is_expired) + self.assertEqual(second_ticket.seats_availability, 'unlimited') + self.assertTrue(second_ticket.sale_available) + self.assertFalse(second_ticket.is_expired) + + # product archived + self.event_product.action_archive() + self.assertFalse(first_ticket.sale_available) + self.assertFalse(second_ticket.sale_available) + + # sale is ended + self.event_product.action_unarchive() + second_ticket.write({'end_sale_date': date(2020, 1, 20)}) + self.assertFalse(second_ticket.sale_available) + self.assertTrue(second_ticket.is_expired) + # sale has not started + second_ticket.write({ + 'start_sale_date': date(2020, 2, 10), + 'end_sale_date': date(2020, 2, 20), }) - test_event_ticket.product_id.active = False - self.assertEqual(test_event.event_registrations_open, False, 'cannot register if product linked to the tickets are all archived') + self.assertFalse(second_ticket.sale_available) + self.assertFalse(second_ticket.is_expired) diff --git a/addons/event_sale/views/event_registration_views.xml b/addons/event_sale/views/event_registration_views.xml index 79394c37acf..c66daf53e02 100644 --- a/addons/event_sale/views/event_registration_views.xml +++ b/addons/event_sale/views/event_registration_views.xml @@ -1,24 +1,12 @@ - - event.registration.search.inherit - event.registration - - - - - - - - event.registration.tree.inherit event.registration - @@ -41,17 +29,6 @@ event.registration - - - diff --git a/addons/event_sale/views/event_ticket_views.xml b/addons/event_sale/views/event_ticket_views.xml index 80e96a5cc5d..b10579396dc 100644 --- a/addons/event_sale/views/event_ticket_views.xml +++ b/addons/event_sale/views/event_ticket_views.xml @@ -1,106 +1,91 @@ - + + + + + event.type.ticket.view.tree.inherit.sale + event.type.ticket + + + + + + + + + + + event.type.ticket.view.form.inherit.sale + event.type.ticket + + + + + + + + + - event.event.ticket.view.tree.from_event + event.event.ticket.view.tree.from.event.inherit.sale event.event.ticket - + - - - - - + + + + - - - - + - event.event.ticket.view.form.from.event + event.event.ticket.view.form.from.event.inherit.sale event.event.ticket - + -
- - - - - - - - - - - - -
+ + + + + +
event.event.ticket.view.kanban.from.event event.event.ticket - + - + - - - - - - -
-
-
- -
-
-
-
-
- reserved + unconfirmed -
-
- - - + + +
+
+ +
+ - - event.event.ticket.view.form + + event.event.ticket.view.form.inherit.sale event.event.ticket + -
-
-
- - - - - - - - - - - - - - - - - - -
+ + + + + + +
- + + diff --git a/addons/event_sale/views/event_views.xml b/addons/event_sale/views/event_views.xml index e18cbb1ae3b..5d846504792 100644 --- a/addons/event_sale/views/event_views.xml +++ b/addons/event_sale/views/event_views.xml @@ -1,33 +1,5 @@ - - event.type.view.form.inherit.sale - event.type - - -
-
-
- -
-
-
-
-
-
-
event.form.inherit @@ -49,15 +21,7 @@
- - - - - + diff --git a/addons/website_event/security/event_security.xml b/addons/website_event/security/event_security.xml index a30ce1f6c42..0b86e8cf8cc 100644 --- a/addons/website_event/security/event_security.xml +++ b/addons/website_event/security/event_security.xml @@ -11,6 +11,17 @@ + + Event ticket: public/portal may read published + + [('event_id.website_published', '=', True)] + + + + + + + diff --git a/addons/website_event/security/ir.model.access.csv b/addons/website_event/security/ir.model.access.csv index 3b9ee7e6aa4..1024bdc8213 100644 --- a/addons/website_event/security/ir.model.access.csv +++ b/addons/website_event/security/ir.model.access.csv @@ -1,4 +1,6 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink access_event_event_public,event.event.public,event.model_event_event,base.group_public,1,0,0,0 access_event_event_portal,event.event.portal,event.model_event_event,base.group_portal,1,0,0,0 -access_event_type_public,event.type.public,event.model_event_type,,1,0,0,0 \ No newline at end of file +access_event_type_public,event.type.public,event.model_event_type,,1,0,0,0 +access_event_event_ticket_public,event.event.ticket.public,event.model_event_event_ticket,base.group_public,1,0,0,0 +access_event_event_ticket_portal,event.event.ticket.portal,event.model_event_event_ticket,base.group_portal,1,0,0,0 diff --git a/addons/website_event_sale/data/event_data.xml b/addons/website_event_sale/data/event_data.xml index 089c5aa2d24..9df17e43c99 100644 --- a/addons/website_event_sale/data/event_data.xml +++ b/addons/website_event_sale/data/event_data.xml @@ -1,7 +1,7 @@ - + Sell Online diff --git a/addons/website_event_sale/models/sale_order.py b/addons/website_event_sale/models/sale_order.py index 662217167ad..215412e0a9d 100644 --- a/addons/website_event_sale/models/sale_order.py +++ b/addons/website_event_sale/models/sale_order.py @@ -43,7 +43,7 @@ class SaleOrder(models.Model): values['price_unit'] = ticket.price else: values['price_unit'] = ticket.price_reduce - values['name'] = ticket._get_ticket_multiline_description_sale() + values['name'] = ticket._get_ticket_multiline_description() # avoid writing related values that end up locking the product record values.pop('event_ok', None) diff --git a/addons/website_event_sale/security/ir.model.access.csv b/addons/website_event_sale/security/ir.model.access.csv index ec5ea43cd7d..74efdec9621 100644 --- a/addons/website_event_sale/security/ir.model.access.csv +++ b/addons/website_event_sale/security/ir.model.access.csv @@ -1,4 +1,3 @@ id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink -access_event_event_ticket_public,event.event.ticket.public,event_sale.model_event_event_ticket,,1,0,0,0 access_event_product_product_public,event.product.product.public,product.model_product_product,base.group_public,1,0,0,0 -access_event_product_template_public,event.product.template.public,product.model_product_template,base.group_public,1,0,0,0 \ No newline at end of file +access_event_product_template_public,event.product.template.public,product.model_product_template,base.group_public,1,0,0,0 diff --git a/addons/website_event_sale/security/website_event_sale_security.xml b/addons/website_event_sale/security/website_event_sale_security.xml index 662b935cb56..a3b77d8ad05 100644 --- a/addons/website_event_sale/security/website_event_sale_security.xml +++ b/addons/website_event_sale/security/website_event_sale_security.xml @@ -1,16 +1,5 @@ - - event ticket: Public - - [('event_id.website_published', '=', True)] - - - - - - - Product template linked to event: Public From 1a2fcf0add42def532cf407671c11089c3285268 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 22 Jan 2020 12:17:59 +0000 Subject: [PATCH 4/7] [IMP] event(_sale): add a description on event tickets PURPOSE This commit is part of ticket model support directly in event application. SPECIFICATIONS In this commit we add a description on event.type.ticket and event.event.type models. Its purpose is to have a description to use notably in frontend (website_event and website_event_sale). Currently a ticket description is available only in website_event_sale and is the description_sale field of the ticket product. As we now have tickets independent from products, we introduce a real description field on ticket that is either put by hand (event), either coming from the product with event_sale. Field is therefore also copied from event type tickets configuration to its event tickets. Naming on sale order does not take into account ticket description as we consider this fields as being used for front-end mainly. SO line descripiton is now updated as the following * either we have a description_sale on the product, and SO line description is 'product.description_sale \n event.display_name'; * either not and SO line description is 'ticket.name \n event.display_name'; LINKS Task ID 2177281 Community PR #43488 --- addons/event/models/event_ticket.py | 11 +++++------ addons/event/views/event_ticket_views.xml | 4 ++++ addons/event/views/res_config_settings_views.xml | 2 +- addons/event_sale/data/event_sale_data.xml | 1 + addons/event_sale/models/event_ticket.py | 10 ++++++++++ addons/event_sale/tests/common.py | 1 + addons/event_sale/tests/test_event_internals.py | 4 ++++ addons/event_sale/views/event_ticket_views.xml | 8 ++++++-- 8 files changed, 32 insertions(+), 9 deletions(-) diff --git a/addons/event/models/event_ticket.py b/addons/event/models/event_ticket.py index acdc899e3d1..1e82c985084 100644 --- a/addons/event/models/event_ticket.py +++ b/addons/event/models/event_ticket.py @@ -13,6 +13,9 @@ class EventTemplateTicket(models.Model): 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) # sale @@ -66,7 +69,7 @@ class EventTemplateTicket(models.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'] + return ['name', 'description'] class EventTicket(models.Model): @@ -129,11 +132,7 @@ class EventTicket(models.Model): """ Compute a multiline description of this ticket. It is used when ticket description are necessary without having to encode it manually, like sales information. """ - self.ensure_one() - lines = [self.display_name] - if self.event_id: - lines.append(self.event_id.display_name) - return '\n'.join(lines) + return '%s\n%s' % (self.display_name, self.event_id.display_name) def _get_ticket_tz(self): return self.event_id.date_tz or self.env.user.tz diff --git a/addons/event/views/event_ticket_views.xml b/addons/event/views/event_ticket_views.xml index e098b44a7ad..3beb059d6fd 100644 --- a/addons/event/views/event_ticket_views.xml +++ b/addons/event/views/event_ticket_views.xml @@ -9,6 +9,7 @@ + @@ -24,6 +25,7 @@ + @@ -69,6 +71,7 @@ + @@ -88,6 +91,7 @@ + diff --git a/addons/event/views/res_config_settings_views.xml b/addons/event/views/res_config_settings_views.xml index 0e8c5455e8d..8636b17efa3 100644 --- a/addons/event/views/res_config_settings_views.xml +++ b/addons/event/views/res_config_settings_views.xml @@ -34,7 +34,7 @@
diff --git a/addons/event_sale/data/event_sale_data.xml b/addons/event_sale/data/event_sale_data.xml index a7928ded17b..bf8bc78249e 100644 --- a/addons/event_sale/data/event_sale_data.xml +++ b/addons/event_sale/data/event_sale_data.xml @@ -13,6 +13,7 @@ Event Registration + EVENT_REG service diff --git a/addons/event_sale/models/event_ticket.py b/addons/event_sale/models/event_ticket.py index beaa73a7090..c0f3fa2da91 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -38,6 +38,8 @@ class EventTemplateTicket(models.Model): @api.onchange('product_id') def _onchange_product_id(self): self.price = self.product_id.list_price or 0 + if not self.description and self.product_id.description_sale: + self.description = self.product_id.description_sale def _init_column(self, column_name): if column_name != "product_id": @@ -93,3 +95,11 @@ class EventTicket(models.Model): tax_ids = record.sudo().product_id.taxes_id.filtered(lambda r: r.company_id == record.event_id.company_id) taxes = tax_ids.compute_all(record.price_reduce, record.event_id.company_id.currency_id, 1.0, product=record.product_id) record.price_reduce_taxinc = taxes['total_included'] + + def _get_ticket_multiline_description(self): + """ If people set a description on their product it has more priority + than the ticket name itself for the SO description. """ + self.ensure_one() + if self.product_id.description_sale: + return '%s\n%s' % (self.product_id.description_sale, self.event_id.display_name) + return super(EventTicket, self)._get_ticket_multiline_description() diff --git a/addons/event_sale/tests/common.py b/addons/event_sale/tests/common.py index 30f437fd760..698a52c9805 100644 --- a/addons/event_sale/tests/common.py +++ b/addons/event_sale/tests/common.py @@ -12,6 +12,7 @@ class TestEventSaleCommon(TestEventCommon): cls.event_product = cls.env['product.product'].create({ 'name': 'Test Registration Product', + 'description_sale': 'Mighty Description', 'list_price': 10, 'event_ok': True, 'standard_price': 30.0, diff --git a/addons/event_sale/tests/test_event_internals.py b/addons/event_sale/tests/test_event_internals.py index f740fb4643b..9cd0997aa45 100644 --- a/addons/event_sale/tests/test_event_internals.py +++ b/addons/event_sale/tests/test_event_internals.py @@ -39,10 +39,14 @@ class TestEventData(TestEventSaleCommon): })] }) event_type.event_type_ticket_ids._onchange_product_id() + self.assertEqual(event_type.event_type_ticket_ids.description, self.event_product.description_sale) + + # synchronize event event._onchange_type() self.assertEqual(event.event_ticket_ids.name, 'Registration for %s' % event.name) self.assertEqual(event.event_ticket_ids.product_id, self.event_product) self.assertEqual(event.event_ticket_ids.price, self.event_product.list_price) + self.assertEqual(event.event_ticket_ids.description, self.event_product.description_sale) def test_event_registrable(self): """Test if `_compute_event_registrations_open` works properly with additional diff --git a/addons/event_sale/views/event_ticket_views.xml b/addons/event_sale/views/event_ticket_views.xml index b10579396dc..04547b31f3b 100644 --- a/addons/event_sale/views/event_ticket_views.xml +++ b/addons/event_sale/views/event_ticket_views.xml @@ -9,6 +9,8 @@ + + @@ -21,6 +23,8 @@ + + @@ -35,7 +39,7 @@ - + @@ -49,7 +53,7 @@ - + From f8e80cf47d672384d7311572edd4b6e083e4b398 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Thu, 23 Jan 2020 08:11:56 +0000 Subject: [PATCH 5/7] [REF] event: improve ticket type / ticket event fields propagation PURPOSE This commit is part of ticket model support directly in event application. SPECIFICATIONS 1: remove date fields from event.type.ticket Coming from old ticket implementation there are several unused fields on event.type.ticket, notably dates about selling. Indeed tickets defined on event.type are templates and should not be time bound. Previously those fields were available on event.type tickets but not propagated to the event tickets. Now they are not available anymore, simplifying models. We therefore remove them from event.type.ticket model. SPECIFICATIONS 2: propagate seats definition from ticket template to tickets Previously to the global feature this commit is a member of, only ticket name, product and price were copied from event.type to its events. Description has been added in a previous commit. In this commit we also propagate maximum seats availability. Use case is linked to event.type defining a recurring event in a given location. As seats availability is known it can be copied on all events linked to this event type. SPECIFICATIONS 3: improve naming of event tickets Before this commit, all tickets of an event were labeled the same way by default, using simply ````. This is annoying when having several tickets as they had all the same name. In this commit we fix that behavior by setting the name * directly from the ticket template when tickets come from an event.type. Configuring Standard and VIP tickets on an event type will therefore give Standard and VIP tickets on event; * generic 'Registration for ' when adding manually a new ticket for an event; LINKS Task ID 2177281 Community PR #43488 --- addons/event/models/event_event.py | 1 - addons/event/models/event_ticket.py | 71 ++++++++++--------- addons/event/tests/test_event_internals.py | 10 +-- addons/event/views/event_views.xml | 2 +- addons/event_sale/models/event_ticket.py | 14 ++-- .../event_sale/tests/test_event_internals.py | 15 ++-- 6 files changed, 59 insertions(+), 54 deletions(-) diff --git a/addons/event/models/event_event.py b/addons/event/models/event_event.py index 263b3153941..42e55b83e1b 100644 --- a/addons/event/models/event_event.py +++ b/addons/event/models/event_event.py @@ -307,7 +307,6 @@ class EventEvent(models.Model): (attribute_name, ticket[attribute_name] if not isinstance(ticket[attribute_name], models.BaseModel) else ticket[attribute_name].id) for attribute_name in self.env['event.type.ticket']._get_event_ticket_fields_whitelist() ) - ticket_vals['name'] = self.name and _('Registration for %s') % self.name or ticket.name all_ticket_values.append(ticket_vals) self.event_ticket_ids = [(5, 0, 0)] + [(0, 0, item) for item in all_ticket_values] diff --git a/addons/event/models/event_ticket.py b/addons/event/models/event_ticket.py index 1e82c985084..5885602d2b7 100644 --- a/addons/event/models/event_ticket.py +++ b/addons/event/models/event_ticket.py @@ -18,11 +18,6 @@ class EventTemplateTicket(models.Model): 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) - # sale - start_sale_date = fields.Date(string="Registration Start") - end_sale_date = fields.Date(string="Registration End") - is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired') - sale_available = fields.Boolean(string='Is Available', compute='_compute_sale_available') # seats seats_availability = fields.Selection([ ('limited', 'Limited'), ('unlimited', 'Unlimited')], string='Seats Limit', @@ -32,44 +27,16 @@ class EventTemplateTicket(models.Model): 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('end_sale_date', 'event_type_id') - def _compute_is_expired(self): - for ticket in self: - if ticket.end_sale_date: - current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz())) - ticket.is_expired = ticket.end_sale_date < current_date - else: - ticket.is_expired = False - - @api.depends('start_sale_date', 'end_sale_date', 'event_type_id') - def _compute_sale_available(self): - for ticket in self: - current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz())) - if (ticket.start_sale_date and ticket.start_sale_date > current_date) or \ - ticket.end_sale_date and ticket.end_sale_date < current_date: - ticket.sale_available = False - else: - ticket.sale_available = True - @api.depends('seats_max') def _compute_seats_availability(self): for ticket in self: ticket.seats_availability = 'limited' if ticket.seats_max else 'unlimited' - @api.constrains('start_sale_date', 'end_sale_date') - def _constrains_dates_coherency(self): - for ticket in self: - if ticket.start_sale_date and ticket.end_sale_date and ticket.start_sale_date > ticket.end_sale_date: - raise UserError(_('The stop date cannot be earlier than the start date.')) - - def _get_ticket_tz(self): - return self.event_type_id.use_timezone and self.event_type_id.default_timezone or self.env.user.tz - @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'] + return ['name', 'description', 'seats_max'] class EventTicket(models.Model): @@ -81,6 +48,13 @@ class EventTicket(models.Model): _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( @@ -88,6 +62,10 @@ class EventTicket(models.Model): ondelete='cascade', required=True) company_id = fields.Many2one('res.company', related='event_id.company_id') # sale + start_sale_date = fields.Date(string="Registration Start") + end_sale_date = fields.Date(string="Registration End") + is_expired = fields.Boolean(string='Is Expired', compute='_compute_is_expired') + sale_available = fields.Boolean(string='Is Available', compute='_compute_sale_available') registration_ids = fields.One2many('event.registration', 'event_ticket_id', string='Registrations') # seats seats_reserved = fields.Integer(string='Reserved Seats', compute='_compute_seats', store=True) @@ -95,6 +73,25 @@ class EventTicket(models.Model): seats_unconfirmed = fields.Integer(string='Unconfirmed Seats', compute='_compute_seats', store=True) seats_used = fields.Integer(string='Used Seats', compute='_compute_seats', store=True) + @api.depends('end_sale_date', 'event_id.date_tz') + def _compute_is_expired(self): + for ticket in self: + if ticket.end_sale_date: + current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz())) + ticket.is_expired = ticket.end_sale_date < current_date + else: + ticket.is_expired = False + + @api.depends('start_sale_date', 'end_sale_date', 'event_id.date_tz') + def _compute_sale_available(self): + for ticket in self: + current_date = fields.Date.context_today(ticket.with_context(tz=ticket._get_ticket_tz())) + if (ticket.start_sale_date and ticket.start_sale_date > current_date) or \ + ticket.end_sale_date and ticket.end_sale_date < current_date: + ticket.sale_available = False + else: + ticket.sale_available = True + @api.depends('seats_max', 'registration_ids.state') def _compute_seats(self): """ Determine reserved, available, reserved but unconfirmed and used seats. """ @@ -123,6 +120,12 @@ class EventTicket(models.Model): if ticket.seats_max > 0: ticket.seats_available = ticket.seats_max - (ticket.seats_reserved + ticket.seats_used) + @api.constrains('start_sale_date', 'end_sale_date') + def _constrains_dates_coherency(self): + for ticket in self: + if ticket.start_sale_date and ticket.end_sale_date and ticket.start_sale_date > ticket.end_sale_date: + raise UserError(_('The stop date cannot be earlier than the start date.')) + @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): diff --git a/addons/event/tests/test_event_internals.py b/addons/event/tests/test_event_internals.py index 779f2ea3445..f19090f791c 100644 --- a/addons/event/tests/test_event_internals.py +++ b/addons/event/tests/test_event_internals.py @@ -210,9 +210,9 @@ class TestEventTicketData(TestEventCommon): @users('user_eventmanager') def test_event_ticket_fields(self): """ Test event ticket fields synchronization """ - self.event_type_complex.write({ - 'use_ticket': True, - 'event_type_ticket_ids': [ + event = self.event_0.with_user(self.env.user) + event.write({ + 'event_ticket_ids': [ (5, 0), (0, 0, { 'name': 'First Ticket', @@ -224,8 +224,8 @@ class TestEventTicketData(TestEventCommon): }) ], }) - first_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'First Ticket') - second_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'Second Ticket') + 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') self.assertEqual(first_ticket.seats_availability, 'limited') self.assertTrue(first_ticket.sale_available) diff --git a/addons/event/views/event_views.xml b/addons/event/views/event_views.xml index e02bac699c9..2e5f8f77d4f 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -279,7 +279,7 @@ diff --git a/addons/event_sale/models/event_ticket.py b/addons/event_sale/models/event_ticket.py index c0f3fa2da91..e3e666d5db3 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -28,13 +28,6 @@ class EventTemplateTicket(models.Model): discount = product.lst_price and (product.lst_price - product.price) / product.lst_price or 0.0 record.price_reduce = (1.0 - discount) * record.price - @api.depends('product_id.active') - def _compute_sale_available(self): - inactive_product_tickets = self.filtered(lambda ticket: not ticket.product_id.active) - for ticket in inactive_product_tickets: - ticket.sale_available = False - super(EventTemplateTicket, self - inactive_product_tickets)._compute_sale_available() - @api.onchange('product_id') def _onchange_product_id(self): self.price = self.product_id.list_price or 0 @@ -96,6 +89,13 @@ class EventTicket(models.Model): taxes = tax_ids.compute_all(record.price_reduce, record.event_id.company_id.currency_id, 1.0, product=record.product_id) record.price_reduce_taxinc = taxes['total_included'] + @api.depends('product_id.active') + def _compute_sale_available(self): + inactive_product_tickets = self.filtered(lambda ticket: not ticket.product_id.active) + for ticket in inactive_product_tickets: + ticket.sale_available = False + super(EventTicket, self - inactive_product_tickets)._compute_sale_available() + def _get_ticket_multiline_description(self): """ If people set a description on their product it has more priority than the ticket name itself for the SO description. """ diff --git a/addons/event_sale/tests/test_event_internals.py b/addons/event_sale/tests/test_event_internals.py index 9cd0997aa45..d70911b1524 100644 --- a/addons/event_sale/tests/test_event_internals.py +++ b/addons/event_sale/tests/test_event_internals.py @@ -36,6 +36,7 @@ class TestEventData(TestEventSaleCommon): 'event_type_ticket_ids': [(5, 0), (0, 0, { 'name': 'First Ticket', 'product_id': self.event_product.id, + 'seats_max': 5, })] }) event_type.event_type_ticket_ids._onchange_product_id() @@ -43,7 +44,9 @@ class TestEventData(TestEventSaleCommon): # synchronize event event._onchange_type() - self.assertEqual(event.event_ticket_ids.name, 'Registration for %s' % event.name) + self.assertEqual(event.event_ticket_ids.name, event.event_type_id.event_type_ticket_ids.name) + self.assertEqual(event.event_ticket_ids.seats_availability, 'limited') + self.assertEqual(event.event_ticket_ids.seats_max, 5) self.assertEqual(event.event_ticket_ids.product_id, self.event_product) self.assertEqual(event.event_ticket_ids.price, self.event_product.list_price) self.assertEqual(event.event_ticket_ids.description, self.event_product.description_sale) @@ -93,9 +96,9 @@ class TestEventTicketData(TestEventSaleCommon): @users('user_eventmanager') def test_event_ticket_fields(self): """ Test event ticket fields synchronization """ - self.event_type_complex.write({ - 'use_ticket': True, - 'event_type_ticket_ids': [ + event = self.event_0.with_user(self.env.user) + event.write({ + 'event_ticket_ids': [ (5, 0), (0, 0, { 'name': 'First Ticket', @@ -109,9 +112,9 @@ class TestEventTicketData(TestEventSaleCommon): }) ], }) - first_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'First Ticket') + first_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'First Ticket') first_ticket._onchange_product_id() - second_ticket = self.event_type_complex.event_type_ticket_ids.filtered(lambda t: t.name == 'Second Ticket') + second_ticket = event.event_ticket_ids.filtered(lambda t: t.name == 'Second Ticket') second_ticket._onchange_product_id() # force second ticket price, after calling the onchange second_ticket.write({'price': 8.0}) From cb928ed6133bc3d2104fbf586bba5cae2a482524 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Thibault=20Delavall=C3=A9e?= Date: Wed, 22 Jan 2020 10:20:19 +0000 Subject: [PATCH 6/7] [REF][MOV] website_event(_sale): support tickets in website_event frontend PURPOSE Integration between event and eCommerce is required only when users handle the entire selling process online. However users may require ticketing support while managing payments outside of Odoo. Purpose of this commit is to support tickets directly in event application without need of sales. RATIONALE Remove the need to have event_sale installed to manage basic multi ticket event type. Integration with eCommerce is needed only when one wants to handle the entire selling flow online, i.e. order, payment, ... Integration with Sales is needed only when one wants to create sale orders linked to attendees. Many event users do not need all of this. Their attendees pay through bank transfers or they simply manage payments outside of Odoo while still requiring tickets management. SPECIFICATIONS In this commit we move frontend part of ticket support from website_event_sale to website_event. Now eCommerce / event integration adds only payment information when registering. About tickets * if there is no ticket -> generic registration allowed; * if there is one ticket -> quick registration box; * more than one ticket -> unfolding registration box with all available tickets; About price * sale not installed -> no mention of price. A ticket without price is not free. Its description allow to tell how to pay for example; * a price is set: price is displayed; * no price is set: FREE is displayed; Most event frontend templates and controllers are therefore moved from website_event_sale to website_event. Only part about pricing and sale order creation is now located in website_event_sale. Buy flow remains mainly untouched. Indeed this commit is mainly about moving template to support tickets. LINKS Task ID 2177281 Community PR #43488 --- addons/website_event/controllers/main.py | 78 +++++++-- .../static/src/scss/website_event.scss | 6 + .../website_event/views/event_templates.xml | 160 +++++++++++++----- .../controllers/main.py | 4 +- addons/website_event_sale/controllers/main.py | 70 ++++---- .../views/website_event_templates.xml | 141 +++------------ 6 files changed, 239 insertions(+), 220 deletions(-) diff --git a/addons/website_event/controllers/main.py b/addons/website_event/controllers/main.py index d4d8e7f06ca..dfb66f7f834 100644 --- a/addons/website_event/controllers/main.py +++ b/addons/website_event/controllers/main.py @@ -254,25 +254,62 @@ class WebsiteEventController(http.Controller): "url": event.website_url}) return request.env['ir.ui.view'].render_template("website_event.country_events_list", result) - def _process_tickets_details(self, data): - nb_register = int(data.get('nb_register-0', 0)) - if nb_register: - return [{'id': 0, 'name': 'Registration', 'quantity': nb_register, 'price': 0}] - return [] + def _process_tickets_form(self, event, form_details): + """ Process posted data about ticket order. Generic ticket are supported + for event without tickets (generic registration). + + :return: list of order per ticket: [{ + 'id': if of ticket if any (0 if no ticket), + 'ticket': browse record of ticket if any (None if no ticket), + 'name': ticket name (or generic 'Registration' name if no ticket), + 'quantity': number of registrations for that ticket, + }, {...}] + """ + ticket_order = {} + for key, value in form_details.items(): + registration_items = key.split('nb_register-') + if len(registration_items) != 2: + continue + ticket_order[int(registration_items[1])] = int(value) + + ticket_dict = dict((ticket.id, ticket) for ticket in request.env['event.event.ticket'].search([ + ('id', 'in', [tid for tid in ticket_order.keys() if tid]), + ('event_id', '=', event.id) + ])) + + return [{ + 'id': tid if ticket_dict.get(tid) else 0, + 'ticket': ticket_dict.get(tid), + 'name': ticket_dict[tid]['name'] if ticket_dict.get(tid) else _('Registration'), + 'quantity': count, + } for tid, count in ticket_order.items() if count] @http.route(['/event//registration/new'], type='json', auth="public", methods=['POST'], website=True) def registration_new(self, event, **post): - tickets = self._process_tickets_details(post) + tickets = self._process_tickets_form(event, post) if not tickets: return False return request.env['ir.ui.view'].render_template("website_event.registration_attendee_details", {'tickets': tickets, 'event': event}) - def _process_registration_details(self, details): - ''' Process data posted from the attendee details form. ''' + def _process_attendees_form(self, event, form_details): + """ Process data posted from the attendee details form. + + :param details: posted data from frontend registration form, like + {'1-name': 'r', '1-email': 'r@r.com', '1-phone': '', '1-event_ticket_id': '1'} + """ + registration_fields = request.env['event.registration']._fields + registrations = {} global_values = {} - for key, value in details.items(): + for key, value in form_details.items(): counter, field_name = key.split('-', 1) + if field_name not in registration_fields: + continue + elif isinstance(registration_fields[field_name], (fields.Many2one, fields.Integer)): + value = int(value) + else: + value = value + if counter == '0': global_values[field_name] = value else: @@ -282,23 +319,28 @@ class WebsiteEventController(http.Controller): registration[key] = value return list(registrations.values()) + def _create_attendees_from_registration_post(self, event, registration_data): + attendees_sudo = request.env['event.registration'].sudo() + + for registration_values in registration_data: + registration_values['event_id'] = event.id + if not registration_values.get('partner_id'): + registration_values['partner_id'] = request.env.user.partner_id.id + attendees_sudo += request.env['event.registration'].sudo().create(registration_values) + + return attendees_sudo + @http.route(['''/event//registration/confirm'''], type='http', auth="public", methods=['POST'], website=True) def registration_confirm(self, event, **post): if not event.can_access_from_current_website(): raise werkzeug.exceptions.NotFound() - Attendees = request.env['event.registration'] - registrations = self._process_registration_details(post) - - for registration_vals in registrations: - registration_vals['event_id'] = event.id - if not registration_vals.get('partner_id'): - registration_vals['partner_id'] = request.env.user.partner_id.id - Attendees += Attendees.sudo().create(registration_vals) + registrations = self._process_attendees_form(event, post) + attendees = self._create_attendees_from_registration_post(event, registrations) urls = event._get_event_resource_urls() return request.render("website_event.registration_complete", { - 'attendees': Attendees.sudo(), + 'attendees': attendees, 'event': event, 'google_url': urls.get('google_url'), 'iCal_url': urls.get('iCal_url') diff --git a/addons/website_event/static/src/scss/website_event.scss b/addons/website_event/static/src/scss/website_event.scss index 576d0360bb6..ebd7d31ce2b 100644 --- a/addons/website_event/static/src/scss/website_event.scss +++ b/addons/website_event/static/src/scss/website_event.scss @@ -269,3 +269,9 @@ $o-wevent-event-title-sizes-variants: ( } } } + +.modal-open { + > .modal-backdrop { + z-index: $zindex-dropdown - 1; + } +} diff --git a/addons/website_event/views/event_templates.xml b/addons/website_event/views/event_templates.xml index bc08e29530c..52a9e9be649 100644 --- a/addons/website_event/views/event_templates.xml +++ b/addons/website_event/views/event_templates.xml @@ -209,7 +209,18 @@
-
Free
+ + + Sold Out + + + Only Remaining + +
@@ -505,22 +516,109 @@ - -