742 lines
24 KiB
Python
742 lines
24 KiB
Python
# Copyright (C) 2022 Cetmix OÜ
|
|
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
|
|
from base64 import b64decode, b64encode
|
|
|
|
from dateutil.relativedelta import relativedelta
|
|
|
|
from odoo import _, api, fields, models
|
|
from odoo.exceptions import AccessError, UserError, ValidationError
|
|
from odoo.tools import exception_to_unicode
|
|
|
|
# mapping of field names from template and field names from file
|
|
TEMPLATE_FILE_FIELD_MAPPING = {
|
|
"code": "code",
|
|
"file_name": "name",
|
|
"file_type": "file_type",
|
|
"server_dir": "server_dir",
|
|
"keep_when_deleted": "keep_when_deleted",
|
|
"auto_sync": "auto_sync",
|
|
}
|
|
|
|
# to convert to 'relativedelta' object
|
|
INTERVAL_TYPES = {
|
|
"minutes": lambda interval: relativedelta(minutes=interval),
|
|
"hours": lambda interval: relativedelta(hours=interval),
|
|
"days": lambda interval: relativedelta(days=interval),
|
|
"weeks": lambda interval: relativedelta(days=7 * interval),
|
|
"months": lambda interval: relativedelta(months=interval),
|
|
"years": lambda interval: relativedelta(years=interval),
|
|
}
|
|
|
|
|
|
class CxTowerFile(models.Model):
|
|
"""Files"""
|
|
|
|
_name = "cx.tower.file"
|
|
_inherit = [
|
|
"cx.tower.template.mixin",
|
|
"cx.tower.reference.mixin",
|
|
"mail.thread",
|
|
"mail.activity.mixin",
|
|
"cx.tower.key.mixin",
|
|
]
|
|
_description = "Cetmix Tower File"
|
|
_order = "name"
|
|
|
|
active = fields.Boolean(default=True)
|
|
name = fields.Char(help="File name WITHOUT path. Eg 'test.txt'")
|
|
rendered_name = fields.Char(
|
|
compute="_compute_render",
|
|
compute_sudo=True,
|
|
)
|
|
template_id = fields.Many2one(
|
|
"cx.tower.file.template",
|
|
inverse="_inverse_template_id",
|
|
index=True,
|
|
)
|
|
server_dir = fields.Char(
|
|
string="Directory on Server",
|
|
required=True,
|
|
default="",
|
|
help="Eg '/home/user' or '/var/log'",
|
|
)
|
|
rendered_server_dir = fields.Char(
|
|
compute="_compute_render",
|
|
compute_sudo=True,
|
|
)
|
|
full_server_path = fields.Char(
|
|
string="Full Path",
|
|
compute="_compute_render",
|
|
compute_sudo=True,
|
|
)
|
|
source = fields.Selection(
|
|
[
|
|
("tower", "Tower"),
|
|
("server", "Server"),
|
|
],
|
|
help="""
|
|
- Tower: file is pushed from Tower to server.
|
|
- Server: file is pulled from server to Tower.
|
|
""",
|
|
)
|
|
auto_sync = fields.Boolean(
|
|
help="If enabled file will be synced automatically using cron",
|
|
default=False,
|
|
)
|
|
# selection format: interval_number(integer)-interval_type(name of interval)
|
|
# it will be parsed as 'relativedelta' object
|
|
auto_sync_interval = fields.Selection(
|
|
selection=lambda self: self._selection_auto_sync_interval(),
|
|
)
|
|
sync_date_next = fields.Datetime(
|
|
string="Next Sync Date",
|
|
required=True,
|
|
default=fields.Datetime.now,
|
|
help="Date and time of the next synchronisation",
|
|
)
|
|
sync_date_last = fields.Datetime(
|
|
string="Last Sync Date",
|
|
readonly=True,
|
|
tracking=True,
|
|
help="Date and time of the latest successful synchronisation",
|
|
)
|
|
server_response = fields.Text(
|
|
copy=False,
|
|
help="Server response received during the last operation.\n"
|
|
"Default value if no error happened is 'ok'.\n"
|
|
"Otherwise there will be a server error message logged.",
|
|
)
|
|
server_id = fields.Many2one(
|
|
comodel_name="cx.tower.server", required=False, ondelete="cascade"
|
|
)
|
|
code_on_server = fields.Text(
|
|
readonly=True,
|
|
help="Latest version of file content on server",
|
|
)
|
|
rendered_code = fields.Char(
|
|
compute="_compute_render",
|
|
compute_sudo=True,
|
|
help="File content with variables rendered",
|
|
)
|
|
keep_when_deleted = fields.Boolean(
|
|
help="File will be kept on server when deleted in Tower",
|
|
)
|
|
file_type = fields.Selection(
|
|
selection=lambda self: self._selection_file_type(),
|
|
default=lambda self: self._default_file_type(),
|
|
required=True,
|
|
)
|
|
file = fields.Binary(
|
|
string="Binary Content",
|
|
attachment=True,
|
|
)
|
|
variable_ids = fields.Many2many(
|
|
comodel_name="cx.tower.variable",
|
|
relation="cx_tower_file_variable_rel",
|
|
column1="file_id",
|
|
column2="variable_id",
|
|
)
|
|
|
|
@classmethod
|
|
def _get_depends_fields(cls):
|
|
"""
|
|
Define dependent fields for computing `variable_ids` in file-related models.
|
|
|
|
This implementation specifies that the fields `code`, `server_dir`,
|
|
and `name` are used to compute the variables associated with a file.
|
|
|
|
Returns:
|
|
list: A list of field names (str) representing the dependencies.
|
|
|
|
Example:
|
|
The following fields trigger recomputation of `variable_ids`:
|
|
- `code`: The content of the file.
|
|
- `server_dir`: The directory on the server where the file is located.
|
|
- `name`: The name of the file.
|
|
"""
|
|
return ["code", "server_dir", "name"]
|
|
|
|
# -- Selection
|
|
def _selection_file_type(self):
|
|
"""Available file types
|
|
|
|
Returns:
|
|
List of tuples: available options.
|
|
"""
|
|
return [
|
|
("text", "Text"),
|
|
("binary", "Binary"),
|
|
]
|
|
|
|
def _selection_auto_sync_interval(self):
|
|
"""
|
|
Selection of auto sync interval
|
|
"""
|
|
return [
|
|
("10-minutes", "10 min"),
|
|
("30-minutes", "30 min"),
|
|
("1-hours", "1 hour"),
|
|
("2-hours", "2 hour"),
|
|
("6-hours", "6 hour"),
|
|
("12-hours", "12 hour"),
|
|
("1-days", "1 day"),
|
|
("1-weeks", "1 week"),
|
|
("1-months", "1 month"),
|
|
("1-years", "1 year"),
|
|
]
|
|
|
|
# -- Defaults
|
|
def _default_file_type(self):
|
|
"""Default file type
|
|
|
|
Returns:
|
|
Char: `file_type` field selection value
|
|
"""
|
|
return "text"
|
|
|
|
# -- Computes
|
|
@api.depends("server_id", "template_id", "name", "server_dir", "code")
|
|
def _compute_render(self):
|
|
"""
|
|
Compute file name, directory and code
|
|
"""
|
|
for file in self:
|
|
if not file.server_id:
|
|
file.update(
|
|
{
|
|
"rendered_name": False,
|
|
"rendered_server_dir": False,
|
|
"rendered_code": False,
|
|
"full_server_path": False,
|
|
}
|
|
)
|
|
continue
|
|
variables = list(
|
|
set(
|
|
file.get_variables_from_code(file.name)
|
|
+ file.get_variables_from_code(file.server_dir)
|
|
+ file.get_variables_from_code(file.code)
|
|
)
|
|
)
|
|
render_code_custom = file.render_code_custom
|
|
var_vals = file.server_id.get_variable_values(variables).get(
|
|
file.server_id.id
|
|
)
|
|
|
|
rendered_code = ""
|
|
if file.file_type == "text" and file.source == "tower":
|
|
rendered_code = (
|
|
var_vals
|
|
and file.code
|
|
and render_code_custom(file.code, **var_vals)
|
|
or file.code
|
|
)
|
|
rendered_name = (
|
|
var_vals
|
|
and file.name
|
|
and render_code_custom(file.name, **var_vals)
|
|
or file.name
|
|
)
|
|
rendered_server_dir = (
|
|
var_vals
|
|
and file.server_dir
|
|
and render_code_custom(file.server_dir, **var_vals)
|
|
or file.server_dir
|
|
)
|
|
file.update(
|
|
{
|
|
"rendered_name": rendered_name,
|
|
"rendered_server_dir": rendered_server_dir,
|
|
"rendered_code": rendered_code,
|
|
"full_server_path": f"{rendered_server_dir}/{rendered_name}",
|
|
}
|
|
)
|
|
|
|
# -- Onchange
|
|
@api.onchange("template_id")
|
|
def _onchange_template_id(self):
|
|
"""
|
|
Update file data by template values
|
|
"""
|
|
for file in self:
|
|
if file.template_id:
|
|
file.update(file._get_file_values_from_related_template())
|
|
|
|
@api.onchange("source")
|
|
def _onchange_source(self):
|
|
"""
|
|
Reset file template after change source
|
|
"""
|
|
self.update({"template_id": False})
|
|
|
|
def _inverse_template_id(self):
|
|
"""
|
|
Replace file fields values by template values
|
|
"""
|
|
for file in self:
|
|
if file.template_id:
|
|
file.write(file._get_file_values_from_related_template())
|
|
|
|
# -- Create/Write/Unlink
|
|
@api.model_create_multi
|
|
def create(self, vals_list):
|
|
"""
|
|
Override to sync files
|
|
"""
|
|
vals_list = [self._sanitize_values(vals) for vals in vals_list]
|
|
records = super().create(vals_list)
|
|
records._post_create_write("create")
|
|
return records
|
|
|
|
def write(self, vals):
|
|
"""
|
|
Override to sync files from tower
|
|
"""
|
|
vals = self._sanitize_values(vals)
|
|
result = super().write(vals)
|
|
|
|
# sync tower files after change
|
|
sync_fields = self._get_tower_sync_field_names()
|
|
files_to_sync = self.filtered(
|
|
lambda file: file.auto_sync
|
|
and file.source == "tower"
|
|
and any(field in vals for field in sync_fields)
|
|
)
|
|
if files_to_sync:
|
|
files_to_sync._post_create_write("write")
|
|
return result
|
|
|
|
def unlink(self):
|
|
"""
|
|
Override to delete from server tower files with
|
|
`keep_when_deleted` set to False
|
|
"""
|
|
self.filtered(
|
|
lambda file_: (
|
|
file_.server_id
|
|
and file_.source == "tower"
|
|
and not file_.keep_when_deleted
|
|
)
|
|
).delete()
|
|
return super().unlink()
|
|
|
|
# -- Actions
|
|
def action_unlink_from_template(self):
|
|
"""
|
|
Unlink file from template to make it editable
|
|
"""
|
|
self.ensure_one()
|
|
self.template_id = False
|
|
|
|
def action_push_to_server(self):
|
|
"""
|
|
Push the file to server
|
|
"""
|
|
server_files = self.filtered(lambda file_: file_.source == "server")
|
|
if server_files:
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": _("Failure"),
|
|
"message": _(
|
|
"Unable to upload file '%(f)s'.\n"
|
|
"Upload operation is not supported for 'server' type files.",
|
|
f=server_files[0].rendered_name,
|
|
),
|
|
"sticky": False,
|
|
},
|
|
}
|
|
self.upload(raise_error=True)
|
|
single_msg = _("File uploaded!")
|
|
plural_msg = _("Files uploaded!")
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": _("Success"),
|
|
"message": single_msg if len(self) == 1 else plural_msg,
|
|
"sticky": False,
|
|
},
|
|
}
|
|
|
|
def action_pull_from_server(self):
|
|
"""
|
|
Pull file from server
|
|
"""
|
|
tower_files = self.filtered(lambda file_: file_.source == "tower")
|
|
server_files = self - tower_files
|
|
tower_files.action_get_current_server_code()
|
|
res = server_files.download(raise_error=True)
|
|
if isinstance(res, dict):
|
|
return res
|
|
|
|
single_msg = _("File downloaded!")
|
|
plural_msg = _("Files downloaded!")
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": _("Success"),
|
|
"message": single_msg if len(self) == 1 else plural_msg,
|
|
"sticky": False,
|
|
},
|
|
}
|
|
|
|
def action_delete_from_server(self):
|
|
"""
|
|
Delete file from server
|
|
"""
|
|
server_files = self.filtered(lambda file_: file_.source == "server")
|
|
if server_files:
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": _("Failure"),
|
|
"message": _(
|
|
"Unable to delete file '%(f)s'.\n"
|
|
"Delete operation is not supported for 'server' type files.",
|
|
f=server_files[0].rendered_name,
|
|
),
|
|
"sticky": False,
|
|
},
|
|
}
|
|
self.delete(raise_error=True)
|
|
single_msg = _("File deleted!")
|
|
plural_msg = _("Files deleted!")
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": _("Success"),
|
|
"message": single_msg if len(self) == 1 else plural_msg,
|
|
"sticky": False,
|
|
},
|
|
}
|
|
|
|
def action_get_current_server_code(self):
|
|
"""
|
|
Get actual file code from server
|
|
"""
|
|
for file in self:
|
|
if file.source != "tower":
|
|
raise UserError(
|
|
_(
|
|
"File %(f)s is not 'tower' type. "
|
|
"This operation is supported for 'tower' "
|
|
"files only",
|
|
f=file.name,
|
|
)
|
|
)
|
|
|
|
# Calling `_process` directly to get server version of a `tower` file
|
|
res = self.with_context(is_server_code_version_process=True)._process(
|
|
"download"
|
|
)
|
|
# Type check because _process method could return
|
|
# a display_notification action dict
|
|
if isinstance(res, dict):
|
|
return res
|
|
file.code_on_server = res
|
|
|
|
# -- Business logic
|
|
def _post_create_write(self, op_type="write"):
|
|
"""Helper function that is called after file creation or update.
|
|
Use this function to implement custom hooks.
|
|
|
|
Args:
|
|
op_type (str, optional): Operation type. Defaults to "write".
|
|
Possible options:
|
|
- "create"
|
|
- "write"
|
|
"""
|
|
|
|
# Pull all `auto_sync` server files
|
|
server_files_to_sync = self.filtered(
|
|
lambda file: file.auto_sync and file.source == "server"
|
|
)
|
|
if server_files_to_sync:
|
|
server_files_to_sync.action_pull_from_server()
|
|
|
|
# Push all `auto_sync` tower files
|
|
tower_files_to_sync = self.filtered(
|
|
lambda file: file.auto_sync and file.source == "tower"
|
|
)
|
|
if tower_files_to_sync:
|
|
tower_files_to_sync.action_push_to_server()
|
|
|
|
def _get_file_values_from_related_template(self):
|
|
"""
|
|
Return file values from related template
|
|
"""
|
|
self.ensure_one()
|
|
if not self.template_id:
|
|
return {}
|
|
|
|
values = self.template_id.read(list(TEMPLATE_FILE_FIELD_MAPPING), load=False)[0]
|
|
if (
|
|
self.env.context.get("is_custom_server_dir")
|
|
and self.server_dir
|
|
and "server_dir" in values
|
|
):
|
|
del values["server_dir"]
|
|
|
|
return {
|
|
key: values[name]
|
|
for name, key in TEMPLATE_FILE_FIELD_MAPPING.items()
|
|
if name in values
|
|
}
|
|
|
|
@api.model
|
|
def _sanitize_values(self, values):
|
|
"""
|
|
Check the values and reformat if necessary
|
|
"""
|
|
if "server_dir" in values:
|
|
server_dir = values["server_dir"].strip()
|
|
if server_dir.endswith("/") and server_dir != "/":
|
|
server_dir = server_dir[:-1]
|
|
values.update(
|
|
{
|
|
"server_dir": server_dir,
|
|
}
|
|
)
|
|
return values
|
|
|
|
def download(self, raise_error=False):
|
|
"""Wrapper function for file download.
|
|
Use it for custom hooks implementation.
|
|
|
|
Args:
|
|
raise_error (bool, optional):
|
|
Will raise and exception on error if set to 'True'.
|
|
Defaults to False.
|
|
"""
|
|
return self._process("download", raise_error)
|
|
|
|
def upload(self, raise_error=False):
|
|
"""Wrapper function for file upload.
|
|
Use it for custom hooks implementation.
|
|
|
|
Args:
|
|
raise_error (bool, optional):
|
|
Will raise and exception on error if set to 'True'.
|
|
Defaults to False.
|
|
"""
|
|
self._process("upload", raise_error)
|
|
|
|
def delete(self, raise_error=False):
|
|
"""Wrapper function for file removal.
|
|
Use it for custom hooks implementation.
|
|
|
|
Args:
|
|
raise_error (bool, optional):
|
|
Will raise and exception on error if set to 'True'.
|
|
Defaults to False.
|
|
"""
|
|
self._process("delete", raise_error)
|
|
|
|
def _process_download(
|
|
self,
|
|
tower_key_obj,
|
|
is_server_code_version_process=False,
|
|
):
|
|
"""
|
|
Processing of file download.
|
|
Note: moved this functionality to a separate function from
|
|
the general `_process` method because it is already too complex.
|
|
|
|
Args:
|
|
tower_key_obj (RecordSet): `cx.tower.key`
|
|
recordset to parse file path.
|
|
is_server_code_version_process (bool):
|
|
Flag to fetch actual file content from server
|
|
for a `tower` type file.
|
|
|
|
Returns:
|
|
[dict|str|None]:
|
|
display_notification action dict if there was an error
|
|
during the operation.
|
|
file content if `is_server_code_version_process` is True.
|
|
None otherwise.
|
|
"""
|
|
self.ensure_one()
|
|
code = self.server_id.download_file(
|
|
tower_key_obj._parse_code(self.full_server_path),
|
|
)
|
|
if self.file_type == "text" and b"\x00" in code:
|
|
return {
|
|
"type": "ir.actions.client",
|
|
"tag": "display_notification",
|
|
"params": {
|
|
"title": _("Failure"),
|
|
"message": _(
|
|
"Cannot download %(f)s from server: "
|
|
"Binary content is not supported "
|
|
"for 'Text' file type",
|
|
)
|
|
% {"f": self.rendered_name},
|
|
"sticky": True,
|
|
},
|
|
}
|
|
# In case server version of a 'tower' file is requested
|
|
if is_server_code_version_process:
|
|
return code
|
|
if self.file_type == "binary":
|
|
self.file = b64encode(code)
|
|
else:
|
|
self.code = code
|
|
|
|
def _process(self, action, raise_error=False):
|
|
"""Upload or download file to/from server.
|
|
Important!
|
|
This function will return a value only in case `is_server_code_version_process`
|
|
key is present in context.
|
|
This key is used to fetch actual file content from server
|
|
for a `tower` type file.
|
|
In all other cases it will update the file content and save
|
|
server response into the `server_response` field.
|
|
|
|
|
|
|
|
Args:
|
|
action (Selection): Action to process.
|
|
Possible options:
|
|
- "upload": Upload file.
|
|
- "download": Download file.
|
|
- "delete": Delete file.
|
|
raise_error (bool, optional): Raise exception if there was an error
|
|
during the operation. Defaults to False.
|
|
|
|
Raises:
|
|
UserError: In case file format doesn't match the requested operation.
|
|
Eg if trying to upload 'server' type file.
|
|
ValidationError: In case there is an error while performing
|
|
an action with a file.
|
|
|
|
Returns:
|
|
Char: file content or False.
|
|
"""
|
|
|
|
tower_key_obj = self.env["cx.tower.key"]
|
|
is_server_code_version_process = self.env.context.get(
|
|
"is_server_code_version_process"
|
|
)
|
|
for file in self:
|
|
if not is_server_code_version_process and (
|
|
(action == "download" and file.source != "server")
|
|
or (action == "upload" and file.source != "tower")
|
|
or (action == "delete" and file.source != "tower")
|
|
):
|
|
if raise_error:
|
|
raise UserError(
|
|
_(
|
|
"File %(f)s shouldn't have the '%(src)s' source "
|
|
" for the '%(act)s' action",
|
|
f=file.name,
|
|
src=file.source,
|
|
act=action,
|
|
)
|
|
)
|
|
return False
|
|
|
|
if action == "delete":
|
|
try:
|
|
file.check_access_rights("unlink")
|
|
file.check_access_rule("unlink")
|
|
except AccessError as e:
|
|
if raise_error:
|
|
raise AccessError(
|
|
_(
|
|
"Due to security restrictions you are "
|
|
"not allowed to delete %(fp)s",
|
|
fp=file.full_server_path,
|
|
)
|
|
) from e
|
|
return False
|
|
|
|
try:
|
|
if action == "download":
|
|
res = file._process_download(
|
|
tower_key_obj, is_server_code_version_process
|
|
)
|
|
if res:
|
|
return res
|
|
elif action == "upload":
|
|
if file.file_type == "binary":
|
|
file_content = b64decode(file.file)
|
|
else:
|
|
file_content = tower_key_obj._parse_code(file.rendered_code)
|
|
file.server_id.upload_file(
|
|
file_content,
|
|
tower_key_obj._parse_code(file.full_server_path),
|
|
)
|
|
elif action == "delete":
|
|
file.server_id.delete_file(
|
|
tower_key_obj._parse_code(file.full_server_path)
|
|
)
|
|
else:
|
|
return False
|
|
file.sudo().server_response = "ok"
|
|
except Exception as error:
|
|
if raise_error:
|
|
raise ValidationError(
|
|
_(
|
|
"Cannot pull %(f)s from server: %(err)s",
|
|
f=file.rendered_name,
|
|
err=exception_to_unicode(error),
|
|
)
|
|
) from error
|
|
file.server_response = repr(error)
|
|
|
|
if not is_server_code_version_process:
|
|
self._update_file_sync_date(fields.Datetime.now())
|
|
|
|
@api.model
|
|
def _get_tower_sync_field_names(self):
|
|
"""
|
|
Return the list of field names to start synchronization
|
|
after changing these fields
|
|
"""
|
|
return ["name", "server_dir", "code"]
|
|
|
|
@api.model
|
|
def _run_auto_pull_files(self):
|
|
"""
|
|
Run auto sync files
|
|
"""
|
|
now = fields.Datetime.now()
|
|
files = self.search(
|
|
[
|
|
("source", "=", "server"),
|
|
("auto_sync", "=", True),
|
|
("sync_date_next", "<=", now),
|
|
]
|
|
)
|
|
files.download(raise_error=False)
|
|
|
|
def _update_file_sync_date(self, last_sync_date):
|
|
"""
|
|
Compute and update next date of sync
|
|
"""
|
|
for file in self:
|
|
vals = {}
|
|
if file.source == "server" and file.auto_sync:
|
|
interval, interval_type = file.auto_sync_interval.split("-")
|
|
vals.update(
|
|
{
|
|
"sync_date_next": last_sync_date
|
|
+ INTERVAL_TYPES[interval_type](int(interval))
|
|
}
|
|
)
|
|
if file.server_response == "ok":
|
|
vals.update({"sync_date_last": last_sync_date})
|
|
file.sudo().write(vals)
|
|
|
|
# Check cx.tower.reference.mixin for the function documentation
|
|
def _get_pre_populated_model_data(self):
|
|
res = super()._get_pre_populated_model_data()
|
|
res.update({"cx.tower.file": ["cx.tower.server", "server_id"]})
|
|
return res
|