diff --git a/addons/event/__manifest__.py b/addons/event/__manifest__.py index 5ad98513c17..e4b21a6930c 100644 --- a/addons/event/__manifest__.py +++ b/addons/event/__manifest__.py @@ -21,6 +21,8 @@ Key Features 'data': [ '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', @@ -32,7 +34,9 @@ Key Features 'views/res_partner_views.xml', ], 'demo': [ + 'data/res_users_demo.xml', 'data/event_demo.xml', + 'data/event_registration_demo.xml', ], 'installable': True, 'auto_install': False, 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/data/event_demo.xml b/addons/event/data/event_demo.xml index a8db6afd5d0..3b6385ce406 100644 --- a/addons/event/data/event_demo.xml +++ b/addons/event/data/event_demo.xml @@ -1,18 +1,17 @@ - - - Exhibition + Training + Sport @@ -22,15 +21,40 @@ Design Fair Los Angeles - - - + + + limited - 500 + 50 + US/Pacific + + DesignFairLA + + Free + Free entrance, no food ! + + + + 0 + + + Standard + For only 10, you gain access to catering. Yum yum. + + + 50 + + + VIP + You are truly among the best. + + + 10 + Great Reno Ballon Race @@ -54,6 +78,18 @@ 200 + + Standard + + + 50 + + + VIP + + + 5 + Live Music Festival @@ -66,15 +102,26 @@ + Business workshops - - - + + + limited + 4 + US/Pacific + + + + General Admission + + + 4 + Hockey Tournament @@ -94,13 +141,5 @@ - - - Camptocamp - odoo@camptocamp.com - +41 21 619 10 04 - - - diff --git a/addons/event/data/event_registration_demo.xml b/addons/event/data/event_registration_demo.xml new file mode 100644 index 00000000000..e3b3b8a0ec1 --- /dev/null +++ b/addons/event/data/event_registration_demo.xml @@ -0,0 +1,44 @@ + + + + + + + + + + + + + + + + + Tucker Carlson + tuck@test.example.com + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/addons/event/data/res_users_demo.xml b/addons/event/data/res_users_demo.xml new file mode 100644 index 00000000000..04cd8668c93 --- /dev/null +++ b/addons/event/data/res_users_demo.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file 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..42e55b83e1b 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,18 @@ 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() + ) + 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 3a85bee3e68..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,58 +46,71 @@ 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.')) - 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.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: + 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 +132,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 +165,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/models/event_ticket.py b/addons/event/models/event_ticket.py new file mode 100644 index 00000000000..5885602d2b7 --- /dev/null +++ b/addons/event/models/event_ticket.py @@ -0,0 +1,141 @@ +# -*- 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) + description = fields.Text( + 'Description', translate=True, + help="A description of the ticket that you want to communicate to your customers.") + event_type_id = fields.Many2one( + 'event.type', string='Event Category', ondelete='cascade', required=True) + # seats + seats_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('seats_max') + def _compute_seats_availability(self): + for ticket in self: + ticket.seats_availability = 'limited' if ticket.seats_max else 'unlimited' + + @api.model + def _get_event_ticket_fields_whitelist(self): + """ Whitelist of fields that are copied from event_type_ticket_ids to event_ticket_ids when + changing the event_type_id field of event.event """ + return ['name', 'description', 'seats_max'] + + +class EventTicket(models.Model): + """ Ticket model allowing to have differnt kind of registrations for a given + event. Ticket are based on ticket type as they share some common fields + and behavior. Those models come from <= v13 Odoo event.event.ticket that + modeled both concept: tickets for event templates, and tickets for events. """ + _name = 'event.event.ticket' + _inherit = 'event.type.ticket' + _description = 'Event Ticket' + + @api.model + def default_get(self, fields): + res = super(EventTicket, self).default_get(fields) + if 'name' in fields and (not res.get('name') or res['name'] == _('Registration')) and self.env.context.get('default_event_name'): + res['name'] = _('Registration for %s') % self.env.context['default_event_name'] + return res + + # description + event_type_id = fields.Many2one(ondelete='set null', required=False) + event_id = fields.Many2one( + 'event.event', string="Event", + ondelete='cascade', required=True) + company_id = fields.Many2one('res.company', related='event_id.company_id') + # sale + start_sale_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) + 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('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. """ + # 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('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): + 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. """ + 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/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/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/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..f19090f791c 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 """ + event = self.event_0.with_user(self.env.user) + event.write({ + 'event_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 = 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) + 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_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_ticket_views.xml b/addons/event/views/event_ticket_views.xml new file mode 100644 index 00000000000..3beb059d6fd --- /dev/null +++ b/addons/event/views/event_ticket_views.xml @@ -0,0 +1,182 @@ + + + + + + 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 919c4251c43..2e5f8f77d4f 100644 --- a/addons/event/views/event_views.xml +++ b/addons/event/views/event_views.xml @@ -2,10 +2,6 @@ - - - - event.type.form @@ -111,6 +107,24 @@

Attendees

+
+
+ +
+
+
+
@@ -158,9 +172,9 @@ event.type - - + + + @@ -263,6 +277,13 @@ + + + @@ -348,8 +369,8 @@ -
-
+
+
@@ -475,7 +496,9 @@ - + + + - + + + + @@ -531,6 +557,13 @@ + @@ -643,6 +676,7 @@ + @@ -726,6 +760,8 @@ event.mail - + + + diff --git a/addons/event_sale/data/event_demo.xml b/addons/event_sale/data/event_demo.xml index 6ce4d91dfd6..f11a1dfdeec 100644 --- a/addons/event_sale/data/event_demo.xml +++ b/addons/event_sale/data/event_demo.xml @@ -1,48 +1,31 @@ - - Standard - + + + 0 + + - 1000.0 - 50 - - - VIP - + - 1500.0 - 10 - - General Admission - + + + 1000.0 + + + + 1500.0 + + + - 99.0 - 100 - - Standard - - - - 1000.0 - 50 - - - - VIP - - - - 1500.0 - 5 - diff --git a/addons/event_sale/data/event_sale_data.xml b/addons/event_sale/data/event_sale_data.xml index ce5c21e136e..bf8bc78249e 100644 --- a/addons/event_sale/data/event_sale_data.xml +++ b/addons/event_sale/data/event_sale_data.xml @@ -13,17 +13,11 @@ Event Registration + EVENT_REG 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 bc9c2ae4917..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: @@ -41,29 +27,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/event_ticket.py b/addons/event_sale/models/event_ticket.py index 06711ddd2a1..e3e666d5db3 100644 --- a/addons/event_sale/models/event_ticket.py +++ b/addons/event_sale/models/event_ticket.py @@ -1,145 +1,105 @@ # -*- 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): + @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": + return super(EventTemplateTicket, self)._init_column(column_name) + + # 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 + + # 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)) + ) + + @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'] + + +class EventTicket(models.Model): + _inherit = 'event.event.ticket' + + # 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'] - @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.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() - @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.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. - - 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) - - 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 - """ - - name = self.display_name - - if self.event_id: - name += '\n' + self.event_id.display_name - elif self.product_id: - name += '\n' + self.product_id.display_name - - return name - - @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.')) + 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/models/sale_order.py b/addons/event_sale/models/sale_order.py index 10a8e0a5e2c..a46eeee1503 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') @@ -103,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/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 9f8539ec6bd..d70911b1524 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,119 @@ 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, + 'seats_max': 5, })] }) - event_type.event_ticket_ids._onchange_product_id() + 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.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) 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 """ + event = self.event_0.with_user(self.env.user) + event.write({ + 'event_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 = event.event_ticket_ids.filtered(lambda t: t.name == 'First Ticket') + first_ticket._onchange_product_id() + 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}) - 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..04547b31f3b 100644 --- a/addons/event_sale/views/event_ticket_views.xml +++ b/addons/event_sale/views/event_ticket_views.xml @@ -1,106 +1,95 @@ - + + + + + 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/controllers/main.py b/addons/website_event/controllers/main.py index 15c83004bb2..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,22 +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 in registrations: - registration['event_id'] = event - Attendees += Attendees.sudo().create( - Attendees._prepare_attendee_values(registration)) + 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/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/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 @@ - -