diff --git a/addons/mrp/controller/main.py b/addons/mrp/controller/main.py
index 9c1a6c0b097..679bc2fe335 100644
--- a/addons/mrp/controller/main.py
+++ b/addons/mrp/controller/main.py
@@ -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')),
diff --git a/addons/product/__init__.py b/addons/product/__init__.py
index 2a8e352ba2e..5fd14a9202b 100644
--- a/addons/product/__init__.py
+++ b/addons/product/__init__.py
@@ -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
diff --git a/addons/product/__manifest__.py b/addons/product/__manifest__.py
index b83af700149..228d69b2b7a 100644
--- a/addons/product/__manifest__.py
+++ b/addons/product/__manifest__.py
@@ -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',
diff --git a/addons/product/controllers/__init__.py b/addons/product/controllers/__init__.py
new file mode 100644
index 00000000000..51c0b8196aa
--- /dev/null
+++ b/addons/product/controllers/__init__.py
@@ -0,0 +1 @@
+from . import product_document
diff --git a/addons/product/controllers/product_document.py b/addons/product/controllers/product_document.py
new file mode 100644
index 00000000000..3e77895555d
--- /dev/null
+++ b/addons/product/controllers/product_document.py
@@ -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)
diff --git a/addons/product/models/__init__.py b/addons/product/models/__init__.py
index e4d759ee569..6e9141c38b3 100644
--- a/addons/product/models/__init__.py
+++ b/addons/product/models/__init__.py
@@ -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
diff --git a/addons/product/models/ir_attachment.py b/addons/product/models/ir_attachment.py
new file mode 100644
index 00000000000..532db9d9db9
--- /dev/null
+++ b/addons/product/models/ir_attachment.py
@@ -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
diff --git a/addons/product/models/product_document.py b/addons/product/models/product_document.py
new file mode 100644
index 00000000000..6fbda527aee
--- /dev/null
+++ b/addons/product/models/product_document.py
@@ -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()
diff --git a/addons/product/models/product_product.py b/addons/product/models/product_product.py
index 1d70112f648..1d198853673 100644
--- a/addons/product/models/product_product.py
+++ b/addons/product/models/product_product.py
@@ -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': """
+
+ %s
+
+ %s
+
+ %s
+
+ """ % (
+ _("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))
diff --git a/addons/product/models/product_template.py b/addons/product/models/product_template.py
index 2fa08a4f7b2..63104d33152 100644
--- a/addons/product/models/product_template.py
+++ b/addons/product/models/product_template.py
@@ -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': """
+
+ %s
+
+ %s
+
+ %s
+
+ """ % (
+ _("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 = {}
diff --git a/addons/product/security/ir.model.access.csv b/addons/product/security/ir.model.access.csv
index b531c1a3b21..424b7297274 100644
--- a/addons/product/security/ir.model.access.csv
+++ b/addons/product/security/ir.model.access.csv
@@ -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
diff --git a/addons/product/security/product_security.xml b/addons/product/security/product_security.xml
index 1fbc4234f11..ea6493a51c1 100644
--- a/addons/product/security/product_security.xml
+++ b/addons/product/security/product_security.xml
@@ -37,6 +37,12 @@
['|', ('company_id', 'parent_of', company_ids), ('company_id', '=', False)]
+
+ Product multi-company
+
+ ['|', ('company_id', 'parent_of', company_ids), ('company_id', '=', False)]
+
+
product pricelist company rule
diff --git a/addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.js b/addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.js
new file mode 100644
index 00000000000..f462eefa073
--- /dev/null
+++ b/addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.js
@@ -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 = "";
+ }
+}
diff --git a/addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.xml b/addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.xml
new file mode 100644
index 00000000000..9bfa9c95391
--- /dev/null
+++ b/addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.xml
@@ -0,0 +1,12 @@
+
+
+
+
+
+
+
+
+
diff --git a/addons/product/static/src/js/product_document_kanban/product_document_kanban_record.js b/addons/product/static/src/js/product_document_kanban/product_document_kanban_record.js
new file mode 100644
index 00000000000..e32872a2ce5
--- /dev/null
+++ b/addons/product/static/src/js/product_document_kanban/product_document_kanban_record.js
@@ -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);
+ }
+}
diff --git a/addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.js b/addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.js
new file mode 100644
index 00000000000..10eb04c19d4
--- /dev/null
+++ b/addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.js
@@ -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";
diff --git a/addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.xml b/addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.xml
new file mode 100644
index 00000000000..ce7da3f2b20
--- /dev/null
+++ b/addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.xml
@@ -0,0 +1,9 @@
+
+
+
+
+
+
+
+
+
diff --git a/addons/product/static/src/js/product_document_kanban/product_document_kanban_view.js b/addons/product/static/src/js/product_document_kanban/product_document_kanban_view.js
new file mode 100644
index 00000000000..68affa06e58
--- /dev/null
+++ b/addons/product/static/src/js/product_document_kanban/product_document_kanban_view.js
@@ -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);
diff --git a/addons/product/views/product_document_views.xml b/addons/product/views/product_document_views.xml
new file mode 100644
index 00000000000..3fd26d71760
--- /dev/null
+++ b/addons/product/views/product_document_views.xml
@@ -0,0 +1,114 @@
+
+
+
+
+ product.document.form
+ product.document
+
+
+
+
+
+
+ product.document.kanban
+ product.document
+
+
+
+
+
+
+
+
+
+
+ Edit
+ Delete
+ Download
+
+
+
+
+ Archived
+
+
+
+
+
+
+
![Document]()
+
+
+
+
+
+
+
+
+
+
+
+
+ product.document.list
+ product.document
+
+
+
+
+
+
+
+
+
+
+ product.document.search
+ product.document
+
+
+
+
+
+
+
+
+
diff --git a/addons/product/views/product_views.xml b/addons/product/views/product_views.xml
index 7c1bc2e66a8..67da7f7ff3d 100644
--- a/addons/product/views/product_views.xml
+++ b/addons/product/views/product_views.xml
@@ -35,6 +35,12 @@
+
diff --git a/addons/sale/__manifest__.py b/addons/sale/__manifest__.py
index ef8885f8ae4..f64235159c2 100644
--- a/addons/sale/__manifest__.py
+++ b/addons/sale/__manifest__.py
@@ -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',
diff --git a/addons/sale/controllers/portal.py b/addons/sale/controllers/portal.py
index aec73434d57..f0c19049770 100644
--- a/addons/sale/controllers/portal.py
+++ b/addons/sale/controllers/portal.py
@@ -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//document/', 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):
diff --git a/addons/sale/models/__init__.py b/addons/sale/models/__init__.py
index 6a8faf82e87..f5523d79892 100644
--- a/addons/sale/models/__init__.py
+++ b/addons/sale/models/__init__.py
@@ -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
diff --git a/addons/sale/models/product_document.py b/addons/sale/models/product_document.py
new file mode 100644
index 00000000000..edd4f3e09b8
--- /dev/null
+++ b/addons/sale/models/product_document.py
@@ -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. ",
+ )
diff --git a/addons/sale/models/sale_order.py b/addons/sale/models/sale_order.py
index 0bb65c9a66c..464da51139a 100644
--- a/addons/sale/models/sale_order.py
+++ b/addons/sale/models/sale_order.py
@@ -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):
diff --git a/addons/sale/views/product_document_views.xml b/addons/sale/views/product_document_views.xml
new file mode 100644
index 00000000000..77b2cbcc7ee
--- /dev/null
+++ b/addons/sale/views/product_document_views.xml
@@ -0,0 +1,59 @@
+
+
+
+
+ product.document.form.sale
+ product.document
+
+
+
+
+
+
+
+
+
+
+
+ product.document.kanban.sale
+ product.document
+
+
+
+
+ Visible at
+
+
+
+
+
+
+
+ product.document.list.sale
+ product.document
+
+
+
+
+
+
+
+
+
+ product.document.search.sale
+ product.document
+
+
+
+
+
+
+
+
+
+
+
diff --git a/addons/sale/views/sale_portal_templates.xml b/addons/sale/views/sale_portal_templates.xml
index 1c26d6def2a..6d1498f75d9 100644
--- a/addons/sale/views/sale_portal_templates.xml
+++ b/addons/sale/views/sale_portal_templates.xml
@@ -465,6 +465,28 @@
+
+ Documents
+
+
+
+
Pricing
diff --git a/addons/website_sale/__manifest__.py b/addons/website_sale/__manifest__.py
index 617a1f00ab7..18641b4c16a 100644
--- a/addons/website_sale/__manifest__.py
+++ b/addons/website_sale/__manifest__.py
@@ -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',
diff --git a/addons/website_sale/controllers/main.py b/addons/website_sale/controllers/main.py
index 44e34157c0d..56a99fb90d7 100644
--- a/addons/website_sale/controllers/main.py
+++ b/addons/website_sale/controllers/main.py
@@ -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//document/',
+ 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/'], type='http', auth="public", website=True, sitemap=False)
def old_product(self, product, category='', search='', **kwargs):
# Compatibility pre-v14
diff --git a/addons/website_sale/models/__init__.py b/addons/website_sale/models/__init__.py
index f33b9eb3a60..919281835fe 100644
--- a/addons/website_sale/models/__init__.py
+++ b/addons/website_sale/models/__init__.py
@@ -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
diff --git a/addons/website_sale/models/product_document.py b/addons/website_sale/models/product_document.py
new file mode 100644
index 00000000000..c910288a548
--- /dev/null
+++ b/addons/website_sale/models/product_document.py
@@ -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"))
diff --git a/addons/website_sale/views/product_document_views.xml b/addons/website_sale/views/product_document_views.xml
new file mode 100644
index 00000000000..ae815cda73a
--- /dev/null
+++ b/addons/website_sale/views/product_document_views.xml
@@ -0,0 +1,39 @@
+
+
+
+
+ product.document.form.website_sale
+ product.document
+
+
+
+
+
+
+
+
+
+
+
+ product.document.list.website_sale
+ product.document
+
+
+
+
+
+
+
+
+
+ product.document.search.sale
+ product.document
+
+
+
+
+
+
+
+
+
diff --git a/addons/website_sale/views/templates.xml b/addons/website_sale/views/templates.xml
index c2b306bd168..5a1bd26d099 100644
--- a/addons/website_sale/views/templates.xml
+++ b/addons/website_sale/views/templates.xml
@@ -1078,6 +1078,21 @@
+
+
+
Documents
+
+
+
+
+
+
+