diff --git a/addons/event_booth_sale/__init__.py b/addons/event_booth_sale/__init__.py
new file mode 100644
index 00000000000..2ae6446f9dc
--- /dev/null
+++ b/addons/event_booth_sale/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import models
+from . import wizard
diff --git a/addons/event_booth_sale/__manifest__.py b/addons/event_booth_sale/__manifest__.py
new file mode 100644
index 00000000000..dc61102cce1
--- /dev/null
+++ b/addons/event_booth_sale/__manifest__.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+{
+ 'name': "Events Booths Sales",
+ 'category': 'Marketing/Events',
+ 'version': '1.0',
+ 'summary': "Manage event booths sale",
+ 'description': """
+Sell your event booths and track payments on sale orders.
+ """,
+ 'depends': ['event_booth', 'event_sale'],
+ 'data': [
+ 'security/ir.model.access.csv',
+ 'data/product_data.xml',
+ 'data/event_booth_category_data.xml',
+ 'views/product_views.xml',
+ 'views/sale_order_views.xml',
+ 'views/event_type_booth_views.xml',
+ 'views/event_booth_category_views.xml',
+ 'views/event_booth_registration_views.xml',
+ 'views/event_booth_views.xml',
+ 'wizard/event_booth_configurator_views.xml',
+ ],
+ 'auto_install': True,
+ 'assets': {
+ 'web.assets_backend': [
+ 'event_booth_sale/static/src/js/event_booth_configurator_form_controller.js',
+ 'event_booth_sale/static/src/js/event_booth_configurator_form_view.js',
+ 'event_booth_sale/static/src/js/event_booth_configurator_widget.js',
+ ]
+ },
+ 'license': 'LGPL-3',
+}
diff --git a/addons/event_booth_sale/data/event_booth_category_data.xml b/addons/event_booth_sale/data/event_booth_category_data.xml
new file mode 100644
index 00000000000..dcbd5a75570
--- /dev/null
+++ b/addons/event_booth_sale/data/event_booth_category_data.xml
@@ -0,0 +1,19 @@
+
+
+
+
+
+ 100
+
+
+
+
+ 500
+
+
+
+
+ 1000
+
+
+
diff --git a/addons/event_booth_sale/data/product_data.xml b/addons/event_booth_sale/data/product_data.xml
new file mode 100644
index 00000000000..2a5f6befe94
--- /dev/null
+++ b/addons/event_booth_sale/data/product_data.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ Event Booth
+
+
+ 100.0
+
+
+ order
+ service
+
+
+
diff --git a/addons/event_booth_sale/models/__init__.py b/addons/event_booth_sale/models/__init__.py
new file mode 100644
index 00000000000..a5fe40c3823
--- /dev/null
+++ b/addons/event_booth_sale/models/__init__.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import account_move
+from . import product
+from . import event_booth_registration
+from . import event_booth
+from . import event_booth_category
+from . import event_type_booth
+from . import sale_order
+from . import sale_order_line
diff --git a/addons/event_booth_sale/models/account_move.py b/addons/event_booth_sale/models/account_move.py
new file mode 100644
index 00000000000..b3d7f10bdf1
--- /dev/null
+++ b/addons/event_booth_sale/models/account_move.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import models
+
+
+class AccountMove(models.Model):
+ _inherit = 'account.move'
+
+ def action_invoice_paid(self):
+ """ When an invoice linked to a sales order selling registrations is
+ paid, update booths accordingly as they are booked when invoice is paid.
+ """
+ res = super(AccountMove, self).action_invoice_paid()
+ self.mapped('line_ids.sale_line_ids')._update_event_booths(set_paid=True)
+ return res
diff --git a/addons/event_booth_sale/models/event_booth.py b/addons/event_booth_sale/models/event_booth.py
new file mode 100644
index 00000000000..e03df7942a4
--- /dev/null
+++ b/addons/event_booth_sale/models/event_booth.py
@@ -0,0 +1,49 @@
+# -*- 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 UserError
+
+
+class EventBooth(models.Model):
+ _inherit = 'event.booth'
+
+ # registrations
+ event_booth_registration_ids = fields.One2many('event.booth.registration', 'event_booth_id')
+ # sale information
+ sale_order_line_registration_ids = fields.Many2many(
+ 'sale.order.line', 'event_booth_registration',
+ 'event_booth_id', 'sale_order_line_id', string='SO Lines with reservations',
+ groups='sales_team.group_sale_salesman', copy=False)
+ sale_order_line_id = fields.Many2one(
+ 'sale.order.line', string='Final Sale Order Line', ondelete='set null',
+ readonly=True, states={'available': [('readonly', False)]},
+ groups='sales_team.group_sale_salesman', copy=False)
+ sale_order_id = fields.Many2one(
+ related='sale_order_line_id.order_id', store='True', readonly=True,
+ groups='sales_team.group_sale_salesman')
+ is_paid = fields.Boolean('Is Paid', copy=False)
+
+ @api.ondelete(at_uninstall=False)
+ def _unlink_except_linked_sale_order(self):
+ booth_with_so = self.filtered('sale_order_id')
+ if booth_with_so:
+ raise UserError(_(
+ 'You can\'t delete the following booths as they are linked to sales orders: '
+ '%(booths)s', booths=', '.join(booth_with_so.mapped('name'))))
+
+ def action_set_paid(self):
+ self.write({'is_paid': True})
+
+ def action_view_sale_order(self):
+ self.sale_order_id.ensure_one()
+ action = self.env['ir.actions.actions']._for_xml_id('sale.action_orders')
+ action['views'] = [(False, 'form')]
+ action['res_id'] = self.sale_order_id.id
+ return action
+
+ def _get_booth_multiline_description(self):
+ return '%s : \n%s' % (
+ self.event_id.display_name,
+ '\n'.join(['- %s' % booth.name for booth in self])
+ )
diff --git a/addons/event_booth_sale/models/event_booth_category.py b/addons/event_booth_sale/models/event_booth_category.py
new file mode 100644
index 00000000000..44fc51b9e83
--- /dev/null
+++ b/addons/event_booth_sale/models/event_booth_category.py
@@ -0,0 +1,84 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+import logging
+
+from odoo import api, fields, models
+
+_logger = logging.getLogger(__name__)
+
+
+class EventBoothCategory(models.Model):
+ _inherit = 'event.booth.category'
+
+ def _default_product_id(self):
+ return self.env.ref('event_booth_sale.product_product_event_booth', raise_if_not_found=False)
+
+ product_id = fields.Many2one(
+ 'product.product', string='Product', required=True,
+ domain=[('is_event_booth', '=', True)], default=_default_product_id)
+ price = fields.Float(string='Price', compute='_compute_price', digits='Product Price', readonly=False, store=True)
+ price_reduce = fields.Float(
+ string='Price Reduce', compute='_compute_price_reduce',
+ compute_sudo=True, digits='Product Price')
+ image_1920 = fields.Image(compute='_compute_image_1920', readonly=False, store=True)
+
+ @api.depends('product_id')
+ def _compute_image_1920(self):
+ for category in self:
+ category.image_1920 = category.image_1920 if category.image_1920 else category.product_id.image_1920
+
+ @api.depends('product_id')
+ def _compute_price(self):
+ """ By default price comes from category but can be changed by event
+ people as product may be shared accross various categories. """
+ for category in self:
+ if category.product_id and category.product_id.list_price:
+ category.price = category.product_id.list_price + category.product_id.price_extra
+
+ @api.depends('product_id', 'price')
+ def _compute_price_reduce(self):
+ for category in self:
+ product = category.product_id
+ list_price = product.list_price + product.price_extra
+ discount = (list_price - product.price) / list_price if list_price else 0.0
+ category.price_reduce = (1.0 - discount) * category.price
+
+ def _init_column(self, column_name):
+ """ Initialize product_id for existing columns when installing sale
+ bridge, to ensure required attribute is fulfilled. """
+ if column_name != "product_id":
+ return super(EventBoothCategory, self)._init_column(column_name)
+
+ # fetch void columns
+ self.env.cr.execute("SELECT id FROM %s WHERE product_id IS NULL" % self._table)
+ booth_category_ids = self.env.cr.fetchall()
+ if not booth_category_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_booth_product = self._default_product_id()
+ if default_booth_product:
+ product_id = default_booth_product.id
+ else:
+ product_id = self.env['product.product'].create({
+ 'name': 'Generic Event Booth Product',
+ 'categ_id': self.env.ref('event_sale.product_category_events').id,
+ 'list_price': 100,
+ 'standard_price': 0,
+ 'is_event_booth': True,
+ 'type': 'service',
+ 'invoice_policy': 'order',
+ }).id
+ self.env['ir.model.data'].create({
+ 'name': 'product_product_event_booth',
+ 'module': 'event_booth_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(booth_category_ids))
+ )
diff --git a/addons/event_booth_sale/models/event_booth_registration.py b/addons/event_booth_sale/models/event_booth_registration.py
new file mode 100644
index 00000000000..a08a4bfc845
--- /dev/null
+++ b/addons/event_booth_sale/models/event_booth_registration.py
@@ -0,0 +1,78 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models, _
+
+
+class EventBoothRegistration(models.Model):
+ """event.booth.registrations are used to allow multiple partners to book the same booth.
+ Whenever a partner has paid his registration all the others linked to the booth will be deleted."""
+
+ _name = 'event.booth.registration'
+ _description = 'Event Booth Registration'
+
+ sale_order_line_id = fields.Many2one('sale.order.line', string='Sale Order Line', required=True)
+ event_booth_id = fields.Many2one('event.booth', string='Booth', required=True)
+ partner_id = fields.Many2one(
+ 'res.partner', related='sale_order_line_id.order_partner_id', store=True)
+ contact_name = fields.Char(string='Contact Name', compute='_compute_contact_name', readonly=False, store=True)
+ contact_email = fields.Char(string='Contact Email', compute='_compute_contact_email', readonly=False, store=True)
+ contact_phone = fields.Char(string='Contact Phone', compute='_compute_contact_phone', readonly=False, store=True)
+ contact_mobile = fields.Char(string='Contact Mobile', compute='_compute_contact_mobile', readonly=False, store=True)
+
+ _sql_constraints = [('unique_registration', 'unique(sale_order_line_id, event_booth_id)',
+ 'There can be only one registration for a booth by sale order line')]
+
+ @api.depends('partner_id')
+ def _compute_contact_name(self):
+ for registration in self:
+ if not registration.contact_name:
+ registration.contact_name = registration.partner_id.name or False
+
+ @api.depends('partner_id')
+ def _compute_contact_email(self):
+ for registration in self:
+ if not registration.contact_email:
+ registration.contact_email = registration.partner_id.email or False
+
+ @api.depends('partner_id')
+ def _compute_contact_phone(self):
+ for registration in self:
+ if not registration.contact_phone:
+ registration.contact_phone = registration.partner_id.phone or False
+
+ @api.depends('partner_id')
+ def _compute_contact_mobile(self):
+ for registration in self:
+ if not registration.contact_mobile:
+ registration.contact_mobile = registration.partner_id.mobile or False
+
+ @api.model
+ def _get_fields_for_booth_confirmation(self):
+ return ['sale_order_line_id', 'partner_id', 'contact_name', 'contact_email', 'contact_phone', 'contact_mobile']
+
+ def action_confirm(self):
+ for registration in self:
+ values = {
+ field: registration[field].id if isinstance(registration[field], models.BaseModel) else registration[field]
+ for field in self._get_fields_for_booth_confirmation()
+ }
+ registration.event_booth_id.action_confirm(values)
+ self._cancel_pending_registrations()
+
+ def _cancel_pending_registrations(self):
+ body = '
%(message)s:
' % {
+ 'message': _('Your order has been cancelled because the following booths have been reserved'),
+ 'booth_names': ''.join('%s' % booth.display_name for booth in self.event_booth_id)
+ }
+ other_registrations = self.search([
+ ('event_booth_id', 'in', self.event_booth_id.ids),
+ ('id', 'not in', self.ids)
+ ])
+ for order in other_registrations.sale_order_line_id.order_id:
+ order.sudo().message_post(
+ body=body,
+ partner_ids=order.user_id.partner_id.ids,
+ )
+ order.sudo().action_cancel()
+ other_registrations.unlink()
diff --git a/addons/event_booth_sale/models/event_type_booth.py b/addons/event_booth_sale/models/event_type_booth.py
new file mode 100644
index 00000000000..c5318e29611
--- /dev/null
+++ b/addons/event_booth_sale/models/event_type_booth.py
@@ -0,0 +1,16 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class EventTypeBooth(models.Model):
+ _inherit = 'event.type.booth'
+
+ product_id = fields.Many2one(related='booth_category_id.product_id')
+ price = fields.Float(related='booth_category_id.price')
+
+ @api.model
+ def _get_event_booth_fields_whitelist(self):
+ res = super(EventTypeBooth, self)._get_event_booth_fields_whitelist()
+ return res + ['product_id', 'price']
diff --git a/addons/event_booth_sale/models/product.py b/addons/event_booth_sale/models/product.py
new file mode 100644
index 00000000000..62f4a8ac0b1
--- /dev/null
+++ b/addons/event_booth_sale/models/product.py
@@ -0,0 +1,26 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class ProductTemplate(models.Model):
+ _inherit = 'product.template'
+
+ is_event_booth = fields.Boolean(string='Is an Event Booth')
+
+ @api.onchange('is_event_booth')
+ def _onchange_is_event_booth(self):
+ if self.is_event_booth:
+ self.type = 'service'
+ self.invoice_policy = 'order'
+
+
+class Product(models.Model):
+ _inherit = 'product.product'
+
+ @api.onchange('is_event_booth')
+ def _onchange_is_event_booth(self):
+ if self.is_event_booth:
+ self.type = 'service'
+ self.invoice_policy = 'order'
diff --git a/addons/event_booth_sale/models/sale_order.py b/addons/event_booth_sale/models/sale_order.py
new file mode 100644
index 00000000000..1569533272c
--- /dev/null
+++ b/addons/event_booth_sale/models/sale_order.py
@@ -0,0 +1,35 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, fields, models
+
+
+class SaleOrder(models.Model):
+ _inherit = 'sale.order'
+
+ event_booth_ids = fields.One2many('event.booth', 'sale_order_id', string='Booths')
+ event_booth_count = fields.Integer(string='Booth Count', compute='_compute_event_booth_count')
+
+ @api.depends('event_booth_ids')
+ def _compute_event_booth_count(self):
+ if self.ids:
+ slot_data = self.env['event.booth'].read_group(
+ [('sale_order_id', 'in', self.ids)],
+ ['sale_order_id'], ['sale_order_id']
+ )
+ slot_mapped = dict((data['sale_order_id'][0], data['sale_order_id_count']) for data in slot_data)
+ else:
+ slot_mapped = dict()
+ for so in self:
+ so.event_booth_count = slot_mapped.get(so.id, 0)
+
+ def action_confirm(self):
+ res = super(SaleOrder, self).action_confirm()
+ for so in self:
+ so.order_line._update_event_booths()
+ return res
+
+ def action_view_booth_list(self):
+ action = self.env['ir.actions.act_window']._for_xml_id('event_booth.event_booth_action')
+ action['domain'] = [('sale_order_id', 'in', self.ids)]
+ return action
diff --git a/addons/event_booth_sale/models/sale_order_line.py b/addons/event_booth_sale/models/sale_order_line.py
new file mode 100644
index 00000000000..9255c61d2ea
--- /dev/null
+++ b/addons/event_booth_sale/models/sale_order_line.py
@@ -0,0 +1,97 @@
+# -*- 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
+
+
+class SaleOrderLine(models.Model):
+ _inherit = 'sale.order.line'
+
+ event_booth_category_id = fields.Many2one('event.booth.category', string='Booths Category', ondelete='set null')
+ event_booth_pending_ids = fields.Many2many(
+ 'event.booth', string='Pending Booths', search='_search_event_booth_pending_ids',
+ compute='_compute_event_booth_pending_ids', inverse='_inverse_event_booth_pending_ids',
+ help='Used to create registration when providing the desired event booth.'
+ )
+ event_booth_registration_ids = fields.One2many(
+ 'event.booth.registration', 'sale_order_line_id', string='Confirmed Registration')
+ event_booth_ids = fields.One2many('event.booth', 'sale_order_line_id', string='Confirmed Booths')
+ is_event_booth = fields.Boolean(related='product_id.is_event_booth', readonly=True)
+
+ @api.depends('event_booth_ids')
+ def _compute_name_short(self):
+ wbooth = self.filtered(lambda line: line.event_booth_pending_ids)
+ for record in wbooth:
+ record.name_short = record.event_booth_pending_ids.event_id.name
+ super(SaleOrderLine, self - wbooth)._compute_name_short()
+
+ @api.depends('event_booth_registration_ids')
+ def _compute_event_booth_pending_ids(self):
+ for so_line in self:
+ so_line.event_booth_pending_ids = so_line.event_booth_registration_ids.event_booth_id
+
+ def _inverse_event_booth_pending_ids(self):
+ for so_line in self:
+ self.env['event.booth.registration'].create([{
+ 'event_booth_id': booth.id,
+ 'sale_order_line_id': so_line.id,
+ 'partner_id': so_line.order_id.partner_id.id
+ } for booth in so_line.event_booth_pending_ids])
+
+ def _search_event_booth_pending_ids(self, operator, value):
+ return [('event_booth_registration_ids.event_booth_id', operator, value)]
+
+ @api.constrains('event_booth_registration_ids')
+ def _check_event_booth_registration_ids(self):
+ if len(self.event_booth_registration_ids.event_booth_id.event_id) > 1:
+ raise ValidationError(_('Registrations from the same Order Line must belong to a single event.'))
+
+ @api.onchange('product_id')
+ def _onchange_product_id_booth(self):
+ """We reset the event when the selected product doesn't belong to any pending booths."""
+ if self.event_id and (not self.product_id or self.product_id not in self.event_booth_pending_ids.product_id):
+ self.event_id = None
+
+ @api.onchange('event_id')
+ def _onchange_event_id_booth(self):
+ """We reset the pending booths when the event changes to avoid inconsistent state."""
+ if self.event_booth_pending_ids and (not self.event_id or self.event_id != self.event_booth_pending_ids.event_id):
+ self.event_booth_pending_ids = None
+
+ @api.onchange('event_booth_pending_ids')
+ def _onchange_event_booth_pending_ids(self):
+ # trigger the description computation
+ self.product_id_change()
+
+ def _update_event_booths(self, set_paid=False):
+ for so_line in self.filtered('is_event_booth'):
+ if so_line.event_booth_pending_ids and not so_line.event_booth_ids:
+ unavailable = so_line.event_booth_pending_ids.filtered(lambda booth: not booth.is_available)
+ if unavailable:
+ raise ValidationError(
+ _('The following booths are unavailable, please remove them to continue : %(booth_names)s',
+ booth_names=''.join('\n\t- %s' % booth.display_name for booth in unavailable)))
+ so_line.event_booth_registration_ids.action_confirm()
+ if so_line.event_booth_ids and set_paid:
+ so_line.event_booth_ids.action_set_paid()
+ return True
+
+ def get_sale_order_line_multiline_description_sale(self, product):
+ if self.event_booth_pending_ids:
+ booths = self.event_booth_pending_ids.with_context(
+ lang=self.order_id.partner_id.lang,
+ )
+ return booths._get_booth_multiline_description()
+ return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
+
+ def _get_display_price(self, product):
+ if self.event_booth_pending_ids and self.event_id:
+ company = self.event_id.company_id or self.env.company
+ currency = company.currency_id
+ total_price = sum([booth.price for booth in self.event_booth_pending_ids])
+ return currency._convert(
+ total_price, self.order_id.currency_id,
+ self.order_id.company_id or self.env.company.id,
+ self.order_id.date_order or fields.Date.today())
+ return super(SaleOrderLine, self)._get_display_price(product)
diff --git a/addons/event_booth_sale/security/ir.model.access.csv b/addons/event_booth_sale/security/ir.model.access.csv
new file mode 100644
index 00000000000..e375afb8eb3
--- /dev/null
+++ b/addons/event_booth_sale/security/ir.model.access.csv
@@ -0,0 +1,5 @@
+id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
+access_event_booth_registration_salesman,access.event.booth.registration.salesman,model_event_booth_registration,sales_team.group_sale_salesman,1,1,1,1
+access_event_booth_registration_event_desk,access.event.booth.registration.event.desk,model_event_booth_registration,event.group_event_registration_desk,1,0,0,0
+access_event_booth_registration_event_user,access.event.booth.registration.event.user,model_event_booth_registration,event.group_event_user,1,1,1,1
+access_event_booth_configurator_salesman,access.event.booth.configurator.salesman,model_event_booth_configurator,sales_team.group_sale_salesman,1,1,1,0
diff --git a/addons/event_booth_sale/static/src/js/event_booth_configurator_form_controller.js b/addons/event_booth_sale/static/src/js/event_booth_configurator_form_controller.js
new file mode 100644
index 00000000000..393fc7fdc9a
--- /dev/null
+++ b/addons/event_booth_sale/static/src/js/event_booth_configurator_form_controller.js
@@ -0,0 +1,44 @@
+odoo.define('event_booth_sale.event_booth_configurator_form_controller', function (require) {
+'use strict';
+
+const FormController = require('web.FormController');
+/**
+ * This controller is overridden to allow configuring sale_order_lines through a popup
+ * window when a product with 'is_event_booth' is selected.
+ *
+ * This allows keeping an editable list view for sales order and remove the noise of
+ * those 3 fields ('event_id', 'event_booth_category_id' and 'event_booth_ids')
+ */
+const EventBoothConfiguratorFormController = FormController.extend({
+
+ //--------------------------------------------------------------------------
+ // Overrides
+ //--------------------------------------------------------------------------
+
+ saveRecord: function () {
+ const self = this;
+ return this._super.apply(this, arguments).then(function () {
+ const state = self.model.get(self.handle, {raw: true});
+ self.do_action({
+ type: 'ir.actions.act_window_close',
+ infos: {
+ eventBoothConfiguration: {
+ event_id: {id: state.data.event_id},
+ event_booth_category_id: {id: state.data.event_booth_category_id},
+ event_booth_pending_ids: {
+ operation: 'MULTI',
+ commands: [{
+ operation: 'REPLACE_WITH',
+ ids: state.data.event_booth_ids,
+ }],
+ }
+ }
+ }
+ });
+ });
+ }
+});
+
+return EventBoothConfiguratorFormController;
+
+});
diff --git a/addons/event_booth_sale/static/src/js/event_booth_configurator_form_view.js b/addons/event_booth_sale/static/src/js/event_booth_configurator_form_view.js
new file mode 100644
index 00000000000..b1f7d849cbf
--- /dev/null
+++ b/addons/event_booth_sale/static/src/js/event_booth_configurator_form_view.js
@@ -0,0 +1,21 @@
+odoo.define('event_booth_sale.event_booth_configurator_form_view', function (require) {
+'use strict';
+
+const EventBoothConfiguratorFormController = require('event_booth_sale.event_booth_configurator_form_controller');
+const FormView = require('web.FormView');
+const viewRegistry = require('web.view_registry');
+
+/**
+ * @see EventBoothConfiguratorFormController for more information.
+ */
+const EventBoothConfiguratorFormView = FormView.extend({
+ config: _.extend({}, FormView.prototype.config, {
+ Controller: EventBoothConfiguratorFormController,
+ }),
+});
+
+viewRegistry.add('event_booth_configurator_form', EventBoothConfiguratorFormView);
+
+return EventBoothConfiguratorFormView;
+
+});
diff --git a/addons/event_booth_sale/static/src/js/event_booth_configurator_widget.js b/addons/event_booth_sale/static/src/js/event_booth_configurator_widget.js
new file mode 100644
index 00000000000..ce6595a740b
--- /dev/null
+++ b/addons/event_booth_sale/static/src/js/event_booth_configurator_widget.js
@@ -0,0 +1,149 @@
+odoo.define('event_booth_sale.product_configurator', function (require) {
+const ProductConfiguratorWidget = require('sale.product_configurator');
+
+/**
+ * Extension of the ProductConfiguratorWidget to support event booth configuration.
+ * It opens when an event booth product_product is set.
+ *
+ * The event booth information include:
+ * - event_id
+ * - event_booth_category_id
+ * - event_booth_ids
+ *
+ */
+ProductConfiguratorWidget.include({
+
+ //--------------------------------------------------------------------------
+ // Overrides
+ //--------------------------------------------------------------------------
+
+ /**
+ * @returns {boolean}
+ *
+ * @override
+ * @private
+ */
+ _isConfigurableLine: function () {
+ return this.recordData.is_event_booth || this._super.apply(this, arguments);
+ },
+
+ /**
+ * Opens the event booth configurator in 'edit' mode
+ *
+ * @override
+ * @private
+ */
+ _onEditLineConfiguration: function () {
+ if (this.recordData.is_event_booth) {
+ const defaultValues = {
+ default_product_id: this.recordData.product_id.data.id,
+ default_sale_order_line_id: this.recordData.id || null
+ };
+ if (this.recordData.event_id) {
+ defaultValues.default_event_id = this.recordData.event_id.data.id;
+ }
+ if (this.recordData.event_booth_category_id) {
+ defaultValues.default_event_booth_category_id = this.recordData.event_booth_category_id.data.id;
+ }
+ if (this.recordData.event_booth_pending_ids) {
+ defaultValues.default_event_booth_ids = this.recordData.event_booth_pending_ids.res_ids;
+ }
+ this._openEventBoothConfigurator(defaultValues, this.dataPointID);
+ } else {
+ this._super.apply(this, arguments);
+ }
+ },
+
+ /**
+ * @param {integer} productId: product.product ID
+ * @param {String} dataPointId
+ * @returns {Promise} stopPropagation true if a suitable configurator has been found.
+ *
+ * @override
+ * @private
+ */
+ _onProductChange: function (productId, dataPointId) {
+ return this._super.apply(this, arguments).then((stopPropagation) => {
+ if (stopPropagation) {
+ return Promise.resolve(true);
+ } else {
+ return this._isProductEventBooth(productId, dataPointId);
+ }
+ });
+ },
+
+ //--------------------------------------------------------------------------
+ // Private
+ //-------------------------------------------------------------------------
+
+ /**
+ * This method will check if the productId needs a configuration or not.
+ *
+ * @param {integer} productId: product.product ID
+ * @param {String} dataPointId
+ * @returns {Promise} stopPropagation true if the product is an event booth.
+ *
+ * @private
+ */
+ _isProductEventBooth: function (productId, dataPointId) {
+ const self = this;
+ return this._rpc({
+ model: 'product.product',
+ method: 'read',
+ args: [productId, ['is_event_booth']],
+ }).then(function (result) {
+ if (result && result[0].is_event_booth) {
+ self._openEventBoothConfigurator({
+ default_product_id: productId
+ }, dataPointId);
+ return Promise.resolve(true);
+ }
+ return Promise.resolve(false);
+ });
+ },
+
+ /**
+ * Opens the event booth configurator allowing to configure the SO line with its informations.
+ *
+ * When the window is closed, configured values are used to trigger a 'field_changed'
+ * event to modify the current SO line.
+ *
+ * If the window is closed without providing the required values 'event_id', 'event_booth_category_id'
+ * and 'event_booth_pending_ids', the product_id field is cleaned.
+ *
+ * @param {Object} additionalContext: various "default_" values added to context when calling
+ * configuration wizard model;
+ * @param dataPointId
+ *
+ * @private
+ */
+ _openEventBoothConfigurator: function (additionalContext, dataPointId) {
+ const self = this;
+ this.do_action('event_booth_sale.event_booth_configurator_action', {
+ additional_context: additionalContext,
+ on_close: function (result) {
+ if (result && !result.special) {
+ self.trigger_up('field_changed', {
+ changes: result.eventBoothConfiguration,
+ dataPointID: dataPointId,
+ onSuccess: function () {
+ self._onLineConfigured();
+ }
+ });
+ } else if (!self.recordData.event_id || !self.recordData.event_booth_pending_ids) {
+ self.trigger_up('field_changed', {
+ changes: {
+ product_id: false,
+ name: '',
+ },
+ dataPointID: dataPointId,
+ });
+ }
+ }
+ });
+ },
+});
+
+return ProductConfiguratorWidget;
+
+});
diff --git a/addons/event_booth_sale/tests/__init__.py b/addons/event_booth_sale/tests/__init__.py
new file mode 100644
index 00000000000..93a5521bfb0
--- /dev/null
+++ b/addons/event_booth_sale/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import test_event_internals
diff --git a/addons/event_booth_sale/tests/common.py b/addons/event_booth_sale/tests/common.py
new file mode 100644
index 00000000000..15930921621
--- /dev/null
+++ b/addons/event_booth_sale/tests/common.py
@@ -0,0 +1,24 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo.addons.event_booth.tests.common import TestEventBoothCommon
+from odoo.addons.event_sale.tests.common import TestEventSaleCommon
+
+
+class TestEventBoothSaleCommon(TestEventBoothCommon, TestEventSaleCommon):
+
+ @classmethod
+ def setUpClass(cls):
+ super(TestEventBoothSaleCommon, cls).setUpClass()
+
+ cls.event_booth_product = cls.env['product.product'].create({
+ 'name': 'Test Booth Product',
+ 'description_sale': 'Mighty Booth Description',
+ 'list_price': 20,
+ 'is_event_booth': True,
+ 'standard_price': 60.0,
+ 'type': 'service',
+ })
+ (cls.event_booth_category_1 + cls.event_booth_category_2).write({
+ 'product_id': cls.event_booth_product.id,
+ })
diff --git a/addons/event_booth_sale/tests/test_event_internals.py b/addons/event_booth_sale/tests/test_event_internals.py
new file mode 100644
index 00000000000..e745e2eafff
--- /dev/null
+++ b/addons/event_booth_sale/tests/test_event_internals.py
@@ -0,0 +1,95 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from datetime import datetime, timedelta
+
+from odoo import Command
+from odoo.addons.event_booth_sale.tests.common import TestEventBoothSaleCommon
+from odoo.fields import Datetime as FieldsDatetime
+from odoo.tests.common import users, Form
+
+
+class TestEventData(TestEventBoothSaleCommon):
+
+ @users('user_eventmanager')
+ def test_event_configuration_booths_from_type(self):
+ """ Test data computation (related to booths) of event coming from its event.type template. """
+ # setup test records
+ event_type_nobooth = self.env['event.type'].create({
+ 'name': 'No booth',
+ })
+ event_type_wbooths = self.env['event.type'].create({
+ 'name': 'Using booths',
+ 'event_type_booth_ids': [
+ Command.clear(),
+ Command.create({
+ 'name': 'Standard Booth',
+ 'booth_category_id': self.event_booth_category_1.id,
+ }),
+ Command.create({
+ 'name': 'Premium Booth',
+ 'booth_category_id': self.event_booth_category_2.id,
+ })
+ ]
+ })
+
+ # no booth by default as no booths on type
+ event = self.env['event.event'].create({
+ 'name': 'Event',
+ 'date_begin': FieldsDatetime.to_string(datetime.today() + timedelta(days=1)),
+ 'date_end': FieldsDatetime.to_string(datetime.today() + timedelta(days=15)),
+ 'event_type_id': event_type_nobooth.id
+ })
+ self.assertEqual(event.event_booth_ids, self.env['event.booth'])
+
+ # manually create booths: ok
+ event.write({
+ 'event_booth_ids': [
+ Command.create({
+ 'name': 'Custom Standard Booth 1',
+ 'booth_category_id': self.event_booth_category_1.id,
+ }),
+ Command.create({
+ 'name': 'Custom Standard Booth 2',
+ 'booth_category_id': self.event_booth_category_1.id,
+ })
+ ]
+ })
+ self.assertEqual(event.event_booth_count, 2)
+ self.assertEqual(event.event_booth_count_available, 2)
+ self.assertEqual(event.event_booth_ids.product_id, self.event_booth_product)
+
+ # updating partner is independent from availability
+ event.event_booth_ids[1].write({'partner_id': self.event_customer.id})
+ self.assertEqual(event.event_booth_count, 2)
+ self.assertEqual(event.event_booth_count_available, 2)
+ self.assertEqual(event.event_booth_ids[1].message_partner_ids, self.event_customer)
+
+ # one booth is sold
+ event.event_booth_ids[1].write({'state': 'unavailable'})
+ self.assertEqual(event.event_booth_count, 2)
+ self.assertEqual(event.event_booth_count_available, 1)
+
+ # partner is reset: booth still unavailable but follower removed
+ event.event_booth_ids[1].write({'partner_id': False})
+ self.assertEqual(event.event_booth_count, 2)
+ self.assertEqual(event.event_booth_count_available, 1)
+ self.assertEqual(event.event_booth_ids[1].message_partner_ids, self.env['res.partner'])
+
+ # add group or the test will fail
+ self.user_eventmanager.write({'groups_id': [
+ (4, self.env.ref('sales_team.group_sale_salesman').id),
+ ]})
+
+ # change event type to one using booths: include event type booths and keep reserved booths
+ event_form = Form(event)
+ event_form.event_type_id = event_type_wbooths
+ self.assertEqual(event_form.event_booth_count, 3)
+ self.assertEqual(
+ set(r['name'] for r in event_form.event_booth_ids._records),
+ set(('Custom Standard Booth 2', 'Standard Booth', 'Premium Booth')),
+ 'Should keep booths with reservation, remove unused ones and add type ones'
+ )
+ event_form.save()
+ self.assertEqual(event.event_booth_count_available, 2)
+ self.assertEqual(event.event_booth_category_ids, self.event_booth_category_1 + self.event_booth_category_2)
diff --git a/addons/event_booth_sale/views/event_booth_category_views.xml b/addons/event_booth_sale/views/event_booth_category_views.xml
new file mode 100644
index 00000000000..5c88fa178d5
--- /dev/null
+++ b/addons/event_booth_sale/views/event_booth_category_views.xml
@@ -0,0 +1,32 @@
+
+
+
+
+ event.booth.category.view.form.inherit.sale
+ event.booth.category
+
+
+
+
+
+
+
+
+
+
+
+
+
+ event.booth.category.view.tree.inherit.sale
+ event.booth.category
+
+ 3
+
+
+
+
+
+
+
+
+
diff --git a/addons/event_booth_sale/views/event_booth_registration_views.xml b/addons/event_booth_sale/views/event_booth_registration_views.xml
new file mode 100644
index 00000000000..84c5d6948fa
--- /dev/null
+++ b/addons/event_booth_sale/views/event_booth_registration_views.xml
@@ -0,0 +1,46 @@
+
+
+
+
+ event.booth.registration.view.form
+ event.booth.registration
+
+
+
+
+
+
+ event.booth.registration.view.tree
+ event.booth.registration
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/event_booth_sale/views/event_booth_views.xml b/addons/event_booth_sale/views/event_booth_views.xml
new file mode 100644
index 00000000000..a1f47fcfcf7
--- /dev/null
+++ b/addons/event_booth_sale/views/event_booth_views.xml
@@ -0,0 +1,63 @@
+
+
+
+
+ event.booth.view.form.inherit.sale
+ event.booth
+
+ 10
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ event.booth.view.tree.inherit.sale
+ event.booth
+
+
+
+
+
+
+
+
+
+ event.booth.view.search.inherit.sale
+ event.booth
+
+
+
+
+
+
+
+
+
diff --git a/addons/event_booth_sale/views/event_type_booth_views.xml b/addons/event_booth_sale/views/event_type_booth_views.xml
new file mode 100644
index 00000000000..00a20ed3fa6
--- /dev/null
+++ b/addons/event_booth_sale/views/event_type_booth_views.xml
@@ -0,0 +1,28 @@
+
+
+
+
+ event.booth.view.form.from.type.inherit.sale
+ event.type.booth
+
+
+
+
+
+
+
+
+
+
+ event.booth.view.tree.from.type.inherit.sale
+ event.type.booth
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/event_booth_sale/views/product_views.xml b/addons/event_booth_sale/views/product_views.xml
new file mode 100644
index 00000000000..4eb911c4725
--- /dev/null
+++ b/addons/event_booth_sale/views/product_views.xml
@@ -0,0 +1,15 @@
+
+
+
+
+ product.template.view.form.inherit.event.booth.sale
+ product.template
+
+
+
+
+
+
+
+
+
diff --git a/addons/event_booth_sale/views/sale_order_views.xml b/addons/event_booth_sale/views/sale_order_views.xml
new file mode 100644
index 00000000000..2137ec9b6c1
--- /dev/null
+++ b/addons/event_booth_sale/views/sale_order_views.xml
@@ -0,0 +1,27 @@
+
+
+
+
+ sale.order.view.form.inherit.event.booth.sale
+ sale.order
+
+
+
+
+
+
+
+
+
+
+
+
+ {'readonly': [('is_event_booth', '=', True)]}
+
+
+
+
+
diff --git a/addons/event_booth_sale/wizard/__init__.py b/addons/event_booth_sale/wizard/__init__.py
new file mode 100644
index 00000000000..c17705e493b
--- /dev/null
+++ b/addons/event_booth_sale/wizard/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import event_booth_configurator
diff --git a/addons/event_booth_sale/wizard/event_booth_configurator.py b/addons/event_booth_sale/wizard/event_booth_configurator.py
new file mode 100644
index 00000000000..c53986305ce
--- /dev/null
+++ b/addons/event_booth_sale/wizard/event_booth_configurator.py
@@ -0,0 +1,34 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from odoo import api, models, fields, _
+from odoo.exceptions import ValidationError
+
+
+class EventBoothConfigurator(models.TransientModel):
+ _name = 'event.booth.configurator'
+ _description = 'Event Booth Configurator'
+
+ product_id = fields.Many2one('product.product', string='Product', readonly=True)
+ sale_order_line_id = fields.Many2one('sale.order.line', string='Sale Order Line', readonly=True)
+ event_id = fields.Many2one('event.event', string='Event', required=True)
+ event_booth_category_available_ids = fields.Many2many(related='event_id.event_booth_category_available_ids', readonly=True)
+ event_booth_category_id = fields.Many2one(
+ 'event.booth.category', string='Booth Category', required=True,
+ compute='_compute_event_booth_category_id', readonly=False, store=True)
+ event_booth_ids = fields.Many2many(
+ 'event.booth', string='Booth', required=True,
+ compute='_compute_event_booth_ids', readonly=False, store=True)
+
+ @api.depends('event_id')
+ def _compute_event_booth_category_id(self):
+ self.event_booth_category_id = False
+
+ @api.depends('event_id', 'event_booth_category_id')
+ def _compute_event_booth_ids(self):
+ self.event_booth_ids = False
+
+ @api.constrains('event_booth_ids')
+ def _check_if_no_booth_ids(self):
+ if any(not wizard.event_booth_ids for wizard in self):
+ raise ValidationError(_('You have to select at least one booth.'))
diff --git a/addons/event_booth_sale/wizard/event_booth_configurator_views.xml b/addons/event_booth_sale/wizard/event_booth_configurator_views.xml
new file mode 100644
index 00000000000..0dee2313286
--- /dev/null
+++ b/addons/event_booth_sale/wizard/event_booth_configurator_views.xml
@@ -0,0 +1,44 @@
+
+
+
+
+ event.booth.configurator.view.form
+ event.booth.configurator
+
+
+
+
+
+
+ Configure an event booth
+ event.booth.configurator
+ form
+ new
+
+
+