[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:
@@ -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,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
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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)
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
@@ -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()
|
||||
@@ -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))
|
||||
|
||||
@@ -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
|
||||
|
||||
|
@@ -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"/>
|
||||
|
||||
+39
@@ -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 = "";
|
||||
}
|
||||
}
|
||||
+12
@@ -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>
|
||||
+33
@@ -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);
|
||||
}
|
||||
}
|
||||
+22
@@ -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";
|
||||
+9
@@ -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) && 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>
|
||||
@@ -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"/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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):
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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. ",
|
||||
)
|
||||
@@ -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]"/>
|
||||
|
||||
@@ -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',
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user