[ADD] product,(website_)sale: product documents

Introduce new model of "Product Documents" to hold documents linked
to a given product template/variant, displayed on:

* quotations
* confirmed sale orders
* e-commerce product page

This will also replace the previous "Digital Files" (website_sale_digital)
logic & module, which allowed to specify product documents available
to customers after the SO invoice was paid.

This exact feature will be lost after upgrade, since we only keep the
choice to link documents on quotations/orders, but:

1) on e-commerce, carts are supposed paid when confirmed
2) on portal, the "Online Payment" settings makes sure the users
have to pay to confirm their quotation.

therefore we consider new configuration sufficient, without needing
a "paid order" choice as well.

task-3249201

Part-of: odoo/odoo#132739
This commit is contained in:
Victor Feyens
2023-09-05 10:59:17 +00:00
parent 9ed1dc5cae
commit 5a801bc602
33 changed files with 706 additions and 6 deletions
+3 -1
View File
@@ -21,7 +21,9 @@ class MrpDocumentRoute(http.Controller):
for ufile in files:
try:
mimetype = ufile.content_type
request.env['mrp.document'].create({
request.env['mrp.document'].with_context(
disable_product_documents_creation=True
).create({
'name': ufile.filename,
'res_model': kwargs.get('res_model'),
'res_id': int(kwargs.get('res_id')),
+1
View File
@@ -1,6 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import controllers
from . import models
from . import report
from . import populate
+1
View File
@@ -38,6 +38,7 @@ Print product labels with barcode.
'views/res_config_settings_views.xml',
'views/product_attribute_views.xml',
'views/product_category_views.xml',
'views/product_document_views.xml',
'views/product_packaging_views.xml',
'views/product_pricelist_item_views.xml',
'views/product_pricelist_views.xml',
+1
View File
@@ -0,0 +1 @@
from . import product_document
@@ -0,0 +1,43 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
from odoo import _
from odoo.http import request, route, Controller
logger = logging.getLogger(__name__)
class ProductDocumentController(Controller):
@route('/product/document/upload', type='http', methods=['POST'], auth='user')
def upload_document(self, ufile, res_model, res_id):
if res_model not in ('product.product', 'product.template'):
return
record = request.env[res_model].browse(int(res_id)).exists()
if not record or not record.check_access_rights('write'):
return
files = request.httprequest.files.getlist('ufile')
result = {'success': _("All files uploaded")}
for ufile in files:
try:
mimetype = ufile.content_type
request.env['product.document'].with_context(
disable_product_documents_creation=True,
).create({
'name': ufile.filename,
'res_model': record._name,
'res_id': record.id,
'company_id': record.company_id.id,
'mimetype': mimetype,
'raw': ufile.read(),
})
except Exception as e:
logger.exception("Failed to upload document %s", ufile.filename)
result = {'error': str(e)}
return json.dumps(result)
+2
View File
@@ -9,10 +9,12 @@ from . import product_template
from . import product_product
from . import decimal_precision
from . import ir_attachment
from . import product_attribute
from . import product_attribute_custom_value
from . import product_attribute_value
from . import product_category
from . import product_document
from . import product_packaging
from . import product_pricelist
from . import product_pricelist_item
+24
View File
@@ -0,0 +1,24 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, models
class IrAttachment(models.Model):
_inherit = 'ir.attachment'
@api.model_create_multi
def create(self, vals_list):
"""Create product.document for attachments added in products chatters"""
attachments = super().create(vals_list)
if not self.env.context.get('disable_product_documents_creation'):
product_attachments = self.filtered(
lambda attachment:
attachment.res_model in ('product.product', 'product.template')
and not attachment.res_field
)
if product_attachments:
self.env['product.document'].sudo().create({
'ir_attachment_id': attachment.id
for attachment in product_attachments
})
return attachments
+34
View File
@@ -0,0 +1,34 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductDocument(models.Model):
_name = 'product.document'
_description = "Product Document"
_inherits = {
'ir.attachment': 'ir_attachment_id',
}
ir_attachment_id = fields.Many2one(
'ir.attachment',
string="Related attachment",
required=True,
ondelete='cascade')
active = fields.Boolean(default=True)
#=== CRUD METHODS ===#
def copy(self, default=None):
ir_default = default
if ir_default:
ir_fields = list(self.env['ir.attachment']._fields)
ir_default = {field : default[field] for field in default if field in ir_fields}
new_attach = self.ir_attachment_id.with_context(no_document=True).copy(ir_default)
return super().copy(dict(default, ir_attachment_id=new_attach.id))
def unlink(self):
self.ir_attachment_id.unlink()
return super().unlink()
+55 -5
View File
@@ -60,6 +60,14 @@ class ProductProduct(models.Model):
pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_variant_item_count")
product_document_ids = fields.One2many(
string="Documents",
comodel_name='product.document',
inverse_name='res_id',
domain=lambda self: [('res_model', '=', self._name)])
product_document_count = fields.Integer(
string="Documents Count", compute='_compute_product_document_count')
packaging_ids = fields.One2many(
'product.packaging', 'product_id', 'Product Packages',
help="Gives the different ways to package the same product.")
@@ -278,6 +286,13 @@ class ProductProduct(models.Model):
'&', ('product_id', '=', product.id), ('applied_on', '=', '0_product_variant')]
product.pricelist_item_count = self.env['product.pricelist.item'].search_count(domain)
def _compute_product_document_count(self):
for product in self:
product.product_document_count = product.env['product.document'].search_count([
('res_model', '=', 'product.product'),
('res_id', '=', product.id),
])
@api.depends('product_tag_ids', 'additional_product_tag_ids')
def _compute_all_product_tag_ids(self):
for product in self:
@@ -539,6 +554,8 @@ class ProductProduct(models.Model):
)
return super().view_header_get(view_id, view_type)
#=== ACTION METHODS ===#
def action_open_label_layout(self):
action = self.env['ir.actions.act_window']._for_xml_id('product.action_open_label_layout')
action['context'] = {'default_product_ids': self.ids}
@@ -567,11 +584,44 @@ class ProductProduct(models.Model):
def open_product_template(self):
""" Utility method used to add an "Open Template" button in product views """
self.ensure_one()
return {'type': 'ir.actions.act_window',
'res_model': 'product.template',
'view_mode': 'form',
'res_id': self.product_tmpl_id.id,
'target': 'new'}
return {
'type': 'ir.actions.act_window',
'res_model': 'product.template',
'view_mode': 'form',
'res_id': self.product_tmpl_id.id,
'target': 'new'
}
def action_open_documents(self):
self.ensure_one()
return {
'name': _('Documents'),
'type': 'ir.actions.act_window',
'res_model': 'product.document',
'view_mode': 'kanban,tree,form',
'context': {
'default_res_model': self._name,
'default_res_id': self.id,
'default_company_id': self.company_id.id,
},
'domain': [('res_id', 'in', self.ids), ('res_model', '=', self._name)],
'target': 'current',
'help': """
<p class="o_view_nocontent_smiling_face">
%s
</p><p>
%s
<br/>
%s
</p>
""" % (
_("Upload files to your product"),
_("Use this feature to store any files you would like to share with your customers."),
_("E.G: product description, ebook, legal notice, ..."),
)
}
#=== BUSINESS METHODS ===#
def _prepare_sellers(self, params=False):
return self.seller_ids.filtered(lambda s: s.partner_id.active).sorted(lambda s: (s.sequence, -s.min_qty, s.price, s.id))
+48
View File
@@ -133,6 +133,14 @@ class ProductTemplate(models.Model):
pricelist_item_count = fields.Integer("Number of price rules", compute="_compute_item_count")
product_document_ids = fields.One2many(
string="Documents",
comodel_name='product.document',
inverse_name='res_id',
domain=lambda self: [('res_model', '=', self._name)])
product_document_count = fields.Integer(
string="Documents Count", compute='_compute_product_document_count')
can_image_1024_be_zoomed = fields.Boolean("Can Image 1024 be zoomed", compute='_compute_can_image_1024_be_zoomed', store=True)
has_configurable_attributes = fields.Boolean("Is a configurable product", compute='_compute_has_configurable_attributes', store=True)
@@ -158,6 +166,13 @@ class ProductTemplate(models.Model):
('pricelist_id.active', '=', True),
])
def _compute_product_document_count(self):
for template in self:
template.product_document_count = template.env['product.document'].search_count([
('res_model', '=', 'product.template'),
('res_id', '=', template.id),
])
@api.depends('image_1920', 'image_1024')
def _compute_can_image_1024_be_zoomed(self):
for template in self:
@@ -536,6 +551,8 @@ class ProductTemplate(models.Model):
domain = [('id', 'in', list(searched_ids))]
return super()._name_search('', domain, 'ilike', limit, order)
#=== ACTION METHODS ===#
def action_open_label_layout(self):
action = self.env['ir.actions.act_window']._for_xml_id('product.action_open_label_layout')
action['context'] = {'default_product_tmpl_ids': self.ids}
@@ -562,6 +579,37 @@ class ProductTemplate(models.Model):
},
}
def action_open_documents(self):
self.ensure_one()
return {
'name': _('Documents'),
'type': 'ir.actions.act_window',
'res_model': 'product.document',
'view_mode': 'kanban,tree,form',
'context': {
'default_res_model': self._name,
'default_res_id': self.id,
'default_company_id': self.company_id.id,
},
'domain': [('res_id', 'in', self.ids), ('res_model', '=', self._name)],
'target': 'current',
'help': """
<p class="o_view_nocontent_smiling_face">
%s
</p><p>
%s
<br/>
%s
</p>
""" % (
_("Upload files to your product"),
_("Use this feature to store any files you would like to share with your customers."),
_("E.G: product description, ebook, legal notice, ..."),
)
}
#=== BUSINESS METHODS ===#
def _get_product_price_context(self, combination):
self.ensure_one()
res = {}
@@ -29,3 +29,4 @@ access_product_template_attribute_exclusion_manager,product.template.attribute e
access_product_template_attribute_line_manager,product.template.attribute line manager,model_product_template_attribute_line,base.group_system,1,1,1,1
access_product_label_layout_user,product.label.layout.user,model_product_label_layout,base.group_user,1,1,1,1
access_product_tag_manager,product.tag.manager,model_product_tag,base.group_system,1,1,1,1
access_product_document_user,access_product_document_user,model_product_document,base.group_user,1,1,1,1
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
29 access_product_template_attribute_line_manager product.template.attribute line manager model_product_template_attribute_line base.group_system 1 1 1 1
30 access_product_label_layout_user product.label.layout.user model_product_label_layout base.group_user 1 1 1 1
31 access_product_tag_manager product.tag.manager model_product_tag base.group_system 1 1 1 1
32 access_product_document_user access_product_document_user model_product_document base.group_user 1 1 1 1
@@ -37,6 +37,12 @@
<field name="domain_force"> ['|', ('company_id', 'parent_of', company_ids), ('company_id', '=', False)]</field>
</record>
<record id="product_document_comp_rule" model="ir.rule">
<field name="name" >Product multi-company</field>
<field name="model_id" ref="model_product_document"/>
<field name="domain_force"> ['|', ('company_id', 'parent_of', company_ids), ('company_id', '=', False)]</field>
</record>
<record model="ir.rule" id="product_pricelist_comp_rule">
<field name="name">product pricelist company rule</field>
<field name="model_id" ref="model_product_pricelist"/>
@@ -0,0 +1,39 @@
/** @odoo-module **/
import { KanbanController } from "@web/views/kanban/kanban_controller";
import { useBus, useService } from "@web/core/utils/hooks";
const { useRef } = owl;
export class ProductDocumentKanbanController extends KanbanController {
setup() {
super.setup();
this.uploadFileInputRef = useRef("uploadFileInput");
this.fileUploadService = useService("file_upload");
useBus(
this.fileUploadService.bus,
"FILE_UPLOAD_LOADED",
async () => {
await this.model.root.load();
},
);
}
async onFileInputChange(ev) {
if (!ev.target.files.length) {
return;
}
await this.fileUploadService.upload(
"/product/document/upload",
ev.target.files,
{
buildFormData: (formData) => {
formData.append("res_model", this.props.context.active_model);
formData.append("res_id", this.props.context.active_id);
},
},
);
// Reset the file input's value so that the same file may be uploaded twice.
ev.target.value = "";
}
}
@@ -0,0 +1,12 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="product.ProductDocumentKanbanView.Buttons" t-inherit="web.KanbanView.Buttons" t-inherit-mode="primary">
<xpath expr="//div[hasclass('o_cp_buttons')]" position="inside">
<input type="file" multiple="true" t-ref="uploadFileInput" class="o_input_file o_hidden" t-on-change.stop="onFileInputChange"/>
<button type="button" t-attf-class="btn btn-primary o_mrp_documents_kanban_upload"
t-on-click.stop.prevent="() => this.uploadFileInputRef.el.click()">
Upload
</button>
</xpath>
</t>
</templates>
@@ -0,0 +1,33 @@
/** @odoo-module **/
import { CANCEL_GLOBAL_CLICK, KanbanRecord } from "@web/views/kanban/kanban_record";
import { useService } from "@web/core/utils/hooks";
import { useFileViewer } from "@web/core/file_viewer/file_viewer_hook";
export class ProductDocumentKanbanRecord extends KanbanRecord {
setup() {
super.setup();
this.attachmentService = useService("mail.attachment");
this.fileViewer = useFileViewer();
}
/**
* @override
*
* Override to open the preview upon clicking the image, if compatible.
*/
onGlobalClick(ev) {
if (ev.target.closest(CANCEL_GLOBAL_CLICK)) {
return;
} else if (ev.target.closest(".o_kanban_previewer")) {
const attachment = this.attachmentService.insert({
id: this.props.record.data.ir_attachment_id[0],
filename: this.props.record.data.name,
name: this.props.record.data.name,
mimetype: this.props.record.data.mimetype,
});
this.fileViewer.open(attachment)
return;
}
return super.onGlobalClick(...arguments);
}
}
@@ -0,0 +1,22 @@
/** @odoo-module **/
import { useService } from "@web/core/utils/hooks";
import { KanbanRenderer } from "@web/views/kanban/kanban_renderer";
import { ProductDocumentKanbanRecord } from "@product/js/product_document_kanban/product_document_kanban_record";
import { FileUploadProgressContainer } from "@web/core/file_upload/file_upload_progress_container";
import { FileUploadProgressKanbanRecord } from "@web/core/file_upload/file_upload_progress_record";
export class ProductDocumentKanbanRenderer extends KanbanRenderer {
setup() {
super.setup();
this.fileUploadService = useService("file_upload");
}
}
ProductDocumentKanbanRenderer.components = {
...KanbanRenderer.components,
FileUploadProgressContainer,
FileUploadProgressKanbanRecord,
KanbanRecord: ProductDocumentKanbanRecord,
};
ProductDocumentKanbanRenderer.template = "product.ProductDocumentKanbanRenderer";
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates>
<t t-name="product.ProductDocumentKanbanRenderer" t-inherit-mode="primary" t-inherit="web.KanbanRenderer">
<!-- Before the first t-foreach -->
<xpath expr="//t[@t-key='groupOrRecord.key']" position="before">
<FileUploadProgressContainer fileUploads="fileUploadService.uploads" Component="constructor.components.FileUploadProgressKanbanRecord"/>
</xpath>
</t>
</templates>
@@ -0,0 +1,16 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { kanbanView } from "@web/views/kanban/kanban_view";
import { ProductDocumentKanbanController } from "@product/js/product_document_kanban/product_document_kanban_controller";
import { ProductDocumentKanbanRenderer } from "@product/js/product_document_kanban/product_document_kanban_renderer";
export const productDocumentKanbanView = {
...kanbanView,
Controller: ProductDocumentKanbanController,
Renderer: ProductDocumentKanbanRenderer,
buttonTemplate: "product.ProductDocumentKanbanView.Buttons",
};
registry.category("views").add("product_documents_kanban", productDocumentKanbanView);
@@ -0,0 +1,114 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="product_document_form" model="ir.ui.view">
<field name="name">product.document.form</field>
<field name="model">product.document</field>
<field name="arch" type="xml">
<form>
<sheet>
<label for="name"/>
<h1>
<field name="name" readonly="not datas and type != 'url'"/>
</h1>
<group>
<field name="type"/>
<field name="datas" filename="name" invisible="type == 'url'"/>
<field name="url" widget="url" invisible="type == 'binary'"/>
</group>
<group string="Attached To" groups="base.group_no_one">
<field name="res_name"/>
<field name="company_id" groups="base.group_multi_company" options="{'no_create': True}"/>
</group>
<group string="History" groups="base.group_no_one" invisible="not create_date">
<label for="create_uid" string="Creation"/>
<div name="creation_div">
<field name="create_uid" readonly="1" class="oe_inline"/> on
<field name="create_date" readonly="1" class="oe_inline"/>
</div>
</group>
</sheet>
</form>
</field>
</record>
<record id="product_document_kanban" model="ir.ui.view">
<field name="name">product.document.kanban</field>
<field name="model">product.document</field>
<field name="arch" type="xml">
<kanban js_class="product_documents_kanban">
<field name="ir_attachment_id"/>
<field name="mimetype"/>
<field name="type"/>
<field name="name"/>
<field name="create_uid"/>
<field name="active"/>
<templates>
<t t-name="kanban-menu">
<a t-if="widget.editable" type="edit" class="dropdown-item">Edit</a>
<a t-if="widget.deletable" type="delete" class="dropdown-item">Delete</a>
<a t-attf-href="/web/content/#{record.ir_attachment_id.raw_value}?download=true" download="" class="dropdown-item">Download</a>
</t>
<t t-name="kanban-box">
<div class="oe_kanban_global_area o_kanban_attachment oe_kanban_global_click">
<div class="ribbon ribbon-top-right" invisible="active">
<span class="text-bg-danger">Archived</span>
</div>
<div class="o_kanban_image">
<t t-set="webimage" t-value="new RegExp('image.*(gif|jpeg|jpg|png|webp)').test(record.mimetype.value)"/>
<t t-set="binaryPreviewable"
t-value="new RegExp('(image|video|application/pdf|text)').test(record.mimetype.value) &amp;&amp; record.type.raw_value === 'binary'"/>
<div t-attf-class="o_kanban_image_wrapper #{(webimage or binaryPreviewable) ? 'o_kanban_previewer' : ''}">
<div t-if="record.type.raw_value == 'url'" class="o_url_image fa fa-link fa-3x text-muted" aria-label="Image is a link"/>
<img t-elif="webimage" t-attf-src="/web/image/#{record.ir_attachment_id.raw_value}" width="100" height="100" alt="Document" class="o_attachment_image"/>
<div t-else="" class="o_image o_image_thumbnail" t-att-data-mimetype="record.mimetype.value"/>
</div>
</div>
<div class="o_kanban_details">
<div class="o_kanban_details_wrapper">
<div class="o_kanban_record_title">
<field name="name" class="o_text_overflow"/>
</div>
<div class="o_kanban_record_body" name="body">
<field name="url" widget="url" invisible="type == 'binary'"/>
</div>
<div class="o_kanban_record_bottom">
<span class="oe_kanban_bottom_left" name="bottom_left">
</span>
<div class="oe_kanban_bottom_right">
<field name="create_uid" widget="many2one_avatar_user"/>
</div>
</div>
</div>
</div>
</div>
</t>
</templates>
</kanban>
</field>
</record>
<record id="product_document_list" model="ir.ui.view">
<field name="name">product.document.list</field>
<field name="model">product.document</field>
<field name="arch" type="xml">
<tree multi_edit="true">
<field name="res_id"/>
<field name="res_model"/>
<field name="name"/>
</tree>
</field>
</record>
<record id="product_document_search" model="ir.ui.view">
<field name="name">product.document.search</field>
<field name="model">product.document</field>
<field name="arch" type="xml">
<search>
<field name="name"/>
<filter name="archived" string="Archived" domain="[('active', '=', False)]"/>
</search>
</field>
</record>
</odoo>
+6
View File
@@ -35,6 +35,12 @@
</span>
</div>
</button>
<button class="oe_stat_button"
name="action_open_documents"
type="object"
icon="fa-file-text-o">
<field string="Documents" name="product_document_count" widget="statinfo"/>
</button>
</div>
<widget name="web_ribbon" title="Archived" bg_color="text-bg-danger" invisible="active"/>
<field name="id" invisible="True"/>
+1
View File
@@ -48,6 +48,7 @@ This module contains all the common features of Sales Management and eCommerce.
'views/mail_activity_views.xml',
'views/payment_templates.xml',
'views/payment_views.xml',
'views/product_document_views.xml',
'views/product_packaging_views.xml',
'views/product_product_views.xml',
'views/product_views.xml',
+19
View File
@@ -148,6 +148,7 @@ class CustomerPortal(portal.CustomerPortal):
f'&view_type=form'
values = {
'sale_order': order_sudo,
'product_documents': order_sudo._get_product_documents(),
'message': message,
'report_type': 'html',
'backend_url': backend_url,
@@ -279,6 +280,24 @@ class CustomerPortal(portal.CustomerPortal):
return request.redirect(redirect_url)
@http.route('/my/orders/<int:order_id>/document/<int:document_id>', type='http', auth='public')
def portal_quote_document(self, order_id, document_id, access_token):
try:
order_sudo = self._document_check_access('sale.order', order_id, access_token=access_token)
except (AccessError, MissingError):
return request.redirect('/my')
document = request.env['product.document'].browse(document_id).sudo().exists()
if not document or not document.active:
return request.redirect('/my')
if document not in order_sudo._get_product_documents():
return request.redirect('/my')
return request.env['ir.binary']._get_stream_from(
document.ir_attachment_id,
).get_response(as_attachment=True)
class PaymentPortal(payment_portal.PaymentPortal):
+1
View File
@@ -9,6 +9,7 @@ from . import onboarding_onboarding
from . import onboarding_onboarding_step
from . import payment_provider
from . import payment_transaction
from . import product_document
from . import product_product
from . import product_template
from . import res_company
+22
View File
@@ -0,0 +1,22 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
class ProductDocument(models.Model):
_inherit = 'product.document'
attached_on = fields.Selection(
selection=[
('quotation', "Quotation"),
('sale_order', "Confirmed order"),
],
string="Visible at",
help="Allows you to share the document with your customers within a sale."
"Leave it empty if you don't want to share this document with sales customer."
"Quotation: the document will be sent to and accessible by customers at any time."
"e.g. this option can be useful to share Product description files."
"Confirmed order: the document will be sent to and accessible by customers"
"e.g. this option can be useful to share User Manual or digital content bought"
" on ecommerce. ",
)
+12
View File
@@ -1649,6 +1649,18 @@ class SaleOrder(models.Model):
del context
return down_payments_section_line
def _get_product_documents(self):
self.ensure_one()
return (
self.order_line.product_id.product_document_ids
| self.order_line.product_template_id.product_document_ids
).filtered(
lambda document:
document.attached_on == 'quotation'
or (self.state == 'sale' and document.attached_on == 'sale_order')
).sorted()
#=== HOOKS ===#
def add_option_to_order_with_taxcloud(self):
@@ -0,0 +1,59 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="product_document_form" model="ir.ui.view">
<field name="name">product.document.form.sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="product.product_document_form"/>
<field name="arch" type="xml">
<sheet position="inside">
<group name="sale" string="Sales">
<field name="attached_on"/>
</group>
</sheet>
</field>
</record>
<record id="product_document_kanban" model="ir.ui.view">
<field name="name">product.document.kanban.sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="product.product_document_kanban"/>
<field name="arch" type="xml">
<span name="bottom_left" position="inside">
<t t-if="record.attached_on.raw_value">
<strong>Visible at</strong>
<field name="attached_on"/>
</t>
</span>
</field>
</record>
<record id="product_document_list" model="ir.ui.view">
<field name="name">product.document.list.sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="product.product_document_list"/>
<field name="arch" type="xml">
<tree position="inside">
<field name="attached_on"/>
</tree>
</field>
</record>
<record id="product_document_search" model="ir.ui.view">
<field name="name">product.document.search.sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="product.product_document_search"/>
<field name="arch" type="xml">
<search position="inside">
<separator/>
<filter name="quotation"
string="Quotation"
domain="[('attached_on', '=', 'quotation')]"/>
<filter name="sale_order"
string="Sales Order"
domain="[('attached_on', '=', 'sale_order')]"/>
</search>
</field>
</record>
</odoo>
@@ -465,6 +465,28 @@
</div>
<section id="details" style="page-break-inside: auto;" class="mt32">
<t t-if="product_documents">
<h3 id="details">Documents</h3>
<hr class="mt-0 mb-1"/>
<div class="row">
<t t-foreach="product_documents" t-as="document_sudo">
<div class="col-lg-2 col-md-3 col-sm-6">
<div class="mb-2 position-relative text-center">
<t t-set="attachment_sudo" t-value="document_sudo.ir_attachment_id"/>
<t t-set="target" t-value="attachment_sudo.type == 'url' and '_blank' or '_self'"/>
<a t-att-href="sale_order.get_portal_url('/document/' + str(document_sudo.id))" t-att-target="target">
<div class="o_image"
t-att-title="attachment_sudo.name"
t-att-data-mimetype="attachment_sudo.mimetype"
t-attf-data-src="/web/image/#{attachment_sudo.id}/100x80?access_token=#{attachment_sudo.access_token}"/>
<div class="o_portal_product_document" t-out="attachment_sudo.name"/>
</a>
</div>
</div>
</t>
</div>
</t>
<h3 id="details">Pricing</h3>
<t t-set="display_discount" t-value="True in [line.discount > 0 for line in sale_order.order_line]"/>
+1
View File
@@ -25,6 +25,7 @@
'views/crm_team_views.xml',
'views/digest_views.xml',
'views/product_attribute_views.xml',
'views/product_document_views.xml',
'views/product_tag_views.xml',
'views/product_views.xml',
'views/sale_order_views.xml',
+24
View File
@@ -483,6 +483,30 @@ class WebsiteSale(http.Controller):
def product(self, product, category='', search='', **kwargs):
return request.render("website_sale.product", self._prepare_product_values(product, category, search, **kwargs))
@http.route(
'/shop/<model("product.template"):product_template>/document/<int:document_id>',
type='http',
auth='public',
website=True,
sitemap=False,
)
def product_document(self, product_template, document_id):
product_template.check_access_rights('read')
document = request.env['product.document'].browse(document_id).sudo().exists()
if not document or not document.active:
return request.redirect('/shop')
if not document.shown_on_product_page or not (
document.res_id == product_template.id
and document.res_model == 'product.template'
):
return request.redirect('/shop')
return request.env['ir.binary']._get_stream_from(
document.ir_attachment_id,
).get_response(as_attachment=True)
@http.route(['/shop/product/<model("product.template"):product>'], type='http', auth="public", website=True, sitemap=False)
def old_product(self, product, category='', search='', **kwargs):
# Compatibility pre-v14
+1
View File
@@ -7,6 +7,7 @@ from . import delivery_carrier
from . import digest
from . import ir_http
from . import product_attribute
from . import product_document
from . import product_image
from . import product_pricelist
from . import product_product
@@ -0,0 +1,20 @@
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, _
from odoo.exceptions import ValidationError
class ProductDocument(models.Model):
_inherit = 'product.document'
shown_on_product_page = fields.Boolean(string="Show on product page")
@api.constrains('res_model', 'shown_on_product_page')
def _unsupported_product_product_document_on_ecommerce(self):
# Not supported for now because product page is dynamic and it would require a lot of work
# to update documents shown according to combination. It'll wait for planned tasks
# rebuilding the product page & variant mixin.
for document in self:
if document.res_model == 'product.product' and document.shown_on_product_page:
raise ValidationError(
_("Documents shown on product page cannot be restricted to a specific variant"))
@@ -0,0 +1,39 @@
<?xml version="1.0" encoding="UTF-8"?>
<odoo>
<record id="product_document_form" model="ir.ui.view">
<field name="name">product.document.form.website_sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="sale.product_document_form"/>
<field name="arch" type="xml">
<sheet position="inside">
<group name="website_sale" string="E-commerce">
<field name="shown_on_product_page"/>
</group>
</sheet>
</field>
</record>
<record id="product_document_list" model="ir.ui.view">
<field name="name">product.document.list.website_sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="sale.product_document_list"/>
<field name="arch" type="xml">
<tree position="inside">
<field name="shown_on_product_page"/>
</tree>
</field>
</record>
<record id="product_document_search" model="ir.ui.view">
<field name="name">product.document.search.sale</field>
<field name="model">product.document</field>
<field name="inherit_id" ref="sale.product_document_search"/>
<field name="arch" type="xml">
<search position="inside">
<separator/>
<filter name="e_commerce" string="Show on Ecommerce" domain="[('shown_on_product_page', '=', True)]"/>
</search>
</field>
</record>
</odoo>
+15
View File
@@ -1078,6 +1078,21 @@
</t>
</table>
</div>
<t t-set="product_documents" t-value="product.sudo().product_document_ids.filtered(lambda doc: doc.shown_on_product_page)"/>
<div id="product_documents" t-if="product_documents" class="row">
<u class="h5">Documents</u>
<t t-foreach="product_documents" t-as="document_sudo">
<t t-set="attachment_sudo" t-value="document_sudo.ir_attachment_id"/>
<t t-set="target" t-value="attachment_sudo.type == 'url' and '_blank' or '_self'"/>
<t t-set="icon" t-value="attachment_sudo.type == 'url' and 'fa-link' or 'fa-download'"/>
<div class="d-flex mb-2 mt-2">
<a t-att-href="'/shop/' + slug(product) + '/document/' + str(document_sudo.id)" t-att-target="target">
<i t-att-class="'fa ' + icon"/>
<t t-out="attachment_sudo.name"/>
</a>
</div>
</t>
</div>
<div id="o_product_terms_and_share" class="d-flex justify-content-between flex-column flex-md-row align-items-md-end mb-3">
</div>
</div>