[IMP] various: add event and ticket name on sale order and invoice line

In event_sale it is possible to have one product representing several tickets. But before this commit, on the sale order and invoice the client only saw the name of the product and didn't know which event/ticket they subscribed to.

The goal of this commit is to overwrite that behavior whenever a sale order or invoice line is related to an event ticket, to show the ticket name and event name instead of the product name.

Technically, the following changes have been done:

----------------------------------------

* various: add a method to compute a default name for a SaleOrderLine, based on its product

I added the method because the code to compute the name was duplicated in several places. This will make it more consistent.

Moreover this gives the option for modules to override the method to define a custom name as necessary.

----------------------------------------

* event_sale: compute a consistent description for a SaleOrderLine based on an EventTicket

The description will be the same whether the sale order comes from the front end or the back end. It takes into account the customer language.

The default description will be computed as follow:

1. The ticket name should be the first line of the product description on the SO.
2. The event name should be the second line of the product description on the SO.

To compute the ticket multi-line description we decided to ignore entirely the product name and the product description_sale because they are considered to be replaced by the ticket name and event name.

We also ignore them because the work-flow of creating a new event does not lead to filling them correctly, as the product is created through the event interface.

----------------------------------------

* event_sale: improve sale order line and event registration views

It was possible to select a ticket related to a product before an event was selected.
This is typically the case for tickets defined in an event_type but not in an event.

The ticket field is now hidden until both the product and the event have been selected.
This way we are sure the selected ticket will belong to an event and not an event_type.

We reset the ticket on event change because we don't want the user to be able to select a ticket belonging to the wrong event.

We also improve here the comments of the related on-change methods.

----------------------------------------

* website_event_sale: update views to show ticket name and event name

The standard behavior was to show the product name in several places instead of the sale order line description.

However, for event tickets, we don't want to show the product name, but the ticket name and event name instead.

----------------------------------------

* website_event_sale: add ticket on confirmation page

The ticket name was missing from the registration confirmation page (shown when the ticket is free and/or the user paid with a direct method). This is now fixed.

----------------------------------------

