1
0
forked from Mapan/odoo17e
odoo17e-kedaikipas58/addons/spreadsheet_edition/models/spreadsheet_mixin.py
2024-12-10 09:04:09 +07:00

457 lines
20 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import json
import logging
import base64
import psycopg2
import uuid
from datetime import timedelta
from typing import Dict, Any, List, Optional
from odoo import _, fields, models, api
from odoo.exceptions import AccessError, UserError
from odoo.tools import mute_logger
_logger = logging.getLogger(__name__)
CollaborationMessage = Dict[str, Any]
class SpreadsheetMixin(models.AbstractModel):
_inherit = "spreadsheet.mixin"
spreadsheet_snapshot = fields.Binary()
spreadsheet_revision_ids = fields.One2many(
"spreadsheet.revision",
"res_id",
domain=lambda self: [('res_model', '=', self._name)],
groups="base.group_system",
)
# The last revision id known by the current transaction.
# Another concurrent transaction can have saved a revision after this one
# This means it cannot be blindly used to dispatch a new revision.
server_revision_id = fields.Char(compute="_compute_server_revision_id", compute_sudo=True)
@api.depends("spreadsheet_revision_ids", "spreadsheet_snapshot", "spreadsheet_data")
def _compute_server_revision_id(self):
for spreadsheet in self:
revisions = spreadsheet.spreadsheet_revision_ids
if revisions:
spreadsheet.server_revision_id = revisions[-1].revision_id
else:
snapshot = spreadsheet._get_spreadsheet_snapshot()
if snapshot is False:
spreadsheet.server_revision_id = False
else:
spreadsheet.server_revision_id = snapshot.get("revisionId", "START_REVISION")
def write(self, vals):
if "spreadsheet_binary_data" in vals and not self.env.context.get("preserve_spreadsheet_revisions"):
self._delete_collaborative_data()
return super().write(vals)
def copy(self, default=None):
default = default or {}
self.ensure_one()
if "spreadsheet_data" not in default:
default["spreadsheet_data"] = self.spreadsheet_data
self = self.with_context(preserve_spreadsheet_revisions=True)
new_spreadsheet = super().copy(default)
if not default or "spreadsheet_revision_ids" not in default:
self._copy_revisions_to(new_spreadsheet)
new_spreadsheet._copy_spreadsheet_image_attachments()
return new_spreadsheet
@api.model_create_multi
def create(self, vals_list):
spreadsheets = super().create(vals_list)
for spreadsheet in spreadsheets:
spreadsheet._copy_spreadsheet_image_attachments()
return spreadsheets
def join_spreadsheet_session(self, share_id=None, access_token=None):
"""Join a spreadsheet session.
Returns the following data::
- the last snapshot
- pending revisions since the last snapshot
- the spreadsheet name
- whether the user favorited the spreadsheet or not
- whether the user can edit the content of the spreadsheet or not
"""
self.ensure_one()
self._check_collaborative_spreadsheet_access("read", share_id, access_token)
can_write = self._check_collaborative_spreadsheet_access(
"write", share_id, access_token, raise_exception=False
)
spreadsheet_sudo = self.sudo()
return {
"id": spreadsheet_sudo.id,
"name": spreadsheet_sudo.display_name or "",
"data": spreadsheet_sudo._get_spreadsheet_snapshot(),
"revisions": spreadsheet_sudo._build_spreadsheet_messages(),
"snapshot_requested": can_write and spreadsheet_sudo._should_be_snapshotted(),
"isReadonly": not can_write,
"default_currency": self.env["res.currency"].get_company_currency_for_spreadsheet(),
"user_locale": self.env["res.lang"]._get_user_spreadsheet_locale()
}
def dispatch_spreadsheet_message(self, message: CollaborationMessage, share_id=None, access_token=None):
"""This is the entry point of collaborative editing.
Collaboration messages arrive here. For each received messages,
the server decides if it's accepted or not. If the message is
accepted, it's transmitted to all clients through the "bus.bus".
Messages which do not update the spreadsheet state (a client moved
joined or left) are always accepted. Messages updating the state
require special care.
Refused messages
----------------
An important aspect of collaborative messages is their order. The server
checks the order of received messages. If one is out of order, it is refused.
How does it check the order?
Each message has a `serverRevisionId` property which is the revision on which
it should be applied. If it's not equal to the current known revision by the server,
it is out of order and refused.
Accepted messages
-----------------
If the message is found to be in order, it's accepted and the server registers it.
The current server revision becomes the revision carried by the message, in the
`nextRevisionId` property.
With this strategy, we are guaranteed that all accepted message are ordered.
See `_spreadsheet_revision_is_accepted`.
:param message: collaborative message to process
:return: if the message was accepted or not.
:rtype: bool
"""
self.ensure_one()
if message["type"] in ["REMOTE_REVISION", "REVISION_UNDONE", "REVISION_REDONE"]:
self._check_collaborative_spreadsheet_access("write", share_id, access_token)
is_accepted = self.sudo()._save_concurrent_revision(
message["nextRevisionId"],
message["serverRevisionId"],
self._build_spreadsheet_revision_data(message),
)
if is_accepted:
self._broadcast_spreadsheet_message(message)
return is_accepted
elif message["type"] == "SNAPSHOT":
self._check_collaborative_spreadsheet_access("write", share_id, access_token)
return self.sudo()._snapshot_spreadsheet(
message["serverRevisionId"], message["nextRevisionId"], message["data"]
)
elif message["type"] in ["CLIENT_JOINED", "CLIENT_LEFT", "CLIENT_MOVED"]:
self._check_collaborative_spreadsheet_access("read", share_id, access_token)
self._broadcast_spreadsheet_message(message)
return True
return False
def _copy_revisions_to(self, spreadsheet, up_to_revision_id=False):
self._check_collaborative_spreadsheet_access("read")
revisions_data = []
if up_to_revision_id:
revisions = self.sudo().spreadsheet_revision_ids.filtered(
lambda r: r.id <= up_to_revision_id
)
else:
revisions = self.sudo().spreadsheet_revision_ids
for revision in revisions:
revisions_data += revision.copy_data({
"res_model": spreadsheet._name,
"res_id": spreadsheet.id,
})
spreadsheet._check_collaborative_spreadsheet_access("write")
revisions = self.env["spreadsheet.revision"].sudo().create(revisions_data)
spreadsheet.sudo().spreadsheet_revision_ids = revisions
def save_spreadsheet_snapshot(self, snapshot_data):
data_revision_uuid = snapshot_data.get("revisionId")
snapshot_uuid = str(uuid.uuid4())
snapshot_data["revisionId"] = snapshot_uuid
revision = {
"type": "SNAPSHOT",
"serverRevisionId": data_revision_uuid,
"nextRevisionId": snapshot_uuid,
"data": snapshot_data,
}
is_accepted = self.dispatch_spreadsheet_message(revision)
if not is_accepted:
raise UserError(_("The operation could not be applied because of a concurrent update. Please try again."))
def _snapshot_spreadsheet(
self, revision_id: str, snapshot_revision_id, spreadsheet_snapshot: dict
):
"""Save the spreadsheet snapshot along the revision id. Delete previous
revisions which are no longer needed.
If the `revision_id` is not the same as the server revision, the snapshot is
not accepted and is ignored.
:param revision_id: the revision on which the snapshot is based
:param snapshot_revision_id: snapshot revision
:param spreadsheet_snapshot: spreadsheet data
:return: True if the snapshot was saved, False otherwise
"""
if snapshot_revision_id != spreadsheet_snapshot.get("revisionId"):
raise ValueError("The snapshot revision id does not match the revision id")
is_accepted = self._save_concurrent_revision(
snapshot_revision_id,
revision_id,
{"type": "SNAPSHOT_CREATED", "version": 1},
)
if is_accepted:
self.spreadsheet_snapshot = base64.b64encode(
json.dumps(spreadsheet_snapshot).encode("utf-8")
)
self.spreadsheet_revision_ids.active = False
self._broadcast_spreadsheet_message(
{
"type": "SNAPSHOT_CREATED",
"serverRevisionId": revision_id,
"nextRevisionId": snapshot_revision_id,
}
)
return is_accepted
def _get_spreadsheet_snapshot(self):
if self.spreadsheet_snapshot is False and self.spreadsheet_data is False:
return False
elif self.spreadsheet_snapshot is False:
return json.loads(self.spreadsheet_data)
return json.loads(base64.decodebytes(self.spreadsheet_snapshot))
def _should_be_snapshotted(self):
if not self.spreadsheet_revision_ids:
return False
last_activity = max(self.spreadsheet_revision_ids.mapped("create_date"))
return last_activity < fields.Datetime.now() - timedelta(hours=2)
def _save_concurrent_revision(self, next_revision_id, parent_revision_id, commands):
"""Save the given revision if no concurrency issue is found.
i.e. if no other revision was saved based on the same `parent_revision_id`
:param next_revision_id: the new revision id
:param parent_revision_id: the revision on which the commands are based
:param commands: revisions commands
:return: True if the revision was saved, False otherwise
"""
self.ensure_one()
try:
with mute_logger("odoo.sql_db"):
self.env["spreadsheet.revision"].create(
{
"res_model": self._name,
"res_id": self.id,
"commands": json.dumps(commands),
"parent_revision_id": parent_revision_id,
"revision_id": next_revision_id,
"create_date": fields.Datetime.now(),
}
)
return True
except psycopg2.IntegrityError:
# If the creation failed with a unique violation error, it is because the parent_revision_id has already
# been used. This means that at the same (relative) time, another user has made a modification to the
# document while this user also modified the document, without knowing about each other modification.
# We don't need to do anything: when the client that already did the modification will be done, the
# situation will resolve itself when this client receives the other client's modification.
_logger.info("Wrong base spreadsheet revision on %s", self)
return False
def _build_spreadsheet_revision_data(self, message: CollaborationMessage) -> dict:
"""Prepare revision data to save in the database from
the collaboration message.
"""
message = dict(message)
message.pop("serverRevisionId", None)
message.pop("nextRevisionId", None)
message.pop("clientId", None)
return message
def _build_spreadsheet_messages(self) -> List[CollaborationMessage]:
"""Build spreadsheet collaboration messages from the saved
revision data"""
self.ensure_one()
return [
dict(
json.loads(rev.commands),
serverRevisionId=rev.parent_revision_id,
nextRevisionId=rev.revision_id,
)
for rev in self.spreadsheet_revision_ids
]
def _check_collaborative_spreadsheet_access(
self, operation: str, share_id=None, access_token=None, *, raise_exception=True
):
"""Check that the user has the right to read/write on the document.
It's used to ensure that a user can read/write the spreadsheet revisions
of this document.
"""
try:
if share_id and access_token:
self._check_spreadsheet_share(operation, share_id, access_token)
else:
self.check_access_rights(operation)
self.check_access_rule(operation)
except AccessError as e:
if raise_exception:
raise e
return False
return True
def _check_spreadsheet_share(self, operation, share_id, access_token):
"""Delegates the sharing check to the underlying model which might
implement sharing in different ways.
"""
raise AccessError(_("You are not allowed to access this spreadsheet."))
def _broadcast_spreadsheet_message(self, message: CollaborationMessage):
"""Send the message to the spreadsheet channel"""
self.ensure_one()
self.env["bus.bus"]._sendone(self, "spreadsheet", dict(message, id=self.id))
def _delete_collaborative_data(self):
self.spreadsheet_snapshot = False
self._check_collaborative_spreadsheet_access("write")
self.with_context(active_test=False).sudo().spreadsheet_revision_ids.unlink()
def unlink(self):
""" Override unlink to delete spreadsheet revision. This cannot be
cascaded, because link is done through (res_model, res_id). """
if not self:
return True
self.sudo().with_context(active_test=False).spreadsheet_revision_ids.unlink()
return super().unlink()
def action_edit(self):
raise NotImplementedError("This method is not implemented in class %s." % self._name)
@api.model
def _creation_msg(self):
raise NotImplementedError("This method is not implemented in class %s." % self._name)
def get_spreadsheet_history(self, from_snapshot=False):
"""Fetch the spreadsheet history.
- if from_snapshot is provided, then provides the last snapshot and the revisions since then
- otherwise, returns the empty skeleton of the spreadsheet with all the revisions since its creation
"""
self.ensure_one()
self._check_collaborative_spreadsheet_access("read")
spreadsheet_sudo = self.sudo()
if from_snapshot:
data = spreadsheet_sudo._get_spreadsheet_snapshot()
revisions = spreadsheet_sudo.spreadsheet_revision_ids
else:
data = json.loads(self.spreadsheet_data)
revisions = spreadsheet_sudo.with_context(active_test=False).spreadsheet_revision_ids
return {
"name": spreadsheet_sudo.display_name,
"data": data,
"revisions": [
dict(
json.loads(rev.commands),
id=rev.id,
name=rev.name,
user=(rev.create_uid.id, rev.create_uid.name),
serverRevisionId=rev.parent_revision_id,
nextRevisionId=rev.revision_id,
timestamp=rev.create_date,
)
for rev in revisions
],
}
def rename_revision(self, revision_id, name):
self.ensure_one()
self._check_collaborative_spreadsheet_access("write")
self.env["spreadsheet.revision"].sudo().browse(revision_id).name = name
def fork_history(self, revision_id: int, spreadsheet_snapshot: dict, default: Optional[dict] = None):
self.ensure_one()
default = default or {}
default['spreadsheet_revision_ids'] = []
default['spreadsheet_data'] = self.spreadsheet_data
new_spreadsheet = self.copy(default)
self.with_context(active_test=False)._copy_revisions_to(new_spreadsheet, revision_id)
new_spreadsheet.spreadsheet_snapshot = base64.b64encode(json.dumps(spreadsheet_snapshot).encode())
new_spreadsheet.spreadsheet_revision_ids.active = False
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'type': 'info',
'message': self._creation_msg(),
'next': new_spreadsheet.action_edit(),
}
}
def _dispatch_command(self, command):
is_accepted = self.dispatch_spreadsheet_message(self._build_new_revision_data(command))
if not is_accepted:
raise UserError(_("The operation could not be applied because of a concurrent update. Please try again."))
def _build_new_revision_data(self, command):
return {
"type": "REMOTE_REVISION",
"serverRevisionId": self.server_revision_id,
"nextRevisionId": str(uuid.uuid4()),
"commands": [command],
}
def _copy_spreadsheet_image_attachments(self):
"""Ensures the image attachments are linked to the spreadsheet record
and duplicates them if necessary and updates the spreadsheet data and revisions to
point to the new attachments."""
self._check_collaborative_spreadsheet_access("write")
revisions = self.sudo().with_context(active_test=False).spreadsheet_revision_ids
mapping = {} # old_attachment_id: new_attachment
revisions_with_images = revisions.filtered(lambda r: "CREATE_IMAGE" in r.commands)
for revision in revisions_with_images:
data = json.loads(revision.commands)
commands = data.get("commands", [])
for command in commands:
if command["type"] == "CREATE_IMAGE" and command["definition"]["path"].startswith("/web/image/"):
attachment_copy = self._get_spreadsheet_image_attachment(command["definition"]["path"], mapping)
if attachment_copy:
command["definition"]["path"] = get_attachment_image_src(command["definition"]["path"], attachment_copy)
revision.commands = json.dumps(data)
data = json.loads(self.spreadsheet_data)
self._copy_spreadsheet_images_data(data, mapping)
if self.spreadsheet_snapshot:
snapshot = self._get_spreadsheet_snapshot()
self._copy_spreadsheet_images_data(snapshot, mapping)
if mapping:
self.with_context(preserve_spreadsheet_revisions=True).spreadsheet_data = json.dumps(data)
if self.spreadsheet_snapshot:
self.spreadsheet_snapshot = base64.encodebytes(json.dumps(snapshot).encode())
def _copy_spreadsheet_images_data(self, data, mapping):
for sheet in data.get("sheets", []):
for figure in sheet.get("figures", []):
if figure["tag"] == "image" and figure["data"]["path"].startswith("/web/image/"):
attachment_copy = self._get_spreadsheet_image_attachment(figure["data"]["path"], mapping)
if attachment_copy:
figure["data"]["path"] = get_attachment_image_src(figure["data"]["path"], attachment_copy)
def _get_spreadsheet_image_attachment(self, path: str, mapping):
attachment_id = int(path.split("/")[3].split("?")[0])
attachment = self.env["ir.attachment"].browse(attachment_id).exists()
if attachment and (attachment.res_model != self._name or attachment.res_id != self.id):
attachment_copy = mapping.get(attachment_id) or attachment.copy({"res_model": self._name, "res_id": self.id})
mapping[attachment_id] = attachment_copy
return attachment_copy
return self.env["ir.attachment"]
def get_attachment_image_src(original_path, attachment_copy):
has_access_token = f"access_token={attachment_copy.access_token}" in original_path
if has_access_token:
return f"/web/image/{attachment_copy.id}?access_token={attachment_copy.access_token}"
return f"/web/image/{attachment_copy.id}"