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