Files
enterprise-addons/sign/models/sign_template.py
T

601 lines
27 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Part of Odoo. See LICENSE file for full copyright and licensing details.
import base64
import re
from datetime import timedelta
from reportlab.rl_config import TTFSearchPath
from odoo import api, fields, models, _
from odoo.exceptions import UserError
from odoo.fields import Command, Domain
from odoo.tools import misc, pdf
from odoo.tools.pdf import PdfReadError
from odoo.addons.sign.utils.pdf_handling import flatten_pdf
TTFSearchPath.append(misc.file_path("web/static/fonts/sign"))
class SignTemplate(models.Model):
_name = 'sign.template'
_description = "Signature Template"
def _default_favorited_ids(self):
return [(4, self.env.user.id)]
document_ids = fields.One2many('sign.document', 'template_id', string="Documents", copy=True)
name = fields.Char(required=True, default="New Template")
sign_item_ids = fields.One2many('sign.item', 'template_id', string="Signature Items", compute='_compute_sign_item_ids', store=True, domain=[('page', '>', -1)])
responsible_count = fields.Integer(compute='_compute_responsible_count', string="Responsible Count")
active = fields.Boolean(default=True, string="Active")
favorited_ids = fields.Many2many('res.users', string="Favorited Users", relation="sign_template_favorited_users_rel", default=_default_favorited_ids)
user_id = fields.Many2one('res.users', string="Responsible", default=lambda self: self.env.user)
sign_request_ids = fields.One2many('sign.request', 'template_id', string="Signature Requests")
tag_ids = fields.Many2many('sign.template.tag', string='Tags')
color = fields.Integer()
redirect_url = fields.Char(string="Redirect Link", default="",
help="Optional link for redirection after signature")
redirect_url_text = fields.Char(string="Link Label", default="Close", translate=True,
help="Optional text to display on the button link")
signed_count = fields.Integer(compute='_compute_signed_in_progress_template')
in_progress_count = fields.Integer(compute='_compute_signed_in_progress_template')
signature_request_validity = fields.Integer(
string="Default signature request validity",
readonly=False,
default=60,
help="Specify the default validity period (in days) for signature requests. "
"Set to 0 for requests that don't expire.")
authorized_ids = fields.Many2many('res.users', string="Authorized Users", relation="sign_template_authorized_users_rel", default=_default_favorited_ids)
group_ids = fields.Many2many("res.groups", string="Authorized Groups")
has_sign_requests = fields.Boolean(compute="_compute_has_sign_requests", compute_sudo=True, store=True)
is_sharing = fields.Boolean(compute='_compute_is_sharing', help='Checked if this template has created a shared document for you')
# Other model integration
model_id = fields.Many2one('ir.model', domain=[
('model', 'not in', ['sign.request', 'sign.template']),
('is_mail_thread', '=', 'True')
])
model_name = fields.Char(related='model_id.model', string="Model Name")
message = fields.Html("Message", help="Message to be sent to signers of the specified document")
_signature_request_validity_check = models.Constraint(
"CHECK(signature_request_validity IS NULL OR signature_request_validity >= 0)",
"The number of days for expiration must be a positive value.",
)
@api.constrains('model_id')
def _constraint_model_items(self):
for template in self:
if not template.model_name:
continue
model_names = template.document_ids.sign_item_ids.type_id.mapped('model_name')
for model_name in model_names:
if not model_name or model_name == 'res.partner':
continue
if model_name != template.model_name:
raise UserError(self.env._(
"The template model %(t_name)s is incompatible with the signature fields %(model_name)s.",
model_name=model_name,
t_name=template.model_name,
))
@api.model
def name_search(self, name='', domain=None, operator='ilike', limit=100):
# Display favorite templates first
domain = Domain.AND([[('display_name', operator, name)], domain or []])
templates = self.search_fetch(domain, ['display_name'], limit=limit)
if limit is None or len(templates) < limit:
templates = templates.sorted(key=lambda t: self.env.user in t.favorited_ids, reverse=True)
else:
favorited_templates = self.search_fetch(
domain & Domain('favorited_ids', '=', self.env.user.id),
['display_name'], limit=limit)
templates = favorited_templates + (templates - favorited_templates)
templates = templates[:limit]
return [(template.id, template.display_name) for template in templates.sudo()]
@api.depends('document_ids.sign_item_ids')
def _compute_sign_item_ids(self):
for template in self:
template.sign_item_ids = [Command.set([
item.id
for document in template.document_ids
for item in document.sign_item_ids
])]
@api.depends('sign_item_ids.responsible_id')
def _compute_responsible_count(self):
for template in self:
template.responsible_count = len(template.sign_item_ids.mapped('responsible_id'))
@api.depends('sign_request_ids')
def _compute_has_sign_requests(self):
for template in self:
template.has_sign_requests = bool(template.with_context(active_test=False).sign_request_ids)
def _compute_signed_in_progress_template(self):
sign_requests = self.env['sign.request']._read_group([('state', '!=', 'canceled')], ['state', 'template_id'], ['__count'])
signed_request_dict = {template.id: count for state, template, count in sign_requests if state == 'signed'}
in_progress_request_dict = {template.id: count for state, template, count in sign_requests if state == 'sent'}
for template in self:
template.signed_count = signed_request_dict.get(template.id, 0)
template.in_progress_count = in_progress_request_dict.get(template.id, 0)
@api.depends_context('uid')
def _compute_is_sharing(self):
sign_template_sharing_ids = set(self.env['sign.request'].search([
('state', '=', 'shared'), ('create_uid', '=', self.env.user.id), ('template_id', 'in', self.ids)
]).template_id.ids)
for template in self:
template.is_sharing = template.id in sign_template_sharing_ids
@api.model
def get_empty_list_help(self, help_message):
if not self.env.ref('sign.template_sign_tour', raise_if_not_found=False):
return '<p class="o_view_nocontent_smiling_face">%s</p>' % _('Upload a PDF')
return super().get_empty_list_help(help_message)
def copy_data(self, default=None):
# Need to add the new sign map to the context for Copying sign.item.roles
# because they need thier own copys instead of using the same roles in sign.templates.
templates = self.with_context(__sign_role_copy_map={})
vals_list = super(SignTemplate, templates).copy_data(default=default)
for template, vals in zip(templates, vals_list):
if 'name' in vals and vals.get('name') == template.name or 'name' not in vals:
vals['name'] = template._get_copy_name(template.name)
return vals_list
def update_document(self, document_id, attachment_data):
""" Update a document in the template with a new one, preserving sign items.
:param int document_id: ID of the document to replace
:param dict attachment_data: Dictionary containing the new document data with name and datas
:returns dict: Action to redirect to the new template
"""
self.ensure_one()
old_document = self.env['sign.document'].browse(document_id)
if not old_document.exists() or old_document.template_id != self:
raise UserError(_("The document you're trying to update doesn't exist or doesn't belong to this template."))
# Check if the attachment data is valid PDF.
self.env['sign.document']._check_pdf_data_validity(attachment_data['datas'])
# Store the old document's sequence and create a copy of the template.
# Find and delete the corresponding document in the new template.
target_sequence = old_document.sequence
new_template = self.copy()
corresponding_doc = new_template.document_ids.filtered(
lambda d: d.sequence == target_sequence
)
if corresponding_doc:
corresponding_doc.unlink()
# Create the new document directly with the correct sequence.
# Copy sign items from old document to new document.
attachment = self.env['ir.attachment'].create({
'name': attachment_data['name'],
'datas': attachment_data['datas'],
})
new_document = self.env['sign.document'].create({
'attachment_id': attachment.id,
'sequence': target_sequence,
'template_id': new_template.id,
})
old_document._copy_sign_items_to(new_document)
# Return action to open new template.
return {
'type': 'ir.actions.client',
'tag': 'sign.Template',
'name': _('Edit Template'),
'params': {'id': new_template.id},
'context': {},
}
@api.model
def create_from_attachment_data(self, attachment_data_list, active=True):
"""
Create a sign.template record with sign.document records from a list of attachment data.
:param attachment_data_list: List of dictionaries, each with 'name' and 'datas' keys.
Example: [{'name': 'asdf', 'datas': 'asdfasdfasdf23423'}, ...]
:return: [ID of the newly created sign.template record, name of the template]
:raises UserError: If the input list is empty or a dictionary is missing required keys.
"""
# Update the document order sequence and flatten the PDF to make form fields read-only.
count_sequence = len(self.document_ids)
for att_data in attachment_data_list:
att_data['sequence'] = count_sequence
count_sequence += 1
original_pdf_base64 = att_data.get('datas')
if original_pdf_base64:
att_data['datas'] = flatten_pdf(original_pdf_base64)
context = {**self.env.context}
if model_name := self.env.context.get('default_model_name'):
model = self.env['ir.model']._get(model_name)
context.update(default_model_id=model.id)
template = self.with_context(context).create({
'name': attachment_data_list[0]['name'],
'active': active,
})
document_ids = self.env['sign.document'].create_from_attachment_data(attachment_data_list, template.id)
if len(document_ids):
template.write({
'name': document_ids[0].name,
})
return {
'id': template.id,
'name': template.name,
}
def update_from_attachment_data(self, attachment_data_list):
"""
Update the current sign.template record by adding sign.document records created from a list of attachment data.
:param attachment_data_list: List of dictionaries, each with 'name' and 'datas' keys.
Example: [{'name': 'asdf', 'datas': 'asdfasdfasdf23423'}, ...]
:return: None. The sign.template record is updated in place.
"""
# Update sequence for documents order.
count_sequence = len(self.document_ids)
for att_data in attachment_data_list:
att_data['sequence'] = count_sequence
count_sequence += 1
self.env['sign.document'].create_from_attachment_data(attachment_data_list, self.id)
def go_to_custom_template(self, sign_directly_without_mail=False):
self.ensure_one()
return {
'name': self.name,
'type': 'ir.actions.client',
'tag': 'sign.Template',
'context': self.env.context,
'params': {
'id': self.id,
'sign_directly_without_mail': sign_directly_without_mail,
},
}
def _check_send_ready(self):
if any(item.type_id.item_type == 'selection' and not item.option_ids for item in self.sign_item_ids):
raise UserError(self.env._("There are no values in selection field."))
def toggle_favorited(self):
self.ensure_one()
self.write({'favorited_ids': [(3 if self.env.user in self[0].favorited_ids else 4, self.env.user.id)]})
@api.ondelete(at_uninstall=False)
def _unlink_except_existing_signature(self):
if self.filtered(lambda template: template.has_sign_requests):
raise UserError(_(
"Oops! You can’t delete a template that has signature requests. It would be like asking "
"your users to autograph air! How about archiving it instead?"))
def get_radio_set_info_by_item_id(self, sign_item_ids=None):
"""
:param list of sign item IDs (sign_item_ids)
:return: dict radio_set_by_item_dict that maps each sign item ID in sign_item_ids of type "radio"
to a dictionary containing num_options and radio_set_id of the radio set it belongs to.
"""
radio_set_by_item_dict = {}
if sign_item_ids:
radio_items = self.sign_item_ids.filtered(lambda item: item.radio_set_id and item.id in sign_item_ids)
radio_set_by_item_dict = {
radio_item.id: {
'num_options': radio_item.num_options,
'radio_set_id': radio_item.radio_set_id.id,
} for radio_item in radio_items
}
return radio_set_by_item_dict
def update_from_pdfviewer(self, sign_items=None, deleted_sign_item_ids=None, name=None, document_id=None):
""" Update a sign.template from the pdfviewer
:param dict sign_items: ``{id (str): values (dict)}``
* id: positive: sign.item's id in database (the sign item is already in the database and should be update)
* negative: negative random itemId(transaction_id) in pdfviewer (the sign item is new created in the pdfviewer and should be created in database)
* values: values to update/create
:param list[str] deleted_sign_item_ids: list of ids of deleted sign items. These deleted ids may be
* positive: the sign item exists in the database
* negative: the sign item is new created in pdfviewer but removed before a successful transaction
:return: dict new_id_to_item_id_map: {negative itemId(transaction_id) in pdfviewer (str): positive id in database (int)}
"""
self.ensure_one()
if self.has_sign_requests:
return False
if sign_items is None:
sign_items = {}
if name and document_id:
document_id = self.env['sign.document'].search([('id', '=', document_id)])
document_id.update_attachment_name(name)
# update new_sign_items to avoid recreating sign items
new_sign_items = dict(sign_items)
sign_items_exist = self.sign_item_ids.filtered(lambda r: str(r.transaction_id) in sign_items)
for sign_item in sign_items_exist:
new_sign_items[str(sign_item.id)] = new_sign_items.pop(str(sign_item.transaction_id))
new_id_to_item_id_map = {str(sign_item.transaction_id): sign_item.id for sign_item in sign_items_exist}
# unlink sign items
deleted_sign_item_ids = set() if deleted_sign_item_ids is None else set(deleted_sign_item_ids)
self.sign_item_ids.filtered(lambda r: r.id in deleted_sign_item_ids or (r.transaction_id in deleted_sign_item_ids)).unlink()
# update existing sign items
for item in self.sign_item_ids.filtered(lambda r: str(r.id) in new_sign_items):
str_item_id = str(item.id)
if 'option_ids' in new_sign_items.get(str_item_id):
new_option_ids = list(map(int, new_sign_items[str_item_id]['option_ids']))
new_sign_items[str_item_id]['option_ids'] = [[6, 0, new_option_ids]]
item.write(new_sign_items.pop(str_item_id))
# create new sign items
new_values_list = []
for key, values in new_sign_items.items():
if int(key) < 0:
new_values_list.append(values)
new_id_to_item_id_map.update(zip(new_sign_items.keys(), self.env['sign.item'].create(new_values_list).ids))
return new_id_to_item_id_map
@api.model
def _get_copy_name(self, name):
regex = re.compile(r'(.*?)((?:\(\d+\))?)((?:\.pdf)?)$')
match = regex.search(name)
name_doc = match.group(1)
name_ver = match.group(2)
name_ext = match.group(3)
version = int(name_ver[1:-1]) + 1 if name_ver else 2
return f"{name_doc}({version}){name_ext}"
@api.model
def rotate_pdf(self, template_id=None):
template = self.browse(template_id)
if template.has_sign_requests:
return False
template.datas = base64.b64encode(pdf.rotate_pdf(base64.b64decode(template.datas)))
return True
def open_requests(self):
return {
"type": "ir.actions.act_window",
"name": _("Sign requests"),
"res_model": "sign.request",
"res_id": self.id,
"domain": [["template_id", "in", self.ids]],
"views": [[False, 'kanban'], [False, "form"]],
"context": {'search_default_signed': True}
}
def open_shared_sign_request(self):
self.ensure_one()
shared_sign_request = self.sign_request_ids.filtered(lambda sr: sr.state == 'shared' and sr.create_uid == self.env.user)
local_context = dict(self.env.context, default_sign_request_id=self.id)
wizard = self.env["sign.request.share"].with_context(local_context).create({
"template_id": self.id,
"sign_request_id": shared_sign_request.id if shared_sign_request else False,
"is_shared": bool(shared_sign_request)
})
return {
"name": _("Share Document"),
'type': 'ir.actions.act_window',
"view_mode": "form",
"target": "new",
"res_model": "sign.request.share",
"res_id": wizard.id,
"views": [[False, "form"]],
}
def get_action_in_progress_requests(self):
""" Get the in-progress sign requests related to this template. """
sign_request_ids = self.env['sign.request'].search([('state', '=', 'sent'), ('template_id', '=', self.id)]).ids
return {
'type': 'ir.actions.act_window',
'name': _('In Progress Requests'),
'res_model': 'sign.request',
'views': [[False, 'list'], [False, 'form']],
'domain': [('id', 'in', sign_request_ids)],
}
def get_action_signed_requests(self):
""" Get the signed sign requests related to this template. """
sign_request_ids = self.env['sign.request'].search([('state', '=', 'signed'), ('template_id', '=', self.id)]).ids
return {
'type': 'ir.actions.act_window',
'name': _('Signed Requests'),
'res_model': 'sign.request',
'views': [[False, 'list'], [False, 'form']],
'domain': [('id', 'in', sign_request_ids)],
}
def stop_sharing(self):
self.ensure_one()
return self.sign_request_ids.filtered(lambda sr: sr.state == 'shared' and sr.create_uid == self.env.user).unlink()
def trigger_template_tour(self):
template = self.env.ref('sign.template_sign_tour')
# Write access to the item's roles is needed to update the roles names.
sign_items_access = template.sign_item_ids.mapped('responsible_id').has_access('write')
if template.has_sign_requests or not sign_items_access:
template = template.copy({
'user_id': self.env.user.id,
'favorited_ids': [Command.link(self.env.user.id)],
'active': False,
})
# Remove items added by previous tour runs, as the tour guides
# the user to add new ones. Also avoids access errors.
template.sign_item_ids.unlink()
return {
'type': 'ir.actions.client',
'tag': 'sign.Template',
'name': template.name,
'params': {
'id': template.id,
'sign_directly_without_mail': False
}
}
def action_duplicate(self):
self.ensure_one()
self.check_access('write')
for document in self.document_ids:
document.check_access('write')
self.copy()
##################
# PDF Rendering #
##################
def action_template_preview(self, document_id):
self.ensure_one()
# We create the wizard here to have a proper id (not newID). The pdf_viewer widget needs it
# to display the pdf in the iFrame
wizard = self.env['sign.template.preview'].create({
'template_id': self.id,
'document_id': document_id,
})
return {
'name': _("Template Preview"),
'res_id': wizard.id,
'type': 'ir.actions.act_window',
'res_model': 'sign.template.preview',
'target': 'new',
'views': [[False, 'form']],
'context': self.env.context,
}
def get_template_items_roles_info(self):
""" Extract a unique list of role IDs and colors from self.sign_item_ids, adding an index. """
self.ensure_one()
roles_info = []
for idx, role in enumerate(self.sign_item_ids.responsible_id.sorted()):
roles_info.append({
'id': idx,
'name': role.name,
'roleId': role.id,
'colorId': idx,
'assignTo': role.assign_to.avatar_128 or role.assign_to.avatar_1920 or '',
})
return roles_info
def action_template_configuration(self):
""" Open the template properties form view. """
self.ensure_one()
return {
'name': "Edit Template Form",
'type': "ir.actions.act_window",
'res_model': "sign.template",
'res_id': self.id,
'views': [[False, "form"]],
'context': self.env.context,
}
@api.model
def create_sign_template_from_ir_attachment_data(self, attachment_id=False, res_id=False, res_model=False):
"""
Create a sign.template record from an existing ir.attachment.
:param attachment_id: The ID of the `ir.attachment` record to use as the source document.
:param res_id: ID of the related record to link as reference.
:param res_model: Model name of the related record to link as reference.
:return: A tuple containing the ID and name of the newly created `sign.template`.
"""
attachment = self.env['ir.attachment'].browse(attachment_id).exists()
if not attachment:
raise UserError(_("Attachment not found."))
attachment_data = {
'name': attachment.name,
'datas': attachment.datas,
}
try:
template_data = self.create_from_attachment_data([attachment_data], active=False)
except (ValueError, PdfReadError) as e:
raise UserError(self.env._("PDF File is corrupted. Please try with another file.")) from e
return template_data['id'], template_data['name']
def open_sign_send_dialog(self):
""" Create and open dialog. This is needed to be able to compute the values without onchange and default.
"""
context = dict(self.env.context)
template = self and self[:1]
default_activity_id = context.get('default_activity_id')
is_activity = False
if default_activity_id:
activity = self.env['mail.activity'].browse(default_activity_id)
if activity_template := activity.activity_type_id.default_sign_template_id:
if activity_template.has_access('read'):
is_activity = True
template = activity_template
context.update({'default_template_id': template and template.id})
if template.exists() and not is_activity:
# Hide the template_id field
context.update({'default_has_default_template': True})
if context.get('default_reference_doc'):
context.update({'sign_from_record': True})
if not context.get('default_model'):
context.update({'default_model': 'sign.template'})
if not context.get('default_res_ids'):
context.update({'default_res_ids': self.ids})
action = self.env['ir.actions.act_window']._for_xml_id('sign.action_sign_send_request')
action.update({
'context': context,
'target': 'new',
})
return action
@api.model
def create_item_and_role(self, document_id, role_name):
""" Create a new sign role and a corresponding dummy sign item. """
role = self.env['sign.item.role'].create({'name': role_name})
signature_item_type = self.env["sign.item.type"].search([], limit=1)
if not signature_item_type:
raise UserError(_("You need at least one field type in order to sign a document!"))
self.env['sign.item'].create({
'document_id': document_id,
'type_id': signature_item_type.id,
'required': False,
'responsible_id': role.id,
'page': -1,
'posX': -1,
'posY': -1,
'width': -1,
'height': -1,
})
return role.id
@api.autovacuum
def _gc_sign_items(self):
""" Garbage-collect dummy sign items (page < 0) that are older than one day,
and remove sign roles that are no longer linked to any active sign items.
"""
# Filter dummy items older than 1 day to avoid deleting roles that may still be
# used in an ongoing sign request creation session.
one_day_ago = fields.Datetime.now() - timedelta(days=1)
dummy_items = self.env['sign.item'].search([('page', '<', 0), ('create_date', '<', one_day_ago)])
active_items = self.env['sign.item'].search([('page', '>', -1)])
roles_with_dummy_items = dummy_items.mapped('responsible_id')
roles_with_active_items = active_items.mapped('responsible_id')
unused_role_recordset = roles_with_dummy_items - roles_with_active_items
dummy_items.unlink()
unused_role_recordset.unlink()