Task: 1837987
PR: 25819
This commit is contained in:
Sébastien Theys
2018-08-14 16:51:16 +02:00
parent c99eb1b8fb
commit fd3febf7d7
14 changed files with 231 additions and 42 deletions
+1 -1
View File
@@ -468,7 +468,7 @@
<field name="email"/>
</group>
<group string="Event Information" name="event">
<field name="event_id" attrs="{'readonly': [('state', '!=', 'draft')]}"/>
<field name="event_id" attrs="{'readonly': [('state', '!=', 'draft')]}" options="{'no_open': True, 'no_create': True}"/>
<field name="date_open" groups="base.group_no_one"/>
<field name="date_closed" groups="base.group_no_one"/>
</group>
+27 -1
View File
@@ -160,17 +160,43 @@ class EventTicket(models.Model):
def _onchange_product_id(self):
self.price = self.product_id.list_price or 0
def get_ticket_multiline_description_sale(self):
""" Compute a multiline description of this ticket, in the context of sales.
It will often be used as the default description of a sales order line referencing this ticket.
1. the first line is the ticket name
2. the second line is the event name (if it exists, which should be the case with a normal workflow) or the product name (if it exists)
We decided to ignore entirely the product name and the product description_sale because they are considered to be replaced by the ticket name and event name.
-> the workflow of creating a new event also does not lead to filling them correctly, as the product is created through the event interface
"""
name = self.display_name
if self.event_id:
name += '\n' + self.event_id.display_name
elif self.product_id:
name += '\n' + self.product_id.display_name
return name
class EventRegistration(models.Model):
_inherit = 'event.registration'
event_ticket_id = fields.Many2one('event.event.ticket', string='Event Ticket')
event_ticket_id = fields.Many2one('event.event.ticket', string='Event Ticket', readonly=True, states={'draft': [('readonly', False)]})
# in addition to origin generic fields, add real relational fields to correctly
# handle attendees linked to sales orders and their lines
# TDE FIXME: maybe add an onchange on sale_order_id + origin
sale_order_id = fields.Many2one('sale.order', string='Source Sales Order', ondelete='cascade')
sale_order_line_id = fields.Many2one('sale.order.line', string='Sales Order Line', ondelete='cascade')
@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.multi
@api.constrains('event_ticket_id', 'state')
def _check_ticket_seats_limit(self):
+31 -8
View File
@@ -27,14 +27,6 @@ class SaleOrderLine(models.Model):
"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.multi
def _prepare_invoice_line(self, qty):
self.ensure_one()
res = super(SaleOrderLine, self)._prepare_invoice_line(qty)
if self.event_id:
res['name'] = '%s: %s' % (res.get('name', ''), self.event_id.name)
return res
@api.multi
def _update_registrations(self, confirm=True, cancel_to_draft=False, registration_data=None):
""" Create or update registrations linked to a sales order line. A sale
@@ -60,9 +52,40 @@ class SaleOrderLine(models.Model):
Registration._prepare_attendee_values(registration))
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('event_ticket_id')
def _onchange_event_ticket_id(self):
company = self.event_id.company_id or self.env.user.company_id
currency = company.currency_id
self.price_unit = currency._convert(
self.event_ticket_id.price, self.order_id.currency_id, self.order_id.company_id, self.order_id.date_order or fields.Date.today())
# we call this to force update the default name
self.product_id_change()
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_sale()
else:
return super(SaleOrderLine, self).get_sale_order_line_multiline_description_sale(product)
+8 -1
View File
@@ -25,6 +25,12 @@ class EventSaleTest(common.TransactionCase):
'date_begin': '2012-01-01 18:05:15'
})
ticket = self.env['event.event.ticket'].create({
'name': 'test_ticket',
'product_id': product.id,
'event_id': event.id,
})
# I create a sales order
self.sale_order = self.env['sale.order'].create({
'partner_id': self.env.ref('base.res_partner_2').id,
@@ -40,7 +46,8 @@ class EventSaleTest(common.TransactionCase):
'product_uom_qty': 8.0,
'order_id': self.sale_order.id,
'name': 'sales order line',
'event_id': event.id
'event_id': event.id,
'event_ticket_id': ticket.id,
})
# In the event registration I add some attendee detail lines. i choose event product
+9 -1
View File
@@ -29,7 +29,15 @@
<field name="inherit_id" ref="event.view_event_registration_form" />
<field name="arch" type="xml">
<field name="event_id" position="after">
<field name="event_ticket_id" domain="[('event_id', '=', event_id)]"/>
<field
name="event_ticket_id"
domain="[
('event_id', '=', event_id),
'|', ('seats_availability', '=', 'unlimited'), ('seats_available', '>', 0)
]"
attrs="{'invisible': [('event_id', '=', False)]}"
options="{'no_open': True, 'no_create': True}"
/>
</field>
<group name="event" position="after">
<group string="Origin">
+22 -2
View File
@@ -6,8 +6,28 @@
<field name="inherit_id" ref="sale.view_order_form" />
<field name="arch" type="xml">
<xpath expr="//field[@name='order_line']//form//field[@name='product_id']" position="after">
<field name="event_id" domain="[('event_ticket_ids.product_id','=', product_id),('date_end','&gt;=',time.strftime('%Y-%m-%d 00:00:00'))]" attrs="{'invisible': [('event_ok', '=', False)],'required': [('event_ok', '!=', False)]}"/>
<field name="event_ticket_id" domain="[('event_id', '=', event_id), ('product_id','=',product_id), '|', ('seats_availability', '=', 'unlimited'), ('seats_available', '>', 0)]" attrs="{'invisible': [('event_ok', '=', False)], 'required': [('event_ok', '!=', False)]}"/>
<field
name="event_id"
domain="[
('event_ticket_ids.product_id','=', product_id),
('date_end','&gt;=',time.strftime('%Y-%m-%d 00:00:00'))
]"
attrs="{'invisible': [('event_ok', '=', False)], 'required': [('event_ok', '!=', False)]}"
options="{'no_open': True, 'no_create': True}"
/>
<field
name="event_ticket_id"
domain="[
('event_id', '=', event_id),
('product_id','=',product_id),
'|', ('seats_availability', '=', 'unlimited'), ('seats_available', '>', 0)
]"
attrs="{
'invisible': ['|', ('event_ok', '=', False), ('event_id', '=', False)],
'required': [('event_ok', '!=', False), ('event_id', '!=', False)],
}"
options="{'no_open': True, 'no_create': True}"
/>
<field name="event_ok" invisible="1"/>
</xpath>
<xpath expr="//field[@name='order_line']//tree//field[@name='product_id']" position="after">
+10
View File
@@ -572,6 +572,16 @@ class ProductProduct(models.Model):
)
return super(ProductProduct, self).get_empty_list_help(help)
def get_product_multiline_description_sale(self):
""" Compute a multiline description of this product, in the context of sales
(do not use for purchases or other display reasons that don't intend to use "description_sale").
It will often be used as the default description of a sale order line referencing this product.
"""
name = self.display_name
if self.description_sale:
name += '\n' + self.description_sale
return name
class ProductPackaging(models.Model):
_name = "product.packaging"
+10 -4
View File
@@ -1343,10 +1343,7 @@ class SaleOrderLine(models.Model):
self.product_id = False
return result
name = product.name_get()[0][1]
if product.description_sale:
name += '\n' + product.description_sale
vals['name'] = name
vals.update(name=self.get_sale_order_line_multiline_description_sale(product))
self._compute_tax_id()
@@ -1486,3 +1483,12 @@ class SaleOrderLine(models.Model):
def _is_delivery(self):
self.ensure_one()
return False
def get_sale_order_line_multiline_description_sale(self, product):
""" Compute a default multiline description for this sales order line.
This method exists so it can be overridden in other modules to change how the default name is computed.
In general only the product is used to compute the name, and this method would not be necessary (we could directly override the method in product).
BUT in event_sale we need to know specifically the sales order line as well as the product to generate the name:
the product is not sufficient because we also need to know the event_id and the event_ticket_id (both which belong to the sale order line).
"""
return product.get_product_multiline_description_sale()
+1 -3
View File
@@ -238,9 +238,7 @@ class SaleOrderOption(models.Model):
return
product = self.product_id.with_context(lang=self.order_id.partner_id.lang)
self.price_unit = product.list_price
self.name = product.name
if product.description_sale:
self.name += '\n' + product.description_sale
self.name = product.get_product_multiline_description_sale()
self.uom_id = self.uom_id or product.uom_id
pricelist = self.order_id.pricelist_id
if pricelist and product:
+2 -5
View File
@@ -4,12 +4,9 @@ import logging
import pytz
import werkzeug
from datetime import datetime
from odoo import api, fields, models, _
from odoo.addons.http_routing.models.ir_http import slug
from odoo.exceptions import UserError
from odoo.tools import DEFAULT_SERVER_DATETIME_FORMAT
_logger = logging.getLogger(__name__)
@@ -162,8 +159,8 @@ class Event(models.Model):
return result
def _get_event_resource_urls(self, attendees):
url_date_start = datetime.strptime(self.date_begin, DEFAULT_SERVER_DATETIME_FORMAT).strftime('%Y%m%dT%H%M%SZ')
url_date_stop = datetime.strptime(self.date_end, DEFAULT_SERVER_DATETIME_FORMAT).strftime('%Y%m%dT%H%M%SZ')
url_date_start = self.date_begin.strftime('%Y%m%dT%H%M%SZ')
url_date_stop = self.date_end.strftime('%Y%m%dT%H%M%SZ')
params = {
'action': 'TEMPLATE',
'text': self.name,
+16 -1
View File
@@ -42,7 +42,7 @@ class SaleOrder(models.Model):
values['event_id'] = ticket.event_id.id
values['event_ticket_id'] = ticket.id
values['price_unit'] = ticket.price_reduce or ticket.price
values['name'] = "%s\n%s" % (ticket.event_id.display_name, ticket.name)
values['name'] = ticket.get_ticket_multiline_description_sale()
# avoid writing related values that end up locking the product record
values.pop('event_ok', None)
@@ -96,3 +96,18 @@ class SaleOrder(models.Model):
# add in return values the registrations, to display them on website (or not)
values['attendee_ids'] = self.env['event.registration'].search([('sale_order_line_id', '=', line.id), ('state', '!=', 'cancel')]).ids
return values
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
@api.multi
@api.depends('product_id.display_name', 'event_ticket_id.display_name')
def _compute_name_short(self):
""" If the sale order line concerns a ticket, we don't want the product name, but the ticket name instead.
"""
super(SaleOrderLine, self)._compute_name_short()
for record in self:
if record.event_ticket_id:
record.name_short = record.event_ticket_id.display_name
@@ -84,15 +84,54 @@
</xpath>
</template>
<template id="cart" inherit_id="website_sale.cart_lines" name="Hide product reduction for event tickets">
<xpath expr="//td[hasclass('td-product_name')]/div/a" position="attributes">
<!-- If the sale order line concerns an event, we want the "product" link to point to the event itself and not to the product on the ecommerce -->
<template id="cart_line_product_link_inherit_website_event_sale" inherit_id="website_sale.cart_line_product_link" name="Event Shopping Cart Line Product Link">
<xpath expr="//a" position="attributes">
<attribute name="t-attf-href"/>
<attribute name="t-att-href">
line.event_id and ('/event/%s/register' % slug(line.event_id)) or ('/shop/product/%s' % slug(line.product_id.product_tmpl_id))
</attribute>
</xpath>
</template>
<!-- If the sale order line concerns an event, we want to show an additional line with the event name even on small screens -->
<template id="cart_lines_inherit_website_event_sale" inherit_id="website_sale.cart_lines" name="Event Shopping Cart Lines">
<xpath expr="//t[@t-call='website_sale.cart_line_description_following_lines']/t[@t-set='div_class']" position="after">
<t t-if="line.event_id">
<t t-set="div_class" t-value="''"/>
</t>
</xpath>
</template>
<!-- If the sale order line concerns an event, we want to show an additional line with the event name -->
<template id="cart_popover_inherit_website_event_sale" inherit_id="website_sale.cart_popover" name="Event Cart Popover">
<xpath expr="//t[@t-call='website_sale.cart_line_product_link']" position="after">
<t t-if="line.event_id" t-call="website_sale.cart_line_description_following_lines"/>
</xpath>
</template>
<!-- If the sale order line concerns an event, we want to show an additional line with the event name -->
<template id="cart_summary_inherit_website_event_sale" inherit_id="website_sale.cart_summary" name="Event Cart right column">
<xpath expr="//td[hasclass('td-product_name')]/div/strong" position="after">
<t t-if="line.event_id" t-call="website_sale.cart_line_description_following_lines"/>
</xpath>
</template>
<template id="cart_lines_inherit_website_event_sale" inherit_id="website_sale.cart_lines" name="Hide product reduction for event tickets">
<xpath expr="//del" position="attributes">
<attribute name="t-attf-class" separator=" " add="#{line.event_id and 'd-none' or ''}"/>
</xpath>
</template>
<!-- Add the ticket name on the registration complete page. -->
<template id="registration_complete_inherit_website_event_sale" inherit_id="website_event.registration_complete" name="Sale Registration Completed">
<xpath expr="//thead/tr/th[1]" position="after">
<th>Ticket</th>
</xpath>
<xpath expr="//tbody/t/tr/td[1]" position="after">
<td><i class="fa fa-ticket"></i> <t t-if='attendee.event_ticket_id'><t t-esc="attendee.event_ticket_id.name"/></t><t t-if='not attendee.event_ticket_id'>N/A</t></td>
</xpath>
</template>
</odoo>
+18
View File
@@ -239,3 +239,21 @@ class SaleOrder(models.Model):
self.modified(['create_date'])
if self.env.recompute and self.env.context.get('recompute', True):
self.recompute()
class SaleOrderLine(models.Model):
_inherit = "sale.order.line"
name_short = fields.Char(compute="_compute_name_short")
@api.multi
@api.depends('product_id.display_name')
def _compute_name_short(self):
""" Compute a short name for this sale order line, to be used on the website where we don't have much space.
To keep it short, instead of using the first line of the description, we take the product name without the internal reference.
"""
for record in self:
record.name_short = record.product_id.with_context(display_default_code=False).display_name
def get_description_following_lines(self):
return self.name.splitlines()[1:]
+35 -13
View File
@@ -817,6 +817,24 @@
</xpath>
</template>
<!-- We use this template where we want to give the user a link to the product of a sale order line. -->
<template id="cart_line_product_link" name="Shopping Cart Line Product Link">
<a t-attf-href="/shop/product/#{ slug(line.product_id.product_tmpl_id) }">
<t t-raw="0"/>
</a>
</template>
<!-- This template displays all the lines following the first one on the description of the sale order line, with a muted style. For typical products this content will be the product description_sale. -->
<template id="cart_line_description_following_lines" name="Shopping Cart Line Description Following Lines">
<div t-if="line.get_description_following_lines()" t-attf-class="text-muted {{div_class}} small">
<t t-foreach="line.get_description_following_lines()" t-as="name_line">
<span><t t-esc="name_line"/></span>
<br t-if="not name_line_last" />
</t>
</div>
</template>
<!-- This template is the one at the "Review order" step (the first one) on the checkout workflow. -->
<template id="cart_lines" name="Shopping Cart Lines">
<div t-if="not website_sale_order or not website_sale_order.website_order_line" class="js_cart_lines alert alert-info">
Your cart is empty!
@@ -840,15 +858,13 @@
</td>
<td t-if="line.product_id.product_tmpl_id" class='td-product_name'>
<div>
<a t-attf-href="/shop/product/#{ slug(line.product_id.product_tmpl_id) }">
<strong t-esc="line.product_id.with_context(display_default_code=False).display_name" />
</a>
</div>
<div class="text-muted d-none d-md-block small">
<t t-foreach="line.name.splitlines()[1:]" t-as="name_line">
<span><t t-esc="name_line"/></span><br/>
<t t-call="website_sale.cart_line_product_link">
<strong t-field="line.name_short" />
</t>
</div>
<t t-call="website_sale.cart_line_description_following_lines">
<t t-set="div_class" t-value="'d-none d-md-block'"/>
</t>
</td>
<td class="text-center td-qty">
<div class="css_quantity input-group oe_website_spinner">
@@ -943,6 +959,7 @@
</t>
</template>
<!-- this template is the one when we mouse over "My Cart" on the top right -->
<template id="cart_popover" name="Cart Popover">
<div t-if="not website_sale_order or not website_sale_order.website_order_line" class="alert alert-info">
Your cart is empty!
@@ -954,10 +971,11 @@
<span t-field="line.product_id.image_small" t-options="{'widget': 'image', 'class': 'rounded'}" />
</div>
<div class="col-9">
<a t-attf-href="/shop/product/#{ slug(line.product_id.product_tmpl_id) }">
<span t-esc="line.product_id.with_context(display_default_code=False).display_name" class="h6" />
</a>
<br />
<div>
<t t-call="website_sale.cart_line_product_link">
<span class="h6" t-esc="line.name_short" />
</t>
</div>
<small>Qty: <t t-esc="int(line.product_uom_qty) == line.product_uom_qty and int(line.product_uom_qty) or line.product_uom_qty" /></small>
</div>
</div>
@@ -1388,6 +1406,10 @@
</div>
</template>
<!-- This template is the one present on the right during the payment process.
Here it is important to not show too much information to the user, because we want him to pay!
We shouldn't display link to products or long descriptions.
-->
<template id="cart_summary" name="Cart right column">
<div class="card">
<div class="card-body p-xl-0">
@@ -1416,9 +1438,9 @@
</td>
<td class='td-product_name' t-if="line.product_id.product_tmpl_id">
<div>
<strong t-field="line.product_id.with_context(display_default_code=False).display_name" />
<strong t-field="line.name_short" />
</div>
</td>
</td>
<td class='td-qty'>
<div t-esc="line.product_uom_qty" />
</td>