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

566 lines
30 KiB
Python

# Part of Odoo. See LICENSE file for full copyright and licensing details.
import uuid
from datetime import timedelta
from dateutil.relativedelta import relativedelta
from hashlib import sha256
from markupsafe import Markup
from random import randint
from werkzeug.urls import url_quote, url_encode
from odoo import _, api, fields, models
from odoo.exceptions import UserError, ValidationError
from odoo.tools import consteq, email_normalize, formataddr, groupby, get_lang, html2plaintext, is_html_empty
from odoo.tools.misc import hmac
from odoo.tools.urls import urljoin as url_join
class SignRequestItem(models.Model):
_name = 'sign.request.item'
_description = "Signature Request Item"
_inherit = ['portal.mixin']
_rec_name = 'partner_id'
def _default_access_token(self):
return str(uuid.uuid4())
def _get_mail_link(self, email, subject):
return "mailto:%s?subject=%s" % (url_quote(email), url_quote(subject))
# this display_name (with sudo) is used for many2many_tags especially the partner_id is private
display_name = fields.Char(compute_sudo=True)
partner_id = fields.Many2one('res.partner', string="Signer", ondelete='restrict')
sign_request_id = fields.Many2one('sign.request', string="Signature Request", ondelete='cascade', required=True, copy=False, index=True)
sign_item_value_ids = fields.One2many('sign.request.item.value', 'sign_request_item_id', string="Value")
reference = fields.Char(related='sign_request_id.reference', string="Document Name")
mail_sent_order = fields.Integer(default=1)
communication_company_id = fields.Many2one(related='sign_request_id.communication_company_id')
access_token = fields.Char(required=True, default=_default_access_token, readonly=True, copy=False, groups="base.group_system")
access_via_link = fields.Boolean('Accessed Through Token', copy=False)
role_id = fields.Many2one('sign.item.role', string="Role", required=True, readonly=True)
sms_number = fields.Char(related='partner_id.phone', readonly=False, depends=(['partner_id']), store=True, copy=False)
sms_token = fields.Char('SMS Token', readonly=True, copy=False)
signed_without_extra_auth = fields.Boolean('Signed Without Extra Authentication', default=False, readonly=True, copy=False)
signature = fields.Binary(attachment=True, copy=False)
frame_hash = fields.Char(size=256, compute='_compute_frame_hash')
signing_date = fields.Date('Signed on', readonly=True, copy=False)
state = fields.Selection([
("sent", "To Sign"),
("completed", "Signed"),
("canceled", "Cancelled"),
], readonly=True, default="sent", copy=False, index=True)
color = fields.Integer(compute='_compute_color')
signer_email = fields.Char(string='Email', compute="_compute_email", store=True)
is_mail_sent = fields.Boolean(readonly=True, copy=False, help="The signature mail has been sent.")
change_authorized = fields.Boolean(related='role_id.change_authorized')
latitude = fields.Float(digits=(10, 7), copy=False)
longitude = fields.Float(digits=(10, 7), copy=False)
@api.constrains('signer_email')
def _check_signer_email_validity(self):
if any(sri.partner_id and not sri.signer_email for sri in self):
raise ValidationError(_("All signers must have valid email addresses"))
@api.constrains('sign_request_id', 'partner_id', 'role_id')
def _check_signers_validity(self):
# this check allows one signer to be False, which is used to "share" a sign template
self.sign_request_id._check_signers_roles_validity()
self.sign_request_id._check_signers_partners_validity()
@api.depends('signer_email')
def _compute_frame_hash(self):
db_uuid = self.env['ir.config_parameter'].sudo().get_param('database.uuid')
for sri in self:
if sri.partner_id:
sri.frame_hash = sha256((sri.signer_email + db_uuid).encode()).hexdigest()
else:
sri.frame_hash = ''
@api.depends('partner_id.name')
def _compute_display_name(self):
for sri in self:
sri.display_name = sri.partner_id.display_name if sri.partner_id else _('Public User')
def write(self, vals):
if vals.get('partner_id') is False:
raise UserError(_("You need to define a signatory"))
request_items_reassigned = self.env['sign.request.item']
if vals.get('partner_id'):
request_items_reassigned |= self.filtered(lambda sri: sri.partner_id and sri.partner_id.id != vals['partner_id'])
if any(sri.state != 'sent'
or sri.sign_request_id.state != 'sent'
or (sri.partner_id and not sri.role_id.change_authorized)
or sri.sign_request_id.state == 'shared'
for sri in request_items_reassigned):
raise UserError(_("You cannot reassign this signatory"))
new_sign_partner = self.env['res.partner'].browse(vals.get('partner_id'))
for request_item in request_items_reassigned:
sign_request = request_item.sign_request_id
old_sign_user = request_item.partner_id.user_ids[:1]
# remove old activities for internal users if they are no longer one of the unsigned signers of their sign requests
if old_sign_user and old_sign_user.has_group('sign.group_sign_user') and \
not sign_request.request_item_ids.filtered(
lambda sri: sri.partner_id == request_item.partner_id and sri.state == 'sent' and sri not in request_items_reassigned):
sign_request.activity_unlink(['sign.mail_activity_data_signature_request'], user_id=old_sign_user.id)
# create logs
sign_request.message_post(
body=_('The contact of %(role)s has been changed from %(old_partner)s to %(new_partner)s.',
role=request_item.role_id.name, old_partner=request_item.partner_id.name, new_partner=new_sign_partner.name))
# add new followers
request_items_reassigned.sign_request_id.message_subscribe(partner_ids=[vals.get('partner_id')])
# add new activities for internal users
new_sign_user = self.env['res.users'].search([
('partner_id', '=', vals.get('partner_id')),
('all_group_ids', 'in', [self.env.ref('sign.group_sign_user').id])
], limit=1)
if new_sign_user:
activity_ids = set(request_items_reassigned.sign_request_id.activity_search(['sign.mail_activity_data_signature_request'], user_id=new_sign_user.id).mapped('res_id'))
request_items_reassigned.sign_request_id.filtered(lambda sr: sr.id not in activity_ids)._schedule_activity(new_sign_user)
if vals.get('signer_email') and not self.env.user.has_group('sign.group_sign_manager') and self.env.user != self.create_uid:
raise UserError(_("You cannot change the email of a signatory"))
res = super().write(vals)
# change access token
for request_item in request_items_reassigned.filtered(lambda sri: sri.is_mail_sent):
request_item.sudo().update({'access_token': self._default_access_token()})
request_item.is_mail_sent = False
return res
def _cancel(self, no_access=True):
""" Cancel a SignRequestItem. It can only be used in the SignRequest.cancel or SignRequest._refuse
:param bool no_access: Whether the sign request item cannot be accessed by the previous link in the email
"""
for request_item in self:
request_item.write({
'state': 'canceled' if request_item.state == 'sent' else request_item.state,
'signing_date': fields.Date.context_today(self) if request_item.state == 'sent' else request_item.signing_date,
'is_mail_sent': False if no_access else request_item.is_mail_sent,
})
request_item.sudo().write({'access_token': self._default_access_token() if no_access else request_item.access_token})
def _refuse(self, request_state, refusal_reason, refusal_name="", refusal_email=""):
""" Refuse a sign request item with 'sent' or 'shared' states. """
self.ensure_one()
if not self.env.su:
raise UserError(_("This function can only be called with sudo."))
# Get the post message according to the logged user.
refuse_user = self.partner_id.user_ids[:1]
# If refusing with a signed user, we use the user as the refuser.
if not refuse_user and self.env.user and not self.env.user.is_public:
refuse_user = self.env.user
message_post = ""
if refuse_user:
message_post = _(
"The signature has been refused by %(partner)s (%(role)s), refusal reason:",
partner=self.partner_id.name,
role=self.role_id.name
)
else:
message_post = _(
"The signature has been refused by %(name)s with email (%(email)s), refusal reason:",
name=refusal_name,
email=refusal_email,
)
refusal_reason = _("No specified reason") if not refusal_reason or refusal_reason.isspace() else refusal_reason
message_post = Markup('{}<p style="white-space: pre">{}</p>').format(message_post, refusal_reason)
if self.state == 'sent' and request_state == 'sent':
self._refuse_sent(refuse_user, message_post, refusal_reason)
elif self.state == 'sent' and request_state == 'shared':
self._refuse_shared(refuse_user, message_post, refusal_name, refusal_email)
else:
raise UserError(_("This sign request item cannot be refused"))
def _refuse_sent(self, refuse_user, message_post, refusal_reason):
""" Refuse requests that were sent directly to the signers ('sent' state).
The cancelling flow happens directly in the refused sign request. """
self.env['sign.log'].create({'sign_request_item_id': self.id, 'action': 'refuse'})
self.write({'signing_date': fields.Date.context_today(self), 'state': 'canceled'})
# Mark the activity as done for the refuser.
if refuse_user and refuse_user.has_group('sign.group_sign_user'):
self.sign_request_id.activity_feedback(['mail.mail_activity_data_todo'], user_id=refuse_user.id)
self.sign_request_id.message_post(body=message_post)
self.sign_request_id._refuse(self.partner_id, refusal_reason)
def _refuse_shared(self, refuse_user, message_post, refusal_name="", refusal_email=""):
""" Refuse requests that were shared by a shared link ('shared' state).
The request is duplicated and its copy is cancelled, allowing other users to also refuse it.
Public users can also refuse the request by disclosing their identity (name and email). """
user_identified = bool(refusal_name and refusal_email)
if not refuse_user and not user_identified:
raise UserError(_("The public user must identify itself for the refusal."))
# Duplicate the sign request, allowing other users to also refuse it.
request_copy_values = self.sign_request_id.copy_data()[0]
request_copy_values['state'] = 'shared'
new_request = self.env['sign.request'].create(request_copy_values)
new_sign_request_item = new_request.request_item_ids
# Do the refuse action in the request item and set the copied request as cancelled.
self.env['sign.log'].create({'sign_request_item_id': new_sign_request_item.id, 'action': 'refuse'})
new_sign_request_item.write({'signing_date': fields.Date.context_today(new_sign_request_item), 'state': 'canceled'})
new_request.state = "canceled"
# Mark the activity as done for the refuser.
if refuse_user and refuse_user.has_group('sign.group_sign_user'):
new_sign_request_item.sign_request_id.activity_feedback(['mail.mail_activity_data_todo'], user_id=refuse_user.id)
new_sign_request_item.sign_request_id.message_post(body=message_post)
# Link the refusal partner to the cancelled request, it is used for easying the identification.
refusal_partner = refuse_user.partner_id or self.env['res.partner'].search([('email', '=', refusal_email)], limit=1)
if not refusal_partner:
refusal_partner = self.env['res.partner'].create({'name': refusal_name, 'email': refusal_email})
new_sign_request_item.partner_id = refusal_partner
def _get_url_parameters(self, signer, expiry_link_timestamp):
return url_encode({
'timestamp': expiry_link_timestamp,
'exp': signer._generate_expiry_signature(signer.id, expiry_link_timestamp)
})
def _get_access_token(self, signer):
return signer.sudo().access_token
def _get_sign_and_cancel_links(self, signer):
expiry_link_timestamp = signer._generate_expiry_link_timestamp()
url_params = self._get_url_parameters(signer, expiry_link_timestamp)
partial_url = "sign/document/mail/%(request_id)s/%(access_token)s?%(url_params)s" % {
'request_id': signer.sign_request_id.id,
'access_token': self._get_access_token(signer),
'url_params': url_params
}
link_sign = url_join(signer.get_base_url(), partial_url)
company = self.communication_company_id
if 'website_id' in company and company.website_id.domain:
link_sign = url_join(company.website_id.domain, partial_url)
link_cancel = link_sign + '&refuseDocument=1'
return link_sign, link_cancel
def _send_signature_access_message(self):
for signer in self:
signer_email_normalized = email_normalize(signer.signer_email or '')
signer_lang = get_lang(self.env, lang_code=signer.partner_id.lang).code
# We hide the validity information if it is the default (6 month from the create_date)
has_default_validity = signer.sign_request_id.validity and signer.sign_request_id.validity - relativedelta(months=6) == signer.sign_request_id.create_date.date()
link_sign, link_cancel = self._get_sign_and_cancel_links(signer)
body = self.env['ir.qweb']._render('sign.sign_template_mail_request', {
'record': signer,
'link': link_sign,
'link_cancel': link_cancel,
'subject': signer.sign_request_id.subject,
'body': signer.sign_request_id.message if not is_html_empty(signer.sign_request_id.message) else False,
'use_sign_terms': self.env['ir.config_parameter'].sudo().get_param('sign.use_sign_terms'),
'user_signature': signer.create_uid.signature,
'show_validity': signer.sign_request_id.validity and not has_default_validity,
}, lang=signer_lang, minimal_qcontext=True)
attachment_ids = signer.sign_request_id.attachment_ids.ids
self.env['sign.request'].with_context(lang=signer.partner_id.lang or self.env.lang)._message_send_mail(
body,
record_name=signer.sign_request_id.reference,
notif_values={
'model_description': _('Signature'),
'company': signer.communication_company_id or signer.sign_request_id.create_uid.company_id,
'partner': signer.partner_id,
},
mail_values={
'author_id': signer.create_uid.partner_id.id,
'email_from': signer.create_uid.email_formatted,
'email_to': formataddr((signer.partner_id.name, signer_email_normalized)),
'subject': signer.sign_request_id.subject,
'attachment_ids': attachment_ids,
},
force_send=self.env.context.get('force_send', True), # only force_send if not from cron
)
signer.is_mail_sent = True
def sign(self, signature, **kwargs):
""" Sign sign request items at once.
:param signature: dictionary containing signature values and corresponding ids
"""
self.ensure_one()
if not self.env.su:
raise UserError(_("This function can only be called with sudo."))
elif self.state != 'sent' or self.sign_request_id.state != 'sent':
raise UserError(_("This sign request item cannot be signed"))
elif self.sign_request_id.validity and self.sign_request_id.validity < fields.Date.today():
raise UserError(_('This sign request is not valid anymore'))
self._sign(signature, **kwargs)
def _sign(self, signature, **kwargs):
""" Stores the sign request item values.
:param signature: dictionary containing signature values and corresponding ids / signature image
:param validation_required: boolean indicating whether the sign request item will after a further validation process or now
"""
self.ensure_one()
if not self.env.su:
raise UserError(_("This function can only be called with sudo."))
elif self.state != 'sent' or self.sign_request_id.state != 'sent':
raise UserError(_("This sign request item cannot be signed"))
elif self.sign_request_id.validity and self.sign_request_id.validity < fields.Date.today():
raise UserError(_('This sign request is not valid anymore'))
# Constant items are populated automatically and cannot be modified by the signer,
# so they are excluded from required field validation.
required_ids = set(self.sign_request_id.template_id.sign_item_ids.filtered(
lambda r: r.responsible_id.id == self.role_id.id and r.required and not r.constant).ids)
signature_ids = {int(k) for k in signature} if isinstance(signature, dict) else set()
if not (required_ids <= signature_ids): # Security check
raise UserError(_("Some required items are not filled"))
if self.state != 'sent' or self.sign_request_id.state != 'sent':
raise UserError(_("This sign request item cannot be filled"))
if not self.env.su:
raise UserError(_("This function can only be called with sudo."))
self._fill(signature, **kwargs)
if not kwargs.get('validation_required', False):
self._post_fill_request_item()
def _send_no_credits_email(self):
partner_lang = get_lang(self.env, lang_code=self.create_uid.partner_id.lang).code
body = self.env['ir.qweb']._render('sign.sign_template_mail_not_enough_credits', {
'record': self,
'recipient_name': self.create_uid.name,
'subject': '%s signed' % self.reference,
'signer': self.partner_id,
'auth_method': dict(self.role_id._fields['auth_method']._description_selection(self.env))[self.role_id.auth_method]
}, lang=partner_lang, minimal_qcontext=True)
self.env['sign.request'].with_context(lang=self.create_uid.lang or self.env.lang)._message_send_mail(
body,
record_name=self.reference,
notif_values={
'model_description': 'signature',
'company': self.communication_company_id or self.create_uid.company_id,
'partner': self.create_uid.partner_id,
},
mail_values={
'author_id': self.create_uid.partner_id.id,
'email_from': self.create_uid.email_formatted,
'email_to': self.create_uid.email_formatted,
'subject': _('%s: missing credits for extra-authentication', self.reference)
},
force_send=True,
)
def _post_fill_request_item(self):
self.env['sign.log'].create({'sign_request_item_id': self.id, 'action': 'sign'})
self.write({'signing_date': fields.Date.context_today(self), 'state': 'completed'})
if self.signed_without_extra_auth:
self._send_no_credits_email()
# mark signature as done in next activity
if not self.sign_request_id.request_item_ids.filtered(lambda sri: sri.partner_id == self.partner_id and sri.state == 'sent'):
sign_user = self.partner_id.user_ids[:1]
if sign_user and sign_user.has_group('sign.group_sign_user'):
self.sign_request_id.activity_feedback(['sign.mail_activity_data_signature_request'], user_id=sign_user.id)
sign_request = self.sign_request_id
if all(sri.state == 'completed' for sri in sign_request.request_item_ids):
sign_request._sign()
elif all(sri.state == 'completed' for sri in sign_request.request_item_ids.filtered(lambda sri: sri.mail_sent_order == self.mail_sent_order)):
sign_request.send_signature_accesses()
def _fill(self, signature, **kwargs):
""" Stores the sign request item values. (Can be used to pre-fill the document as a hack)
:param signature: dictionary containing signature values and corresponding ids / signature image
"""
self.ensure_one()
authorised_ids = set(self.sign_request_id.template_id.sign_item_ids.filtered(lambda r: r.responsible_id.id == self.role_id.id).ids)
signature_ids = {int(k) for k in signature} if isinstance(signature, dict) else set()
if not (signature_ids <= authorised_ids):
raise UserError(_("Some unauthorised items are filled"))
if not isinstance(signature, dict):
self.signature = signature
else:
SignItemValue = self.env['sign.request.item.value']
sign_request = self.sign_request_id
new_item_values_list = []
item_values_dict = {str(sign_item_value.sign_item_id.id): sign_item_value for sign_item_value in self.sign_item_value_ids}
signature_item_ids = set(sign_request.template_id.sign_item_ids.filtered(lambda r: r.type_id.item_type == 'signature').ids)
for itemId in signature:
frame = kwargs.get('frame', False)
if frame and itemId in frame:
frame_value = frame[itemId].get('frameValue', False)
frame_has_hash = bool(frame[itemId].get('frameHash', False))
else:
frame_value = False
frame_has_hash = False
if itemId not in item_values_dict:
new_item_values_list.append({'sign_item_id': int(itemId), 'sign_request_id': sign_request.id,
'value': signature[itemId], 'frame_value': frame_value,
'frame_has_hash': frame_has_hash, 'sign_request_item_id': self.id})
else:
item_values_dict[itemId].write({
'value': signature[itemId], 'frame_value': frame_value, 'frame_has_hash': frame_has_hash
})
if int(itemId) in signature_item_ids:
self.signature = signature[itemId][signature[itemId].find(',') + 1:]
SignItemValue.create(new_item_values_list)
def send_signature_accesses(self):
self.sign_request_id._check_senders_validity()
users = self.partner_id.user_ids
user_ids = set(users.sudo().search([('all_group_ids', 'in', self.env.ref('sign.group_sign_user').id), ('id', 'in', users.ids)]).ids)
for sign_request, sign_request_items_list in groupby(self, lambda sri: sri.sign_request_id):
notified_users = [sri.partner_id.user_ids[:1]
for sri in sign_request_items_list
if not sri.is_mail_sent and sri.state == 'sent' and sri.partner_id.user_ids[:1].id in user_ids]
sign_request._schedule_activity(notified_users)
body = _("The signature mail has been sent to: ")
receiver_names = ["%s(%s)" % (sri.partner_id.name, sri.role_id.name) for sri in sign_request_items_list]
body += ', '.join(receiver_names)
if not is_html_empty(sign_request.message):
body += sign_request.message
if not sign_request.communication_company_id:
sign_request.communication_company_id = self.env.company
sign_request.message_post(body=body)
self._send_signature_access_message()
def _get_user_signature(self, signature_type='sign_signature'):
""" Gets the user's stored sign_signature/sign_initials (needs sudo permission)
:param str signature_type: 'sign_signature' or 'sign_initials'
:returns bytes or False
"""
self.ensure_one()
sign_user = self.partner_id.user_ids[:1]
current_user_sign = False
if self.env.user._is_public() or sign_user and sign_user == self.env.user:
current_user_sign = True
if current_user_sign and signature_type in ['sign_signature', 'sign_initials']:
return sign_user[signature_type]
return False
def _get_user_signature_frame(self, signature_type='sign_signature_frame'):
""" Gets the user's stored sign_signature/sign_initials (needs sudo permission)
:param str signature_type: 'sign_signature' or 'sign_initials'
:returns bytes or False
"""
self.ensure_one()
sign_user = self.partner_id.user_ids[:1]
if sign_user and signature_type in ['sign_signature_frame', 'sign_initials_frame']:
return sign_user[signature_type]
return False
def _reset_sms_token(self):
for record in self:
record.sms_token = randint(100000, 999999)
def _send_sms(self):
self._reset_sms_token()
sms_values = [{'body': _('Your confirmation code is %s', rec.sms_token), 'number': rec.sms_number} for rec in self]
self.env['sms.sms'].sudo().create(sms_values).send()
def _compute_access_url(self):
super()._compute_access_url()
for signature_request in self:
signature_request.access_url = '/my/signature/%s' % signature_request.id
@api.model
def _generate_expiry_link_timestamp(self):
duration = int(self.env['ir.config_parameter'].sudo().get_param('sign.link_expiry_duration', 360))
expiry_date = fields.Datetime.now() + timedelta(hours=duration)
return int(expiry_date.timestamp())
@api.model
def _generate_expiry_signature(self, sign_request_item_id, timestamp):
return hmac(self.env(su=True), "sign_expiration", (timestamp, sign_request_item_id))
def _validate_expiry(self, exp_timestamp, exp_hash):
""" Validates if the expiry code is still valid
:param float exp_timestamp: a timestamp provided by the user in the URL params
:param str exp_hash: code provided in the URL to be checked
"""
self.ensure_one()
if not (exp_timestamp and exp_hash):
return False
exp_timestamp = int(exp_timestamp)
now = fields.Datetime.now().timestamp()
if now > exp_timestamp:
return False
return consteq(exp_hash, self._generate_expiry_signature(self.id, exp_timestamp))
@api.depends('state')
def _compute_color(self):
color_map = {"canceled": 0,
"sent": 0,
"completed": 10}
for sign_request_item in self:
sign_request_item.color = color_map[sign_request_item.state]
@api.depends('partner_id.email')
def _compute_email(self):
for sign_request_item in self.filtered(lambda sri: sri.state == "sent" or not sri.signer_email):
sign_request_item.signer_email = sign_request_item.partner_id.email_normalized
def _get_auto_field_value(self, item_type):
""" Return the automatic value of a sign item based on the linked model and partner access
:return: str: auto_value
"""
self.ensure_one()
item_type_sudo = self.env['sign.item.type'].sudo().browse(item_type['id'])
record = None
field_record_sudo = None
if item_type_sudo.model_id.model == 'res.partner':
record = self.partner_id
else:
if item_type_sudo.item_type == "stamp":
return self._get_stamp_value()
linked_record = self.sign_request_id.reference_doc
model = linked_record and self.env['ir.model']._get(linked_record._name)
if not model or not model.is_mail_thread or linked_record._name != item_type_sudo.model_id.model:
return ''
record = linked_record
try:
if record:
field_record_sudo = self.env['ir.model.fields'].sudo()._get(record._name, item_type_sudo.auto_field)
auto_field = record.mapped(item_type['auto_field'])
auto_value = auto_field[0] if auto_field and not isinstance(auto_field, models.BaseModel) else ''
if field_record_sudo and field_record_sudo.ttype == 'html':
auto_value = html2plaintext(auto_value)
except (KeyError, TypeError):
auto_value = ""
return auto_value
def _get_stamp_value(self):
"""
Return the formatted stamp value (company name, address, phone) for the partner.
:returns: str Company name and phone separated by a newline, or an empty string if not found.
"""
partner = self.with_context(show_address=1).partner_id
company_address = None
if partner:
if partner.parent_id:
# Partner has a parent → use parent company
company_address = partner.parent_id
elif partner.is_company:
# Partner is a standalone company → use itself (commercial partner)
company_address = partner.commercial_partner_id
elif partner.user_ids and partner.user_ids[0].company_id:
# Partner is an individual user → use their company partner
company_address = partner.user_ids[0].company_id.partner_id
if not company_address:
return ''
return self.env._(
"%(company_address)s\n%(phone)s", company_address=company_address.display_name, phone=company_address.phone or ""
)