feat: Implement sequence and image item types for Odoo Sign, including backend rendering and frontend integration.
This commit is contained in:
parent
923ac57db6
commit
1b840d015d
1
__init__.py
Normal file → Executable file
1
__init__.py
Normal file → Executable file
@ -1,2 +1,3 @@
|
|||||||
# -*- coding: utf-8 -*-
|
# -*- coding: utf-8 -*-
|
||||||
from . import models
|
from . import models
|
||||||
|
from . import controllers
|
||||||
|
|||||||
0
__manifest__.py
Normal file → Executable file
0
__manifest__.py
Normal file → Executable file
Binary file not shown.
2
controllers/__init__.py
Normal file
2
controllers/__init__.py
Normal file
@ -0,0 +1,2 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
from . import main
|
||||||
109
controllers/main.py
Normal file
109
controllers/main.py
Normal file
@ -0,0 +1,109 @@
|
|||||||
|
# -*- coding: utf-8 -*-
|
||||||
|
import base64
|
||||||
|
import io
|
||||||
|
import logging
|
||||||
|
|
||||||
|
from odoo import http
|
||||||
|
from odoo.http import request
|
||||||
|
from odoo.addons.sign.controllers.main import Sign
|
||||||
|
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
|
class SignController(Sign):
|
||||||
|
|
||||||
|
def get_document_qweb_context(self, sign_request_id, token, **post):
|
||||||
|
context = super().get_document_qweb_context(sign_request_id, token, **post)
|
||||||
|
|
||||||
|
# Optimization: Replace base64 image content with URL to prevent massive response size
|
||||||
|
# and "ERR_QUIC_PROTOCOL_ERROR" in production.
|
||||||
|
if 'item_values' in context and 'sign_items' in context:
|
||||||
|
image_items = context['sign_items'].filtered(lambda i: i.type_id.item_type == 'image')
|
||||||
|
for item in image_items:
|
||||||
|
if item.id in context['item_values']:
|
||||||
|
# Reset the value to a URL
|
||||||
|
# We use a special prefix to identify this as a placeholder URL
|
||||||
|
url = f"/sign/image/{sign_request_id}/{token}/{item.id}"
|
||||||
|
context['item_values'][item.id] = url
|
||||||
|
_logger.info(f"Replaced base64 content for item {item.id} with URL {url}")
|
||||||
|
|
||||||
|
return context
|
||||||
|
|
||||||
|
@http.route(['/sign/image/<int:sign_request_id>/<token>/<int:item_id>'], type='http', auth='public')
|
||||||
|
def get_sign_image(self, sign_request_id, token, item_id):
|
||||||
|
sign_request = request.env['sign.request'].sudo().browse(sign_request_id)
|
||||||
|
if not sign_request.exists() or sign_request.access_token != token:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
# Check access to the specific item value
|
||||||
|
# We need to find the value record for this request and item
|
||||||
|
# The logic mimics get_document_qweb_context's search for values
|
||||||
|
|
||||||
|
# Check if the user is the relevant signer (via token) or if the document is completed
|
||||||
|
current_request_item = sign_request.request_item_ids.filtered(lambda r: r.access_token == token)
|
||||||
|
|
||||||
|
# Allow access if:
|
||||||
|
# 1. User has the token for the request (already checked above with sign_request.access_token)
|
||||||
|
# 2. But sign_request.access_token is the SHARED token (for everyone?)
|
||||||
|
# - No, sign_request.access_token is usually for the 'shared' link.
|
||||||
|
# - Signers have individual tokens on request_item.
|
||||||
|
|
||||||
|
# Let's strengthen the check:
|
||||||
|
# If token matches sign_request.access_token -> Public/Shared view.
|
||||||
|
# If token matches request_item.access_token -> Specific signer.
|
||||||
|
|
||||||
|
is_shared_token = (sign_request.access_token == token)
|
||||||
|
signer_access = sign_request.request_item_ids.filtered(lambda r: r.access_token == token)
|
||||||
|
|
||||||
|
if not is_shared_token and not signer_access:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
# Fetch value
|
||||||
|
# We look for value associated with this request and item.
|
||||||
|
# Logic from get_document_qweb_context:
|
||||||
|
# sr_values = http.request.env['sign.request.item.value'].sudo().search([
|
||||||
|
# ('sign_request_id', '=', sign_request.id),
|
||||||
|
# '|',
|
||||||
|
# ('sign_request_item_id', '=', current_request_item.id),
|
||||||
|
# ('sign_request_item_id.state', '=', 'completed')
|
||||||
|
# ])
|
||||||
|
|
||||||
|
domain = [('sign_request_id', '=', sign_request.id), ('sign_item_id', '=', item_id)]
|
||||||
|
|
||||||
|
# If accessing via specific signer token, likely we can see our own values or completed ones.
|
||||||
|
# If shared token, we likely see completed ones.
|
||||||
|
# To be safe and consistent with the view, we fetch the value and assume if it exists in the system
|
||||||
|
# linked to this request, and the user has valid access to the *Request*, they can see it
|
||||||
|
# (images are generally public within the context of the document signatures).
|
||||||
|
|
||||||
|
value_record = request.env['sign.request.item.value'].sudo().search(domain, limit=1)
|
||||||
|
|
||||||
|
if not value_record or not value_record.value:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
try:
|
||||||
|
image_data = base64.b64decode(value_record.value)
|
||||||
|
return request.make_response(
|
||||||
|
image_data,
|
||||||
|
headers=[
|
||||||
|
('Content-Type', 'image/png'), # Assuming PNG or relying on browser sniffing.
|
||||||
|
# Realistically could be JPEG, but browser handles it.
|
||||||
|
('Cache-Control', 'max-age=3600, public'),
|
||||||
|
]
|
||||||
|
)
|
||||||
|
except Exception:
|
||||||
|
return request.not_found()
|
||||||
|
|
||||||
|
@http.route(['/sign/sign/<int:request_id>/<token>'], type='json', auth='public')
|
||||||
|
def sign_document(self, request_id, token, signature=None, items=None, **kwargs):
|
||||||
|
# Intercept items to prevent saving the URL as value
|
||||||
|
if items:
|
||||||
|
keys_to_remove = []
|
||||||
|
for key, value in items.items():
|
||||||
|
if isinstance(value, str) and value.startswith('/sign/image/'):
|
||||||
|
keys_to_remove.append(key)
|
||||||
|
|
||||||
|
for key in keys_to_remove:
|
||||||
|
_logger.info(f"Ignoring URL value for item {key} to preserve existing binary data")
|
||||||
|
del items[key]
|
||||||
|
|
||||||
|
return super().sign_document(request_id, token, signature=signature, items=items, **kwargs)
|
||||||
0
data/sign_item_type_data.xml
Normal file → Executable file
0
data/sign_item_type_data.xml
Normal file → Executable file
0
models/__init__.py
Normal file → Executable file
0
models/__init__.py
Normal file → Executable file
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
4
models/sign_item.py
Normal file → Executable file
4
models/sign_item.py
Normal file → Executable file
@ -6,12 +6,12 @@ class SignItem(models.Model):
|
|||||||
_inherit = "sign.item"
|
_inherit = "sign.item"
|
||||||
|
|
||||||
sequence_id = fields.Many2one('ir.sequence', string="Sequence")
|
sequence_id = fields.Many2one('ir.sequence', string="Sequence")
|
||||||
item_type = fields.Selection(related='type_id.item_type')
|
|
||||||
|
|
||||||
# Proxy fields to allow editing sequence from the sign item
|
# Proxy fields to allow editing sequence from the sign item
|
||||||
sequence_prefix = fields.Char(related='sequence_id.prefix', readonly=False)
|
sequence_prefix = fields.Char(related='sequence_id.prefix', readonly=False)
|
||||||
|
sequence_suffix = fields.Char(related='sequence_id.suffix', readonly=False)
|
||||||
sequence_padding = fields.Integer(related='sequence_id.padding', readonly=False)
|
sequence_padding = fields.Integer(related='sequence_id.padding', readonly=False)
|
||||||
sequence_number_next = fields.Integer(related='sequence_id.number_next_actual', readonly=False, string="Next Number")
|
sequence_number_next = fields.Integer(related='sequence_id.number_next', readonly=False, string="Next Number")
|
||||||
|
|
||||||
@api.onchange('type_id')
|
@api.onchange('type_id')
|
||||||
def _onchange_type_id_sequence(self):
|
def _onchange_type_id_sequence(self):
|
||||||
|
|||||||
0
models/sign_item_type.py
Normal file → Executable file
0
models/sign_item_type.py
Normal file → Executable file
45
models/sign_request.py
Normal file → Executable file
45
models/sign_request.py
Normal file → Executable file
@ -20,6 +20,9 @@ from odoo import api, fields, models, _, Command
|
|||||||
from odoo.exceptions import UserError, ValidationError
|
from odoo.exceptions import UserError, ValidationError
|
||||||
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
|
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
|
||||||
|
|
||||||
|
import logging
|
||||||
|
_logger = logging.getLogger(__name__)
|
||||||
|
|
||||||
# Helper function copied from sign/models/sign_request.py
|
# Helper function copied from sign/models/sign_request.py
|
||||||
def _fix_image_transparency(image):
|
def _fix_image_transparency(image):
|
||||||
pixels = image.load()
|
pixels = image.load()
|
||||||
@ -83,18 +86,23 @@ class SignRequest(models.Model):
|
|||||||
return super(SignRequest, self)._sign()
|
return super(SignRequest, self)._sign()
|
||||||
|
|
||||||
def _generate_completed_document(self, password=""):
|
def _generate_completed_document(self, password=""):
|
||||||
# We need to override this entire method to handle 'sequence' item type
|
# We need to override this entire method to handle 'sequence' and 'image' item types
|
||||||
# as the original method doesn't have hooks or generic handling.
|
# as the original method doesn't have hooks or generic handling.
|
||||||
|
_logger.info("Starting _generate_completed_document for request %s", self.id)
|
||||||
self.ensure_one()
|
self.ensure_one()
|
||||||
if self.state != 'signed':
|
if self.state != 'signed':
|
||||||
raise UserError(_("The completed document cannot be created because the sign request is not fully 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:
|
if not self.template_id.sign_item_ids:
|
||||||
|
_logger.info("No sign items, copying original attachment")
|
||||||
self.completed_document = self.template_id.attachment_id.datas
|
self.completed_document = self.template_id.attachment_id.datas
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
|
_logger.info("Reading original PDF")
|
||||||
old_pdf = PdfFileReader(io.BytesIO(base64.b64decode(self.template_id.attachment_id.datas)), strict=False, overwriteWarnings=False)
|
old_pdf = PdfFileReader(io.BytesIO(base64.b64decode(self.template_id.attachment_id.datas)), strict=False, overwriteWarnings=False)
|
||||||
old_pdf.getNumPages()
|
num_pages = old_pdf.getNumPages()
|
||||||
except:
|
_logger.info("Original PDF has %s pages", num_pages)
|
||||||
|
except Exception as e:
|
||||||
|
_logger.error("Failed to read PDF: %s", e)
|
||||||
raise ValidationError(_("ERROR: Invalid PDF file!"))
|
raise ValidationError(_("ERROR: Invalid PDF file!"))
|
||||||
|
|
||||||
isEncrypted = old_pdf.isEncrypted
|
isEncrypted = old_pdf.isEncrypted
|
||||||
@ -109,6 +117,7 @@ class SignRequest(models.Model):
|
|||||||
can = canvas.Canvas(packet, pagesize=self.get_page_size(old_pdf))
|
can = canvas.Canvas(packet, pagesize=self.get_page_size(old_pdf))
|
||||||
itemsByPage = self.template_id._get_sign_items_by_page()
|
itemsByPage = self.template_id._get_sign_items_by_page()
|
||||||
items_ids = [id for items in itemsByPage.values() for id in items.ids]
|
items_ids = [id for items in itemsByPage.values() for id in items.ids]
|
||||||
|
_logger.info("Fetching item values for %s items", len(items_ids))
|
||||||
values_dict = self.env['sign.request.item.value']._read_group(
|
values_dict = self.env['sign.request.item.value']._read_group(
|
||||||
[('sign_item_id', 'in', items_ids), ('sign_request_id', '=', self.id)],
|
[('sign_item_id', 'in', items_ids), ('sign_request_id', '=', self.id)],
|
||||||
groupby=['sign_item_id'],
|
groupby=['sign_item_id'],
|
||||||
@ -116,12 +125,13 @@ class SignRequest(models.Model):
|
|||||||
)
|
)
|
||||||
values = {
|
values = {
|
||||||
sign_item.id : {
|
sign_item.id : {
|
||||||
'value': values[0],
|
'value': v[0] if v else None,
|
||||||
'frame': frame_values[0],
|
'frame': f[0] if f else None,
|
||||||
'frame_has_hash': frame_has_hashes[0],
|
'frame_has_hash': h[0] if h else None,
|
||||||
}
|
}
|
||||||
for sign_item, values, frame_values, frame_has_hashes in values_dict
|
for sign_item, v, f, h in values_dict
|
||||||
}
|
}
|
||||||
|
_logger.info("Retrieved %s item values", len(values))
|
||||||
|
|
||||||
for p in range(0, old_pdf.getNumPages()):
|
for p in range(0, old_pdf.getNumPages()):
|
||||||
page = old_pdf.getPage(p)
|
page = old_pdf.getPage(p)
|
||||||
@ -182,6 +192,7 @@ class SignRequest(models.Model):
|
|||||||
can.drawCentredString(width*(item.posX+item.width/2), height*(1-item.posY-item.height*0.9), value)
|
can.drawCentredString(width*(item.posX+item.width/2), height*(1-item.posY-item.height*0.9), value)
|
||||||
|
|
||||||
elif item.type_id.item_type == "text":
|
elif item.type_id.item_type == "text":
|
||||||
|
if value:
|
||||||
value = reshape_text(value)
|
value = reshape_text(value)
|
||||||
can.setFont(font, height*item.height*0.8)
|
can.setFont(font, height*item.height*0.8)
|
||||||
if item.alignment == "left":
|
if item.alignment == "left":
|
||||||
@ -192,6 +203,7 @@ class SignRequest(models.Model):
|
|||||||
can.drawCentredString(width*(item.posX+item.width/2), height*(1-item.posY-item.height*0.9), value)
|
can.drawCentredString(width*(item.posX+item.width/2), height*(1-item.posY-item.height*0.9), value)
|
||||||
|
|
||||||
elif item.type_id.item_type == "selection":
|
elif item.type_id.item_type == "selection":
|
||||||
|
if value:
|
||||||
content = []
|
content = []
|
||||||
for option in item.option_ids:
|
for option in item.option_ids:
|
||||||
if option.id != int(value):
|
if option.id != int(value):
|
||||||
@ -207,6 +219,7 @@ class SignRequest(models.Model):
|
|||||||
p.drawOn(can, posX, posY)
|
p.drawOn(can, posX, posY)
|
||||||
|
|
||||||
elif item.type_id.item_type == "textarea":
|
elif item.type_id.item_type == "textarea":
|
||||||
|
if value:
|
||||||
font_size = height * normalFontSize * 0.8
|
font_size = height * normalFontSize * 0.8
|
||||||
can.setFont(font, font_size)
|
can.setFont(font, font_size)
|
||||||
lines = value.split('\n')
|
lines = value.split('\n')
|
||||||
@ -240,7 +253,8 @@ class SignRequest(models.Model):
|
|||||||
if value == "on":
|
if value == "on":
|
||||||
# Draw the inner filled circle.
|
# Draw the inner filled circle.
|
||||||
can.circle(x_cen=c_x, y_cen=c_y, r=h * 0.5 * 0.75, fill=1)
|
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":
|
elif item.type_id.item_type in ["signature", "initial", "image"]:
|
||||||
|
if value:
|
||||||
try:
|
try:
|
||||||
image_reader = ImageReader(io.BytesIO(base64.b64decode(value[value.find(',')+1:])))
|
image_reader = ImageReader(io.BytesIO(base64.b64decode(value[value.find(',')+1:])))
|
||||||
except UnidentifiedImageError:
|
except UnidentifiedImageError:
|
||||||
@ -269,6 +283,19 @@ class SignRequest(models.Model):
|
|||||||
except PdfReadError:
|
except PdfReadError:
|
||||||
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
|
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
|
||||||
|
|
||||||
self.completed_document = base64.b64encode(output.getvalue())
|
_logger.info("Completed document generated, size: %s bytes", len(self.completed_document))
|
||||||
output.close()
|
output.close()
|
||||||
|
|
||||||
|
# Odoo 18 logic to create attachment and link it
|
||||||
|
# This matches enterprise/sign/models/sign_request.py:797+
|
||||||
|
_logger.info("Creating ir.attachment for completed document")
|
||||||
|
attachment = self.env['ir.attachment'].create({
|
||||||
|
'name': "%s.pdf" % self.reference if self.reference.split('.')[-1] != 'pdf' else self.reference,
|
||||||
|
'datas': self.completed_document,
|
||||||
|
'type': 'binary',
|
||||||
|
'res_model': self._name,
|
||||||
|
'res_id': self.id,
|
||||||
|
})
|
||||||
|
self.completed_document_attachment_ids = [Command.set([attachment.id])]
|
||||||
|
_logger.info("Attachment created: %s", attachment.id)
|
||||||
|
|
||||||
|
|||||||
0
static/src/js/sign_backend_patch.js
Normal file → Executable file
0
static/src/js/sign_backend_patch.js
Normal file → Executable file
0
static/src/js/sign_item_custom_popover_patch.js
Normal file → Executable file
0
static/src/js/sign_item_custom_popover_patch.js
Normal file → Executable file
0
static/src/xml/sign_item_custom_popover_patch.xml
Normal file → Executable file
0
static/src/xml/sign_item_custom_popover_patch.xml
Normal file → Executable file
0
static/src/xml/sign_item_sequence.xml
Normal file → Executable file
0
static/src/xml/sign_item_sequence.xml
Normal file → Executable file
12
views/sign_item_views.xml
Normal file → Executable file
12
views/sign_item_views.xml
Normal file → Executable file
@ -5,12 +5,12 @@
|
|||||||
<field name="model">sign.item</field>
|
<field name="model">sign.item</field>
|
||||||
<field name="inherit_id" ref="sign.sign_item_view_form"/>
|
<field name="inherit_id" ref="sign.sign_item_view_form"/>
|
||||||
<field name="arch" type="xml">
|
<field name="arch" type="xml">
|
||||||
<xpath expr="//field[@name='type_id']" position="after">
|
<xpath expr="//field[@name='responsible_id']" position="after">
|
||||||
<field name="item_type" invisible="1"/>
|
<field name="sequence_id" invisible="type_id.item_type != 'sequence'"/>
|
||||||
<field name="sequence_id" invisible="item_type != 'sequence'"/>
|
<field name="sequence_prefix" invisible="type_id.item_type != 'sequence'"/>
|
||||||
<field name="sequence_prefix" invisible="item_type != 'sequence'"/>
|
<field name="sequence_suffix" invisible="type_id.item_type != 'sequence'"/>
|
||||||
<field name="sequence_padding" invisible="item_type != 'sequence'"/>
|
<field name="sequence_padding" invisible="type_id.item_type != 'sequence'"/>
|
||||||
<field name="sequence_number_next" invisible="item_type != 'sequence'"/>
|
<field name="sequence_number_next" invisible="type_id.item_type != 'sequence'"/>
|
||||||
</xpath>
|
</xpath>
|
||||||
</field>
|
</field>
|
||||||
</record>
|
</record>
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user