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 + + +