# -*- coding: utf-8 -*-
import base64
import io
import os
import time
from dateutil.relativedelta import relativedelta
from datetime import timedelta
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.rl_config import TTFSearchPath
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase.pdfmetrics import stringWidth
from PIL import UnidentifiedImageError
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
# Helper function copied from sign/models/sign_request.py
def _fix_image_transparency(image):
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 SignRequest(models.Model):
_inherit = "sign.request"
def _sign(self):
""" Override to generate sequence numbers when request is signed """
# We perform sequence generation BEFORE calling super()._sign()
# because super()._sign() triggers _send_completed_document() immediately.
# We need the sequence values to be ready before the document is generated and sent.
# We only generate if we are about to complete the signing process.
# The logic in _post_fill_request_item calls _sign only when all items are completed.
for request in self:
# Generate sequence numbers for sequence items
for sign_item in request.template_id.sign_item_ids:
if sign_item.type_id.item_type == 'sequence' and sign_item.sequence_id:
# Find the responsible request item (signer)
# Use role_id to match.
# We check if there is a signer for this role in this request.
request_items = request.request_item_ids.filtered(lambda r: r.role_id == sign_item.responsible_id)
if not request_items:
continue
# We only generate if it hasn't been generated yet.
existing_value = request.env['sign.request.item.value'].search([
('sign_request_item_id', 'in', request_items.ids),
('sign_item_id', '=', sign_item.id)
], limit=1)
# If it exists but value is empty/false OR it equals the placeholder "Sequence Number"
# we must regenerate it.
if not existing_value or not existing_value.value or existing_value.value == "Sequence Number":
new_seq = sign_item.sequence_id.next_by_id()
if new_seq:
if existing_value:
# Update existing empty record
existing_value.write({'value': new_seq})
else:
# Create value for the first matching signer
request.env['sign.request.item.value'].create({
'sign_request_item_id': request_items[0].id,
'sign_item_id': sign_item.id,
'value': new_seq
})
# Rename the document if requested
# User asked to put sequence in prefix of document name.
# request.reference is the document name.
# Check if already renamed to avoid double prefixing
if not request.reference.startswith(f"{new_seq} - "):
request.write({'reference': f"{new_seq} - {request.reference}"})
return super(SignRequest, self)._sign()
def _generate_completed_document(self, password=""):
# We need to override this entire method to handle 'sequence' item type
# as the original method doesn't have hooks or generic handling.
self.ensure_one()
if self.state != 'signed':
raise UserError(_("The completed document cannot be created because the sign request is not fully signed"))
if not self.template_id.sign_item_ids:
self.completed_document = self.template_id.attachment_id.datas
else:
try:
old_pdf = PdfFileReader(io.BytesIO(base64.b64decode(self.template_id.attachment_id.datas)), strict=False, overwriteWarnings=False)
old_pdf.getNumPages()
except:
raise ValidationError(_("ERROR: Invalid PDF file!"))
isEncrypted = old_pdf.isEncrypted
if isEncrypted and not old_pdf.decrypt(password):
# password is not correct
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))
itemsByPage = self.template_id._get_sign_items_by_page()
items_ids = [id for items in itemsByPage.values() for id in items.ids]
values_dict = self.env['sign.request.item.value']._read_group(
[('sign_item_id', 'in', items_ids), ('sign_request_id', '=', self.id)],
groupby=['sign_item_id'],
aggregates=['value:array_agg', 'frame_value:array_agg', 'frame_has_hash:array_agg']
)
values = {
sign_item.id : {
'value': values[0],
'frame': frame_values[0],
'frame_has_hash': frame_has_hashes[0],
}
for sign_item, values, frame_values, frame_has_hashes in values_dict
}
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()))
# Set page orientation (either 0, 90, 180 or 270)
rotation = page['/Rotate'] if '/Rotate' in page else 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 = itemsByPage[p + 1] if p + 1 in itemsByPage else []
for item in items:
value_dict = 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(_("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
)
# LOGIC FOR SEQUENCE TYPE (Same as text)
if item.type_id.item_type == "sequence":
if value:
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 == "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":
content = []
for option in item.option_ids:
if option.id != int(value):
content.append("%s" % (option.value))
else:
content.append(option.value)
font_size = height * normalFontSize * 0.8
text = " / ".join(content)
string_width = stringWidth(text.replace("", "").replace("", ""), 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 == "textarea":
font_size = height * normalFontSize * 0.8
can.setFont(font, font_size)
lines = value.split('\n')
y = (1-item.posY)
for line in lines:
empty_space = width * item.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
elif item.type_id.item_type == "checkbox":
can.setFont(font, height*item.height*0.8)
value = 'X' if value == 'on' else ''
can.drawString(width*item.posX, height*(1-item.posY-item.height*0.9), value)
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(_("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)
can.showPage()
can.save()
item_pdf = PdfFileReader(packet, overwriteWarnings=False)
new_pdf = PdfFileWriter()
for p in range(0, old_pdf.getNumPages()):
page = old_pdf.getPage(p)
page.mergePage(item_pdf.getPage(p))
new_pdf.addPage(page)
if isEncrypted:
new_pdf.encrypt(password)
try:
output = io.BytesIO()
new_pdf.write(output)
except PdfReadError:
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
self.completed_document = base64.b64encode(output.getvalue())
output.close()