From 5a801bc60209713a3ba5313debb4daa05c4e2405 Mon Sep 17 00:00:00 2001 From: Victor Feyens Date: Thu, 24 Aug 2023 11:55:02 +0200 Subject: [PATCH] [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 --- addons/mrp/controller/main.py | 4 +- addons/product/__init__.py | 1 + addons/product/__manifest__.py | 1 + addons/product/controllers/__init__.py | 1 + .../product/controllers/product_document.py | 43 +++++++ addons/product/models/__init__.py | 2 + addons/product/models/ir_attachment.py | 24 ++++ addons/product/models/product_document.py | 34 ++++++ addons/product/models/product_product.py | 60 ++++++++- addons/product/models/product_template.py | 48 ++++++++ addons/product/security/ir.model.access.csv | 1 + addons/product/security/product_security.xml | 6 + .../product_document_kanban_controller.js | 39 ++++++ .../product_document_kanban_controller.xml | 12 ++ .../product_document_kanban_record.js | 33 +++++ .../product_document_kanban_renderer.js | 22 ++++ .../product_document_kanban_renderer.xml | 9 ++ .../product_document_kanban_view.js | 16 +++ .../product/views/product_document_views.xml | 114 ++++++++++++++++++ addons/product/views/product_views.xml | 6 + addons/sale/__manifest__.py | 1 + addons/sale/controllers/portal.py | 19 +++ addons/sale/models/__init__.py | 1 + addons/sale/models/product_document.py | 22 ++++ addons/sale/models/sale_order.py | 12 ++ addons/sale/views/product_document_views.xml | 59 +++++++++ addons/sale/views/sale_portal_templates.xml | 22 ++++ addons/website_sale/__manifest__.py | 1 + addons/website_sale/controllers/main.py | 24 ++++ addons/website_sale/models/__init__.py | 1 + .../website_sale/models/product_document.py | 20 +++ .../views/product_document_views.xml | 39 ++++++ addons/website_sale/views/templates.xml | 15 +++ 33 files changed, 706 insertions(+), 6 deletions(-) create mode 100644 addons/product/controllers/__init__.py create mode 100644 addons/product/controllers/product_document.py create mode 100644 addons/product/models/ir_attachment.py create mode 100644 addons/product/models/product_document.py create mode 100644 addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.js create mode 100644 addons/product/static/src/js/product_document_kanban/product_document_kanban_controller.xml create mode 100644 addons/product/static/src/js/product_document_kanban/product_document_kanban_record.js create mode 100644 addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.js create mode 100644 addons/product/static/src/js/product_document_kanban/product_document_kanban_renderer.xml create mode 100644 addons/product/static/src/js/product_document_kanban/product_document_kanban_view.js create mode 100644 addons/product/views/product_document_views.xml create mode 100644 addons/sale/models/product_document.py create mode 100644 addons/sale/views/product_document_views.xml create mode 100644 addons/website_sale/models/product_document.py create mode 100644 addons/website_sale/views/product_document_views.xml 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 + + + + + + +