forked from Mapan/odoo17e
355 lines
13 KiB
Python
355 lines
13 KiB
Python
# -*- coding: utf-8 -*-
|
|
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
|
|
|
import base64
|
|
import io
|
|
import json
|
|
import zipfile
|
|
import datetime
|
|
|
|
from lxml import etree
|
|
|
|
from odoo import _, fields, models, api
|
|
from odoo.exceptions import UserError, AccessError, ValidationError
|
|
from odoo.osv import expression
|
|
from odoo.tools import image_process
|
|
|
|
|
|
SUPPORTED_PATHS = (
|
|
"[Content_Types].xml",
|
|
"xl/sharedStrings.xml",
|
|
"xl/styles.xml",
|
|
"xl/workbook.xml",
|
|
"_rels/",
|
|
"xl/_rels",
|
|
"xl/charts/",
|
|
"xl/drawings/",
|
|
"xl/externalLinks/",
|
|
"xl/pivotTables/",
|
|
"xl/tables/",
|
|
"xl/theme/",
|
|
"xl/worksheets/",
|
|
"xl/media",
|
|
)
|
|
|
|
XLSX_MIME_TYPES = [
|
|
"application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
|
|
"application/wps-office.xlsx",
|
|
]
|
|
|
|
|
|
class Document(models.Model):
|
|
_name = "documents.document"
|
|
_inherit = ["documents.document", "spreadsheet.mixin"]
|
|
|
|
spreadsheet_binary_data = fields.Binary(compute='_compute_spreadsheet_binary_data', inverse='_inverse_spreadsheet_binary_data', default=None)
|
|
|
|
handler = fields.Selection(
|
|
[("spreadsheet", "Spreadsheet")], ondelete={"spreadsheet": "cascade"}
|
|
)
|
|
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
vals_list = self._assign_spreadsheet_default_folder(vals_list)
|
|
vals_list = self._resize_spreadsheet_thumbnails(vals_list)
|
|
documents = super().create(vals_list)
|
|
documents._update_spreadsheet_contributors()
|
|
return documents
|
|
|
|
def write(self, vals):
|
|
if 'handler' not in vals and 'mimetype' in vals and vals['mimetype'] != 'application/o-spreadsheet':
|
|
vals['handler'] = False
|
|
if 'spreadsheet_data' in vals:
|
|
self._update_spreadsheet_contributors()
|
|
if all(document.handler == 'spreadsheet' for document in self):
|
|
vals = self._resize_thumbnail_value(vals)
|
|
return super().write(vals)
|
|
|
|
def join_spreadsheet_session(self, share_id=None, access_token=None):
|
|
if self.sudo().handler != "spreadsheet":
|
|
raise ValidationError(_("The spreadsheet you are trying to access does not exist."))
|
|
data = super().join_spreadsheet_session(share_id, access_token)
|
|
self._update_spreadsheet_contributors()
|
|
return dict(data, is_favorited=self.sudo().is_favorited, folder_id=self.sudo().folder_id.id)
|
|
|
|
def _check_spreadsheet_share(self, operation, share_id, access_token):
|
|
share = self.env['documents.share'].browse(share_id).sudo()
|
|
available_documents = share._get_documents_and_check_access(access_token, operation=operation)
|
|
if not available_documents or self not in available_documents:
|
|
raise AccessError(_("You don't have access to this document"))
|
|
|
|
def _compute_file_extension(self):
|
|
""" Spreadsheet documents do not have file extension. """
|
|
spreadsheet_docs = self.filtered(lambda rec: rec.handler == "spreadsheet")
|
|
spreadsheet_docs.file_extension = False
|
|
super(Document, self - spreadsheet_docs)._compute_file_extension()
|
|
|
|
@api.depends("attachment_id", "handler")
|
|
def _compute_spreadsheet_data(self):
|
|
for document in self.with_context(bin_size=False):
|
|
if document.handler == "spreadsheet":
|
|
document.spreadsheet_data = document.attachment_id.raw
|
|
else:
|
|
document.spreadsheet_data = False
|
|
|
|
@api.depends("datas", "handler")
|
|
def _compute_spreadsheet_binary_data(self):
|
|
for document in self:
|
|
if document.handler == "spreadsheet":
|
|
document.spreadsheet_binary_data = document.datas
|
|
else:
|
|
document.spreadsheet_binary_data = False
|
|
|
|
def _inverse_spreadsheet_binary_data(self):
|
|
for document in self:
|
|
if document.handler == "spreadsheet":
|
|
document.write({
|
|
"datas": document.spreadsheet_binary_data,
|
|
"mimetype": "application/o-spreadsheet"
|
|
})
|
|
|
|
@api.depends("checksum", "handler")
|
|
def _compute_thumbnail(self):
|
|
# Spreadsheet thumbnails cannot be computed from their binary data.
|
|
# They should be saved independently.
|
|
spreadsheets = self.filtered(lambda d: d.handler == "spreadsheet")
|
|
super(Document, self - spreadsheets)._compute_thumbnail()
|
|
|
|
def _copy_spreadsheet_image_attachments(self):
|
|
if self.handler != "spreadsheet":
|
|
return
|
|
super()._copy_spreadsheet_image_attachments()
|
|
|
|
def _resize_thumbnail_value(self, vals):
|
|
if 'thumbnail' in vals:
|
|
return dict(
|
|
vals,
|
|
thumbnail=base64.b64encode(image_process(base64.b64decode(vals['thumbnail'] or ''), size=(750, 750), crop='center')),
|
|
)
|
|
return vals
|
|
|
|
def _resize_spreadsheet_thumbnails(self, vals_list):
|
|
return [
|
|
(
|
|
self._resize_thumbnail_value(vals)
|
|
if vals.get('handler') == 'spreadsheet'
|
|
else vals
|
|
)
|
|
for vals in vals_list
|
|
]
|
|
|
|
def _assign_spreadsheet_default_folder(self, vals_list):
|
|
"""Make sure spreadsheet values have a `folder_id`. Assign the
|
|
default spreadsheet folder if there is none.
|
|
"""
|
|
# Use the current company's spreadsheet workspace, since `company_id` on `documents.document` is a related field
|
|
# on `folder_id` we do not need to check vals_list for different companies.
|
|
default_folder = self.env.company.documents_spreadsheet_folder_id
|
|
if not default_folder:
|
|
default_folder = self.env['documents.folder'].search([], limit=1, order="sequence asc")
|
|
return [
|
|
(
|
|
dict(vals, folder_id=vals.get('folder_id', default_folder.id))
|
|
if vals.get('handler') == 'spreadsheet'
|
|
else vals
|
|
)
|
|
for vals in vals_list
|
|
]
|
|
|
|
def _update_spreadsheet_contributors(self):
|
|
"""Add the current user to the spreadsheet contributors.
|
|
"""
|
|
for document in self:
|
|
if document.handler == 'spreadsheet':
|
|
self.env['spreadsheet.contributor']._update(self.env.user, document)
|
|
|
|
@api.model
|
|
def action_open_new_spreadsheet(self, vals=None):
|
|
if vals is None:
|
|
vals = {}
|
|
spreadsheet = self.create({
|
|
"name": _("Untitled spreadsheet"),
|
|
"mimetype": "application/o-spreadsheet",
|
|
"datas": self._empty_spreadsheet_data_base64(),
|
|
"handler": "spreadsheet",
|
|
**vals,
|
|
})
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "action_open_spreadsheet",
|
|
"params": {
|
|
"spreadsheet_id": spreadsheet.id,
|
|
"is_new_spreadsheet": True,
|
|
},
|
|
}
|
|
|
|
@api.model
|
|
def get_spreadsheets_to_display(self, domain, offset=0, limit=None):
|
|
"""
|
|
Get all the spreadsheets, with the spreadsheet that the user has recently
|
|
opened at first.
|
|
"""
|
|
Contrib = self.env["spreadsheet.contributor"]
|
|
visible_docs = self.search(expression.AND([domain, [("handler", "=", "spreadsheet")]]))
|
|
contribs = Contrib.search(
|
|
[
|
|
("document_id", "in", visible_docs.ids),
|
|
("user_id", "=", self.env.user.id),
|
|
],
|
|
order="last_update_date desc",
|
|
)
|
|
user_docs = contribs.document_id
|
|
# Intersection is used to apply the `domain` to `user_doc`, the union is
|
|
# here to keep only the visible docs, but with the order of contribs.
|
|
docs = ((user_docs & visible_docs) | visible_docs)
|
|
if (limit):
|
|
docs = docs[offset:offset + limit]
|
|
else:
|
|
docs = docs[offset:]
|
|
return docs.read(["name", "thumbnail"])
|
|
|
|
def clone_xlsx_into_spreadsheet(self, archive_source=False):
|
|
"""Clone an XLSX document into a new document with its content unzipped, and return the new document id"""
|
|
|
|
self.ensure_one()
|
|
|
|
unzipped, attachments = self._unzip_xlsx()
|
|
|
|
doc = self.copy({
|
|
"handler": "spreadsheet",
|
|
"mimetype": "application/o-spreadsheet",
|
|
"name": self.name.rstrip(".xlsx"),
|
|
"spreadsheet_data": json.dumps(unzipped)
|
|
})
|
|
|
|
for attachment in attachments:
|
|
attachment.write({'res_id': doc.id})
|
|
|
|
if archive_source:
|
|
self.action_archive()
|
|
|
|
return doc.id
|
|
|
|
def _get_is_multipage(self):
|
|
"""Override for spreadsheets and xlsx."""
|
|
is_multipage = super()._get_is_multipage()
|
|
if is_multipage is not None:
|
|
return is_multipage
|
|
|
|
if self.mimetype in XLSX_MIME_TYPES and self.attachment_id.raw:
|
|
file = io.BytesIO(self.attachment_id.raw)
|
|
if not zipfile.is_zipfile(file):
|
|
return None
|
|
with zipfile.ZipFile(file) as archive:
|
|
if '[Content_Types].xml' not in archive.namelist():
|
|
# the xlsx file is invalid
|
|
return None
|
|
with archive.open("[Content_Types].xml") as myfile:
|
|
content = myfile.read()
|
|
tree = etree.fromstring(content)
|
|
nodes = tree.xpath(
|
|
"//ns:Override[@ContentType='application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml']",
|
|
namespaces={"ns": "http://schemas.openxmlformats.org/package/2006/content-types"}
|
|
)
|
|
return len(nodes) > 1
|
|
|
|
if self.handler == "spreadsheet":
|
|
spreadsheet_data = json.loads(self.spreadsheet_data)
|
|
if spreadsheet_data.get("sheets"):
|
|
return len(spreadsheet_data["sheets"]) > 1
|
|
return self._is_xlsx_data_multipage(spreadsheet_data)
|
|
|
|
@api.model
|
|
def _is_xlsx_data_multipage(self, spreadsheet_data):
|
|
for filename, content in spreadsheet_data.items():
|
|
if filename.endswith("workbook.xml.rels"):
|
|
tree = etree.fromstring(content.encode())
|
|
nodes = tree.findall(
|
|
'.//rels:Relationship',
|
|
{'rels': 'http://schemas.openxmlformats.org/package/2006/relationships'}
|
|
)
|
|
found_first_sheet = False
|
|
for node in nodes:
|
|
if node.attrib["Type"].endswith('/relationships/worksheet'):
|
|
if found_first_sheet:
|
|
return True
|
|
found_first_sheet = True
|
|
break
|
|
|
|
return False
|
|
|
|
def _unzip_xlsx(self):
|
|
file = io.BytesIO(self.attachment_id.raw)
|
|
if not zipfile.is_zipfile(file) or self.mimetype not in XLSX_MIME_TYPES:
|
|
raise XSLXReadUserError(_("The file is not a xlsx file"))
|
|
|
|
unzipped_size = 0
|
|
with zipfile.ZipFile(file) as input_zip:
|
|
if len(input_zip.infolist()) > 1000:
|
|
raise XSLXReadUserError(_("The xlsx file is too big"))
|
|
|
|
if "[Content_Types].xml" not in input_zip.namelist() or \
|
|
not any(name.startswith("xl/") for name in input_zip.namelist()):
|
|
raise XSLXReadUserError(_("The xlsx file is corrupted"))
|
|
|
|
unzipped = {}
|
|
attachments = []
|
|
for info in input_zip.infolist():
|
|
if not (info.filename.endswith((".xml", ".xml.rels")) or "media/image" in info.filename) or\
|
|
not info.filename.startswith(SUPPORTED_PATHS):
|
|
# Don't extract files others than xmls or unsupported xmls
|
|
continue
|
|
|
|
unzipped_size += info.file_size
|
|
if unzipped_size > 50 * 1000 * 1000: # 50MB
|
|
raise XSLXReadUserError(_("The xlsx file is too big"))
|
|
|
|
if info.filename.endswith((".xml", ".xml.rels")):
|
|
unzipped[info.filename] = input_zip.read(info.filename).decode()
|
|
elif "media/image" in info.filename:
|
|
image_file = input_zip.read(info.filename)
|
|
attachment = self._upload_image_file(image_file, info.filename)
|
|
attachments.append(attachment)
|
|
unzipped[info.filename] = {
|
|
"imageSrc": "/web/image/" + str(attachment.id),
|
|
}
|
|
return unzipped, attachments
|
|
|
|
def _upload_image_file(self, image_file, filename):
|
|
attachment_model = self.env['ir.attachment']
|
|
attachment = attachment_model.create({
|
|
'name': filename,
|
|
'datas': base64.encodebytes(image_file),
|
|
'res_model': "documents.document",
|
|
})
|
|
attachment._post_add_create()
|
|
return attachment
|
|
|
|
@api.autovacuum
|
|
def _gc_spreadsheet(self):
|
|
yesterday = datetime.datetime.utcnow() - datetime.timedelta(days=1)
|
|
domain = [
|
|
('handler', '=', 'spreadsheet'),
|
|
('create_date', '<', yesterday),
|
|
('spreadsheet_revision_ids', '=', False),
|
|
('spreadsheet_snapshot', '=', False),
|
|
('previous_attachment_ids', '=', False)
|
|
]
|
|
self.search(domain).action_archive()
|
|
|
|
def action_edit(self):
|
|
self.ensure_one()
|
|
return {
|
|
'type': 'ir.actions.client',
|
|
'tag': 'action_open_spreadsheet',
|
|
'params': {
|
|
'spreadsheet_id': self.id,
|
|
}
|
|
}
|
|
|
|
def _creation_msg(self):
|
|
return _("New spreadsheet created in Documents")
|
|
|
|
class XSLXReadUserError(UserError):
|
|
pass
|