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

502 lines
24 KiB
Python

import base64
import io
from collections import defaultdict
import textwrap
from odoo import models, fields, api
from odoo.exceptions import UserError, ValidationError
from odoo.tools import misc, format_date
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text, NameObject
from odoo.addons.sign.utils.pdf_handling import get_valid_pdf_data
from PIL import UnidentifiedImageError
from reportlab.lib.styles import ParagraphStyle
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.pdfbase.pdfmetrics import stringWidth
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph
def _fix_image_transparency(image):
""" Modify image transparency to minimize issue of grey bar artefact.
When an image has a transparent pixel zone next to white pixel zone on a
white background, this may cause on some renderer grey line artefacts at
the edge between white and transparent.
This method sets black transparent pixel to white transparent pixel which solves
the issue for the most probable case. With this the issue happen for a
black zone on black background but this is less likely to happen.
"""
pixels = image.load()
for x in range(image.size[0]):
for y in range(image.size[1]):
if pixels[x, y] == (0, 0, 0, 0):
pixels[x, y] = (255, 255, 255, 0)
class SignDocument(models.Model):
_name = 'sign.document'
_description = 'Signature Document'
_order = 'sequence'
sequence = fields.Integer()
attachment_id = fields.Many2one('ir.attachment', string="Attachment", required=True, index=True, ondelete='restrict')
datas = fields.Binary(related='attachment_id.datas', readonly=False)
name = fields.Char('Name', related='attachment_id.name', readonly=False)
template_id = fields.Many2one('sign.template', 'Template', index=True, ondelete='cascade', required=True)
sign_item_ids = fields.One2many('sign.item', 'document_id', string="Signature Items", copy=True)
num_pages = fields.Integer('Number of pages', compute="_compute_num_pages", readonly=True, store=True)
@api.depends('attachment_id.datas')
def _compute_num_pages(self):
for record in self:
try:
record.num_pages = self._get_pdf_number_of_pages(base64.b64decode(record.attachment_id.datas)) or 0
except (ValueError, ValidationError):
record.num_pages = 0
@api.model_create_multi
def create(self, vals_list):
attachments = self.env['ir.attachment'].browse([vals.get('attachment_id') for vals in vals_list if vals.get('attachment_id')])
for attachment in attachments:
self._check_pdf_data_validity(attachment.datas)
for vals, attachment in zip(vals_list, attachments):
if attachment.res_model or attachment.res_id:
vals['attachment_id'] = attachment.copy().id
else:
attachment.res_model = self._name
documents = super().create(vals_list)
default_name = self.env['sign.template'].default_get(fields=['name'])['name']
for document, attachment in zip(documents, documents.attachment_id):
attachment.write({
'res_model': self._name,
'res_id': document.id
})
if document.template_id.name == default_name:
document.template_id.name = document.name
documents.attachment_id.check_access('read')
return documents
@api.model
def create_from_attachment_data(self, attachment_data_list, template_id):
"""
Create sign.document records from a list of dictionaries containing attachment data.
:param attachment_data_list: List of dictionaries, each with 'name' and 'datas' keys.
Example: [{'name': 'asdf', 'datas': 'asdfasdfasdf23423'}, ...]
:return: List the newly created sign.document records.
:raises UserError: If the input list is empty or a dictionary is missing required keys.
"""
if not attachment_data_list:
raise UserError(self.env._("The attachment data list cannot be empty."))
created_records = []
for data in attachment_data_list:
if not all(key in data for key in ['name', 'datas']):
raise UserError(self.env._("Each item must contain 'name' and 'datas' keys."))
attachment = self.env['ir.attachment'].create({
'name': data['name'],
'datas': data['datas'],
})
sign_document = self.create({
'attachment_id': attachment.id,
'sequence': data['sequence'],
'template_id': template_id,
})
created_records.append(sign_document)
return created_records
def write(self, vals):
res = super().write(vals)
if 'attachment_id' in vals:
self.attachment_id.check_access('read')
return res
@api.onchange("attachment_id")
def _onchange_attachment_id(self):
self.attachment_id.check_access('read')
def get_radio_sets_dict(self):
"""
:return: dict radio_sets_dict that maps each radio set that belongs to
this template to a dictionary containing num_options and radio_item_ids.
"""
radio_sets = self.sign_item_ids.filtered(lambda item: item.radio_set_id).radio_set_id
radio_sets_dict = {
radio_set.id: {
'num_options': radio_set.num_options,
'radio_item_ids': radio_set.radio_items.ids,
} for radio_set in radio_sets
}
return radio_sets_dict
def _get_sign_items_by_page(self):
self.ensure_one()
items = defaultdict(lambda: self.env['sign.item'])
for item in self.sign_item_ids:
items[item.page] += item
return items
def update_attachment_name(self, name):
"""
Updates the attachment's name. If the provided name is empty or None,
the current name is retained. This forced update prevents the creation
of duplicate sign items during simultaneous RPC requests.
:param name: The new name for the attachment.
:return:
- True: Indicates the attachment name was successfully updated.
- False: Indicates the update was skipped because a sign request linked
to the template already exists
"""
self.ensure_one()
sign_requests = self.env['sign.request'].search([('template_id', '=', self.template_id.id)], limit=1)
if not sign_requests:
self.attachment_id.name = name or self.attachment_id.name
return True
return False
def _get_preview_values(self):
""" prepare preview values based on current user and auto field"""
self.ensure_one()
values_dict = {}
sign_item_type_date = self.env.ref('sign.sign_item_type_date', raise_if_not_found=False)
phone_item_type = self.env.ref('sign.sign_item_type_phone', raise_if_not_found=False)
company_item_type = self.env.ref('sign.sign_item_type_company', raise_if_not_found=False)
email_item_type = self.env.ref('sign.sign_item_type_email', raise_if_not_found=False)
name_item_type = self.env.ref('sign.sign_item_type_name', raise_if_not_found=False)
with misc.file_open('sign/static/demo/signature.png', 'rb') as image_file:
signature_b64 = base64.b64encode(image_file.read())
with misc.file_open('sign/static/img/initial_example.png', 'rb') as image_file:
initial_b64 = base64.b64encode(image_file.read())
for it in self.sign_item_ids:
role_name = it.responsible_id.name
value = None
if it.type_id == name_item_type:
value = self.env._("%s's name", role_name)
elif it.type_id == phone_item_type:
value = "+1 555-555-5555 (%s)" % role_name
elif it.type_id == company_item_type:
value = self.env._("%s Company", role_name)
elif it.type_id == email_item_type:
value = "%s@example.com" % role_name.lower()
elif it.type_id.item_type == "signature":
value = "data:image/png;base64,%s" % signature_b64.decode()
elif it.type_id.item_type == "initial":
value = 'data:image/png;base64,%s' % initial_b64.decode()
elif it.type_id.item_type == "text":
if it.type_id != sign_item_type_date:
value = self.env._("Sample generated by Odoo for %s.", role_name)
else:
value = format_date(self.env, fields.Date.today())
elif it.type_id.item_type == "textarea":
value = self.env._("""Odoo is a suite of open source business apps
that cover all your company needs:
CRM, eCommerce, accounting, inventory, point of sale,\nproject management, etc.
""")
elif it.type_id.item_type == "stamp":
value = self.env._("""My US Company\n1034 Wildwood Street\n44654 Millersburg Ohio United States\n+1 555-555-5556""")
elif it.type_id.item_type == "checkbox":
value = "on"
elif it.type_id.item_type == "selection":
value = it.option_ids[:1].id # we select always the first option
elif it.type_id.item_type == "radio":
radio_items = it.radio_set_id.radio_items
value = "on" if it == radio_items[:1] else "" # we select always the first option
elif it.type_id.item_type == "strikethrough":
value = "striked"
values_dict[it.id] = {
"value": value,
"frame": "",
'frame_has_hash': False,
}
signed_values = values_dict
return signed_values, values_dict
def _get_font(self):
custom_font = self.env["ir.config_parameter"].sudo().get_param("sign.use_custom_font")
# The font must be a TTF font. The tool 'otf2ttf' may be useful for conversion.
if custom_font:
pdfmetrics.registerFont(TTFont(custom_font, custom_font + ".ttf"))
return custom_font
return "Helvetica"
def _get_normal_font_size(self):
return 0.015
@api.model
def _get_page_size(self, pdf_reader):
max_width = max_height = 0
for page in pdf_reader.pages:
media_box = page.mediaBox
width = media_box and media_box.getWidth()
height = media_box and media_box.getHeight()
max_width = max(width, max_width)
max_height = max(height, max_height)
return (max_width, max_height) if max_width and max_height else None
def render_document_with_items(self, signed_values=None, values_dict=None, final_log_hash=None):
self.ensure_one()
items_by_page = self._get_sign_items_by_page()
if not signed_values or not values_dict:
signed_values, values_dict = self._get_preview_values()
try:
old_pdf = PdfFileReader(io.BytesIO(self.attachment_id.raw), strict=False)
old_pdf.getNumPages()
except (ValueError, PdfReadError):
raise ValidationError(self.env._("ERROR: Invalid PDF file!"))
if old_pdf.isEncrypted:
return
font = self._get_font()
normalFontSize = self._get_normal_font_size()
packet = io.BytesIO()
can = canvas.Canvas(packet, pagesize=self._get_page_size(old_pdf))
for p in range(0, old_pdf.getNumPages()):
page = old_pdf.getPage(p)
# Absolute values are taken as it depends on the MediaBox template PDF metadata, they may be negative
width = float(abs(page.mediaBox.getWidth()))
height = float(abs(page.mediaBox.getHeight()))
# Add the final_log_hash as the certificate reference id on each page
if final_log_hash:
can.setFont(font, height * 0.01)
ref_text = f"Signature: {final_log_hash}"
can.drawCentredString(width / 3, height - 15, ref_text)
# Set page orientation (either 0, 90, 180 or 270)
rotation = page.get('/Rotate', 0)
if rotation and isinstance(rotation, int):
can.rotate(rotation)
# Translate system so that elements are placed correctly
# despite of the orientation
if rotation == 90:
width, height = height, width
can.translate(0, -height)
elif rotation == 180:
can.translate(-width, -height)
elif rotation == 270:
width, height = height, width
can.translate(-width, 0)
items = items_by_page.get(p + 1, [])
for item in items:
value_dict = signed_values.get(item.id)
if not value_dict:
continue
# only get the 1st
value = value_dict['value']
frame = value_dict['frame']
if frame:
try:
image_reader = ImageReader(io.BytesIO(base64.b64decode(frame[frame.find(',') + 1:])))
except UnidentifiedImageError:
raise ValidationError(self.env._("There was an issue downloading your document. Please contact an administrator."))
_fix_image_transparency(image_reader._image)
can.drawImage(
image_reader,
width * item.posX,
height * (1 - item.posY - item.height),
width * item.width,
height * item.height,
'auto',
True
)
if item.type_id.item_type == "text":
value = reshape_text(value)
can.setFont(font, height * item.height * 0.8)
if item.alignment == "left":
can.drawString(width * item.posX, height * (1 - item.posY - item.height * 0.9), value)
elif item.alignment == "right":
can.drawRightString(width * (item.posX + item.width), height * (1 - item.posY - item.height * 0.9), value)
else:
can.drawCentredString(width * (item.posX + item.width / 2), height * (1 - item.posY - item.height * 0.9), value)
elif item.type_id.item_type == "selection":
text = ""
for option in item.option_ids:
if option.id == int(value):
text = option.value
font_size = height * normalFontSize * 0.8
string_width = stringWidth(text, font, font_size)
p = Paragraph(text, ParagraphStyle(name='Selection Paragraph', fontName=font, fontSize=font_size, leading=12))
posX = width * (item.posX + item.width * 0.5) - string_width // 2
posY = height * (1 - item.posY - item.height * 0.5) - p.wrap(width, height)[1] // 2
p.drawOn(can, posX, posY)
elif item.type_id.item_type in ["textarea", "stamp"]:
font_size = height * normalFontSize * 0.8
can.setFont(font, font_size)
# Normalize line endings to handle various formats (Windows \r\n, Unix \n, Mac \r)
normalized_value = (value or '').replace('\r\n', '\n').replace('\r', '\n')
# Wrap long lines that exceed field width
wrapped_lines = []
field_width = width * item.width
for line in normalized_value.splitlines():
if not line:
wrapped_lines.append("")
else:
# Calculate an approximate character limit based on average width
avg_char_width = can.stringWidth(line, font, font_size) / len(line)
max_chars = int(field_width / avg_char_width)
wrapped_lines.extend(textwrap.wrap(line, width=max_chars, replace_whitespace=False))
y = (1 - item.posY)
for line in wrapped_lines:
empty_space = field_width - can.stringWidth(line, font, font_size)
x_shift = 0
if item.alignment == 'center':
x_shift = empty_space / 2
elif item.alignment == 'right':
x_shift = empty_space
y -= normalFontSize * 0.9
line = reshape_text(line)
can.drawString(width * item.posX + x_shift, height * y, line)
y -= normalFontSize * 0.1
# Draw a dark blue border around the stamp field for visual emphasis
if item.type_id.item_type == "stamp":
padding = 5
itemW, itemH = item.width * width, item.height * height
itemX, itemY = item.posX * width, (1 - item.posY) * height
can.setLineWidth(1.2) # thickness of border
can.setStrokeColorRGB(0, 0, 139 / 255) # darkblue border
can.rect(
itemX - padding,
itemY - itemH - padding,
itemW + (2 * padding),
itemH + (2 * padding),
stroke=1,
fill=0
)
elif item.type_id.item_type == "checkbox":
itemW, itemH = item.width * width, item.height * height
itemX, itemY = item.posX * width, (1 - item.posY) * height
meanSize = (itemW + itemH) // 2
can.setLineWidth(max(meanSize // 30, 1))
can.rect(itemX, itemY - itemH, itemW, itemH)
if value == 'on':
can.setLineWidth(max(meanSize // 20, 1))
can.bezier(
itemX + 0.20 * itemW, itemY - 0.35 * itemH,
itemX + 0.30 * itemW, itemY - 0.8 * itemH,
itemX + 0.30 * itemW, itemY - 1.2 * itemH,
itemX + 0.85 * itemW, itemY - 0.15 * itemH,
)
elif item.type_id.item_type == "radio":
x = width * item.posX
y = height * (1 - item.posY)
w = item.width * width
h = item.height * height
# Calculate the center of the sign item rectangle.
c_x = x + w * 0.5
c_y = y - h * 0.5
# Draw the outer empty circle.
can.circle(c_x, c_y, h * 0.5)
if value == "on":
# Draw the inner filled circle.
can.circle(x_cen=c_x, y_cen=c_y, r=h * 0.5 * 0.75, fill=1)
elif item.type_id.item_type == "signature" or item.type_id.item_type == "initial":
try:
image_reader = ImageReader(io.BytesIO(base64.b64decode(value[value.find(',') + 1:])))
except UnidentifiedImageError:
raise ValidationError(self.env._("There was an issue downloading your document. Please contact an administrator."))
_fix_image_transparency(image_reader._image)
can.drawImage(image_reader, width * item.posX, height * (1 - item.posY - item.height), width * item.width, height * item.height, 'auto', True)
elif item.type_id.item_type == "strikethrough" and value == "striked":
x = width * item.posX
y = height * (1 - item.posY)
w = item.width * width
h = item.height * height
can.line(x, y - 0.5 * h, x + w, y - 0.5 * h)
can.showPage()
can.save()
item_pdf = PdfFileReader(packet)
new_pdf = PdfFileWriter()
for p in range(0, old_pdf.getNumPages()):
new_pdf.addPage(old_pdf.getPage(p))
page = new_pdf.getPage(-1)
overlay_page = item_pdf.getPage(p)
box = page.cropBox if page.get('/CropBox') else page.mediaBox
if box and box.lower_left:
offset_x = float(box.lower_left[0])
offset_y = float(box.lower_left[1])
else:
offset_x, offset_y = 0.0, 0.0
self.logger.warning("Unable to parse PDF coordinates offset; defaulting to (0,0)")
overlay_page.mediaBox = page.mediaBox
if page.get("/CropBox"):
overlay_page.cropBox = page.cropBox
if offset_x != 0 or offset_y != 0:
# translate the overlay_page to match the offset of the original page,
# ensuring that the signature is placed in the correct location
overlay_page.add_transformation((1, 0, 0, 1, offset_x, offset_y))
page.mergePage(overlay_page)
page.compressContentStreams()
# Preserve page indirect object identity so internal PDF references remain valid.
ref = page.indirect_reference if hasattr(page, "indirect_reference") else None
if ref:
old_pdf.resolved_objects[ref.generation, ref.idnum] = page
# Copy bookmarks from the original PDF to the new one, if any exist
root = old_pdf.trailer.get("/Root")
old_outlines_ref = root.get("/Outlines") if root else None
if old_outlines_ref:
outlines_obj = old_outlines_ref.getObject()
copied_outlines = new_pdf._addObject(outlines_obj)
new_pdf._root_object.update({
NameObject("/Outlines"): copied_outlines,
NameObject("/PageMode"): NameObject("/UseOutlines")
})
output = io.BytesIO()
try:
new_pdf.write(output)
except PdfReadError:
raise ValidationError(self.env._("There was an issue downloading your document. Please contact an administrator."))
return output
def _copy_sign_items_to(self, new_document):
""" Copy all sign items of the self document to the new_document that fit within its page count."""
self.ensure_one()
if new_document.template_id.has_sign_requests:
raise UserError(self.env._("Somebody is already filling a document which uses this template"))
new_document_pages = new_document.num_pages
item_id_map = {}
for sign_item in self.sign_item_ids:
# Only copy sign items that fit within the new document's page range.
if sign_item.page <= new_document_pages:
new_sign_item = sign_item.copy({'document_id': new_document.id})
new_sign_item.responsible_id = sign_item.responsible_id.id
item_id_map[str(sign_item.id)] = str(new_sign_item.id)
return item_id_map
@api.model
def _check_pdf_data_validity(self, datas):
try:
self._get_pdf_number_of_pages(base64.b64decode(datas))
except ValueError as e:
raise UserError(self.env._("One uploaded file cannot be read. Is it a valid PDF?")) from e
@api.model
def _get_pdf_number_of_pages(self, pdf_data):
file_pdf = get_valid_pdf_data(pdf_data, strict=False)
return len(file_pdf.pages)