diff options
Diffstat (limited to 'addons/event_sale/models')
| -rw-r--r-- | addons/event_sale/models/__init__.py | 8 | ||||
| -rw-r--r-- | addons/event_sale/models/account_move.py | 16 | ||||
| -rw-r--r-- | addons/event_sale/models/event_event.py | 85 | ||||
| -rw-r--r-- | addons/event_sale/models/event_registration.py | 133 | ||||
| -rw-r--r-- | addons/event_sale/models/event_ticket.py | 122 | ||||
| -rw-r--r-- | addons/event_sale/models/product.py | 27 | ||||
| -rw-r--r-- | addons/event_sale/models/sale_order.py | 160 |
7 files changed, 551 insertions, 0 deletions
diff --git a/addons/event_sale/models/__init__.py b/addons/event_sale/models/__init__.py new file mode 100644 index 00000000..7228262e --- /dev/null +++ b/addons/event_sale/models/__init__.py @@ -0,0 +1,8 @@ +# -*- coding: utf-8 -*- + +from . import account_move +from . import event_event +from . import event_registration +from . import event_ticket +from . import sale_order +from . import product diff --git a/addons/event_sale/models/account_move.py b/addons/event_sale/models/account_move.py new file mode 100644 index 00000000..8c5b3853 --- /dev/null +++ b/addons/event_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 api, 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 confirm attendees. Attendees should indeed not be confirmed before + full payment. """ + res = super(AccountMove, self).action_invoice_paid() + self.mapped('line_ids.sale_line_ids')._update_registrations(confirm=True, mark_as_paid=True) + return res diff --git a/addons/event_sale/models/event_event.py b/addons/event_sale/models/event_event.py new file mode 100644 index 00000000..5af548e1 --- /dev/null +++ b/addons/event_sale/models/event_event.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models + + +class Event(models.Model): + _inherit = 'event.event' + + sale_order_lines_ids = fields.One2many( + 'sale.order.line', 'event_id', + groups='sales_team.group_sale_salesman', + string='All sale order lines pointing to this event') + sale_price_subtotal = fields.Monetary( + string='Sales (Tax Excluded)', compute='_compute_sale_price_subtotal', + groups='sales_team.group_sale_salesman') + currency_id = fields.Many2one( + 'res.currency', string='Currency', + related='company_id.currency_id', readonly=True) + + @api.depends('company_id.currency_id', + 'sale_order_lines_ids.price_subtotal', 'sale_order_lines_ids.currency_id', + 'sale_order_lines_ids.company_id', 'sale_order_lines_ids.order_id.date_order') + def _compute_sale_price_subtotal(self): + """ Takes all the sale.order.lines related to this event and converts amounts + from the currency of the sale order to the currency of the event company. + + To avoid extra overhead, we use conversion rates as of 'today'. + Meaning we have a number that can change over time, but using the conversion rates + at the time of the related sale.order would mean thousands of extra requests as we would + have to do one conversion per sale.order (and a sale.order is created every time + we sell a single event ticket). """ + date_now = fields.Datetime.now() + sale_price_by_event = {} + if self.ids: + event_subtotals = self.env['sale.order.line'].read_group( + [('event_id', 'in', self.ids), + ('price_subtotal', '!=', 0)], + ['event_id', 'currency_id', 'price_subtotal:sum'], + ['event_id', 'currency_id'], + lazy=False + ) + + company_by_event = { + event._origin.id or event.id: event.company_id + for event in self + } + + currency_by_event = { + event._origin.id or event.id: event.currency_id + for event in self + } + + currency_by_id = { + currency.id: currency + for currency in self.env['res.currency'].browse( + [event_subtotal['currency_id'][0] for event_subtotal in event_subtotals] + ) + } + + for event_subtotal in event_subtotals: + price_subtotal = event_subtotal['price_subtotal'] + event_id = event_subtotal['event_id'][0] + currency_id = event_subtotal['currency_id'][0] + sale_price = currency_by_event[event_id]._convert( + price_subtotal, + currency_by_id[currency_id], + company_by_event[event_id], + date_now) + if event_id in sale_price_by_event: + sale_price_by_event[event_id] += sale_price + else: + sale_price_by_event[event_id] = sale_price + + for event in self: + event.sale_price_subtotal = sale_price_by_event.get(event._origin.id or event.id, 0) + + def action_view_linked_orders(self): + """ Redirects to the orders linked to the current events """ + sale_order_action = self.env["ir.actions.actions"]._for_xml_id("sale.action_orders") + sale_order_action.update({ + 'domain': [('state', '!=', 'cancel'), ('order_line.event_id', 'in', self.ids)], + 'context': {'create': 0}, + }) + return sale_order_action diff --git a/addons/event_sale/models/event_registration.py b/addons/event_sale/models/event_registration.py new file mode 100644 index 00000000..1acb6126 --- /dev/null +++ b/addons/event_sale/models/event_registration.py @@ -0,0 +1,133 @@ +# -*- coding: utf-8 -*- +# Part of Odoo. See LICENSE file for full copyright and licensing details. + +from odoo import api, fields, models, _ +from odoo.tools import float_is_zero + + +class EventRegistration(models.Model): + _inherit = 'event.registration' + + is_paid = fields.Boolean('Is Paid') + # TDE FIXME: maybe add an onchange on sale_order_id + sale_order_id = fields.Many2one('sale.order', string='Sales Order', ondelete='cascade', copy=False) + sale_order_line_id = fields.Many2one('sale.order.line', string='Sales Order Line', ondelete='cascade', copy=False) + payment_status = fields.Selection(string="Payment Status", selection=[ + ('to_pay', 'Not Paid'), + ('paid', 'Paid'), + ('free', 'Free'), + ], compute="_compute_payment_status", compute_sudo=True) + utm_campaign_id = fields.Many2one(compute='_compute_utm_campaign_id', readonly=False, store=True) + utm_source_id = fields.Many2one(compute='_compute_utm_source_id', readonly=False, store=True) + utm_medium_id = fields.Many2one(compute='_compute_utm_medium_id', readonly=False, store=True) + + @api.depends('is_paid', 'sale_order_id.currency_id', 'sale_order_line_id.price_total') + def _compute_payment_status(self): + for record in self: + so = record.sale_order_id + so_line = record.sale_order_line_id + if not so or float_is_zero(so_line.price_total, precision_digits=so.currency_id.rounding): + record.payment_status = 'free' + elif record.is_paid: + record.payment_status = 'paid' + else: + record.payment_status = 'to_pay' + + @api.depends('sale_order_id') + def _compute_utm_campaign_id(self): + for registration in self: + if registration.sale_order_id.campaign_id: + registration.utm_campaign_id = registration.sale_order_id.campaign_id + elif not registration.utm_campaign_id: + registration.utm_campaign_id = False + + @api.depends('sale_order_id') + def _compute_utm_source_id(self): + for registration in self: + if registration.sale_order_id.source_id: + registration.utm_source_id = registration.sale_order_id.source_id + elif not registration.utm_source_id: + registration.utm_source_id = False + + @api.depends('sale_order_id') + def _compute_utm_medium_id(self): + for registration in self: + if registration.sale_order_id.medium_id: + registration.utm_medium_id = registration.sale_order_id.medium_id + elif not registration.utm_medium_id: + registration.utm_medium_id = False + + def action_view_sale_order(self): + 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 + + @api.model_create_multi + def create(self, vals_list): + for vals in vals_list: + 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) + registrations = super(EventRegistration, self).create(vals_list) + for registration in registrations: + if registration.sale_order_id: + registration.message_post_with_view( + 'mail.message_origin_link', + values={'self': registration, 'origin': registration.sale_order_id}, + subtype_id=self.env.ref('mail.mt_note').id) + return registrations + + 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) + + if vals.get('event_ticket_id'): + self.filtered( + lambda registration: registration.event_ticket_id and registration.event_ticket_id.id != vals['event_ticket_id'] + )._sale_order_ticket_type_change_notify(self.env['event.event.ticket'].browse(vals['event_ticket_id'])) + + 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, + 'sale_order_id': so_line.order_id.id, + 'sale_order_line_id': so_line.id, + } + return {} + + def _sale_order_ticket_type_change_notify(self, new_event_ticket): + fallback_user_id = self.env.user.id if not self.env.user._is_public() else self.env.ref("base.user_admin").id + for registration in self: + render_context = { + 'registration': registration, + 'old_ticket_name': registration.event_ticket_id.name, + 'new_ticket_name': new_event_ticket.name + } + user_id = registration.event_id.user_id.id or registration.sale_order_id.user_id.id or fallback_user_id + registration.sale_order_id._activity_schedule_with_view( + 'mail.mail_activity_data_warning', + user_id=user_id, + views_or_xmlid='event_sale.event_ticket_id_change_exception', + render_context=render_context) + + def _action_set_paid(self): + self.write({'is_paid': True}) + + def _get_registration_summary(self): + res = super(EventRegistration, self)._get_registration_summary() + res.update({ + 'payment_status': self.payment_status, + 'payment_status_value': dict(self._fields['payment_status']._description_selection(self.env))[self.payment_status], + 'has_to_pay': not self.is_paid, + }) + return res diff --git a/addons/event_sale/models/event_ticket.py b/addons/event_sale/models/event_ticket.py new file mode 100644 index 00000000..da142f97 --- /dev/null +++ b/addons/event_sale/models/event_ticket.py @@ -0,0 +1,122 @@ +# -*- 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 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) + + description = fields.Text(compute='_compute_description', readonly=False, store=True) + # product + product_id = fields.Many2one( + 'product.product', string='Product', required=True, + domain=[("event_ok", "=", 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') + + @api.depends('product_id') + def _compute_price(self): + for ticket in self: + if ticket.product_id and ticket.product_id.lst_price: + ticket.price = ticket.product_id.lst_price or 0 + elif not ticket.price: + ticket.price = 0 + + @api.depends('product_id') + def _compute_description(self): + for ticket in self: + if ticket.product_id and ticket.product_id.description_sale: + ticket.description = ticket.product_id.description_sale + # initialize, i.e for embedded tree views + if not ticket.description: + ticket.description = False + + @api.depends('product_id', 'price') + def _compute_price_reduce(self): + for ticket in self: + product = ticket.product_id + discount = (product.lst_price - product.price) / product.lst_price if product.lst_price else 0.0 + ticket.price_reduce = (1.0 - discount) * ticket.price + + 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', + }).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' + _order = "event_id, price" + + # product + price_reduce_taxinc = fields.Float( + string='Price Reduce Tax inc', compute='_compute_price_reduce_taxinc', + compute_sudo=True) + + def _compute_price_reduce_taxinc(self): + for event in self: + # sudo necessary here since the field is most probably accessed through the website + tax_ids = event.product_id.taxes_id.filtered(lambda r: r.company_id == event.event_id.company_id) + taxes = tax_ids.compute_all(event.price_reduce, event.event_id.company_id.currency_id, 1.0, product=event.product_id) + event.price_reduce_taxinc = taxes['total_included'] + + @api.depends('product_id.active') + def _compute_sale_available(self): + inactive_product_tickets = self.filtered(lambda ticket: not ticket.product_id.active) + for ticket in inactive_product_tickets: + ticket.sale_available = False + super(EventTicket, self - inactive_product_tickets)._compute_sale_available() + + def _get_ticket_multiline_description(self): + """ If people set a description on their product it has more priority + than the ticket name itself for the SO description. """ + 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/product.py b/addons/event_sale/models/product.py new file mode 100644 index 00000000..a8980466 --- /dev/null +++ b/addons/event_sale/models/product.py @@ -0,0 +1,27 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models + + +class ProductTemplate(models.Model): + _inherit = 'product.template' + + event_ok = fields.Boolean(string='Is an Event Ticket', help="If checked this product automatically " + "creates an event registration at the sales order confirmation.") + + @api.onchange('event_ok') + def _onchange_event_ok(self): + if self.event_ok: + self.type = 'service' + + +class Product(models.Model): + _inherit = 'product.product' + + event_ticket_ids = fields.One2many('event.event.ticket', 'product_id', string='Event Tickets') + + @api.onchange('event_ok') + def _onchange_event_ok(self): + """ Redirection, inheritance mechanism hides the method on the model """ + if self.event_ok: + self.type = 'service' diff --git a/addons/event_sale/models/sale_order.py b/addons/event_sale/models/sale_order.py new file mode 100644 index 00000000..bf3aca6c --- /dev/null +++ b/addons/event_sale/models/sale_order.py @@ -0,0 +1,160 @@ +# -*- coding: utf-8 -*- + +from odoo import api, fields, models, _ + + +class SaleOrder(models.Model): + _inherit = "sale.order" + + attendee_count = fields.Integer('Attendee Count', compute='_compute_attendee_count') + + def write(self, vals): + """ Synchronize partner from SO to registrations. This is done notably + in website_sale controller shop/address that updates customer, but not + only. """ + result = super(SaleOrder, self).write(vals) + if vals.get('partner_id'): + registrations_toupdate = self.env['event.registration'].search([('sale_order_id', 'in', self.ids)]) + registrations_toupdate.write({'partner_id': vals['partner_id']}) + return result + + def action_confirm(self): + res = super(SaleOrder, self).action_confirm() + for so in self: + # confirm registration if it was free (otherwise it will be confirmed once invoice fully paid) + so.order_line._update_registrations(confirm=so.amount_total == 0, cancel_to_draft=False) + if any(line.event_id for line in so.order_line): + return self.env['ir.actions.act_window'] \ + .with_context(default_sale_order_id=so.id) \ + ._for_xml_id('event_sale.action_sale_order_event_registration') + return res + + def action_cancel(self): + self.order_line._cancel_associated_registrations() + return super(SaleOrder, self).action_cancel() + + def action_view_attendee_list(self): + action = self.env["ir.actions.actions"]._for_xml_id("event.event_registration_action_tree") + action['domain'] = [('sale_order_id', 'in', self.ids)] + return action + + def _compute_attendee_count(self): + sale_orders_data = self.env['event.registration'].read_group( + [('sale_order_id', 'in', self.ids)], + ['sale_order_id'], ['sale_order_id'] + ) + attendee_count_data = { + sale_order_data['sale_order_id'][0]: + sale_order_data['sale_order_id_count'] + for sale_order_data in sale_orders_data + } + for sale_order in self: + sale_order.attendee_count = attendee_count_data.get(sale_order.id, 0) + + def unlink(self): + self.mapped('order_line')._unlink_associated_registrations() + return super(SaleOrder, self).unlink() + + +class SaleOrderLine(models.Model): + + _inherit = 'sale.order.line' + + event_id = fields.Many2one( + 'event.event', string='Event', + help="Choose an event and it will automatically create a registration for this event.") + event_ticket_id = fields.Many2one( + 'event.event.ticket', string='Event Ticket', + help="Choose an event ticket and it will automatically create a registration for this event ticket.") + event_ok = fields.Boolean(related='product_id.event_ok', readonly=True) + + @api.depends('state', 'event_id') + def _compute_product_uom_readonly(self): + event_lines = self.filtered(lambda line: line.event_id) + event_lines.update({'product_uom_readonly': True}) + super(SaleOrderLine, self - event_lines)._compute_product_uom_readonly() + + def _update_registrations(self, confirm=True, cancel_to_draft=False, registration_data=None, mark_as_paid=False): + """ Create or update registrations linked to a sales order line. A sale + order line has a product_uom_qty attribute that will be the number of + registrations linked to this line. This method update existing registrations + and create new one for missing one. """ + RegistrationSudo = self.env['event.registration'].sudo() + registrations = RegistrationSudo.search([('sale_order_line_id', 'in', self.ids)]) + registrations_vals = [] + for so_line in self.filtered('event_id'): + existing_registrations = registrations.filtered(lambda self: self.sale_order_line_id.id == so_line.id) + if confirm: + existing_registrations.filtered(lambda self: self.state not in ['open', 'cancel']).action_confirm() + if mark_as_paid: + existing_registrations.filtered(lambda self: not self.is_paid)._action_set_paid() + if cancel_to_draft: + existing_registrations.filtered(lambda self: self.state == 'cancel').action_set_draft() + + for count in range(int(so_line.product_uom_qty) - len(existing_registrations)): + values = { + 'sale_order_line_id': so_line.id, + 'sale_order_id': so_line.order_id.id + } + # TDE CHECK: auto confirmation + if registration_data: + values.update(registration_data.pop()) + registrations_vals.append(values) + + if registrations_vals: + RegistrationSudo.create(registrations_vals) + return True + + @api.onchange('product_id') + def _onchange_product_id(self): + # We reset the event when keeping it would lead to an inconstitent state. + # We need to do it this way because the only relation between the product and the event is through the corresponding tickets. + if self.event_id and (not self.product_id or self.product_id.id not in self.event_id.mapped('event_ticket_ids.product_id.id')): + self.event_id = None + + @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.onchange('product_uom', 'product_uom_qty') + def product_uom_change(self): + if not self.event_ticket_id: + super(SaleOrderLine, self).product_uom_change() + + @api.onchange('event_ticket_id') + def _onchange_event_ticket_id(self): + # we call this to force update the default name + self.product_id_change() + + def unlink(self): + self._unlink_associated_registrations() + return super(SaleOrderLine, self).unlink() + + def _cancel_associated_registrations(self): + self.env['event.registration'].search([('sale_order_line_id', 'in', self.ids)]).action_cancel() + + def _unlink_associated_registrations(self): + self.env['event.registration'].search([('sale_order_line_id', 'in', self.ids)]).unlink() + + def get_sale_order_line_multiline_description_sale(self, product): + """ We override this method because we decided that: + The default description of a sales order line containing a ticket must be different than the default description when no ticket is present. + So in that case we use the description computed from the ticket, instead of the description computed from the product. + We need this override to be defined here in sales order line (and not in product) because here is the only place where the event_ticket_id is referenced. + """ + if self.event_ticket_id: + ticket = self.event_ticket_id.with_context( + lang=self.order_id.partner_id.lang, + ) + + 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) + + def _get_display_price(self, product): + if self.event_ticket_id and self.event_id: + return self.event_ticket_id.with_context(pricelist=self.order_id.pricelist_id.id, uom=self.product_uom.id).price_reduce + else: + return super()._get_display_price(product) |
