diff --git a/__init__.py b/__init__.py old mode 100644 new mode 100755 index a0fdc10..3b38916 --- a/__init__.py +++ b/__init__.py @@ -1,2 +1,3 @@ # -*- coding: utf-8 -*- from . import models +from . import controllers diff --git a/__manifest__.py b/__manifest__.py old mode 100644 new mode 100755 diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc index 199e5ff..349f5e3 100644 Binary files a/__pycache__/__init__.cpython-312.pyc and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/controllers/__init__.py b/controllers/__init__.py new file mode 100644 index 0000000..757b12a --- /dev/null +++ b/controllers/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import main diff --git a/controllers/main.py b/controllers/main.py new file mode 100644 index 0000000..fb791cc --- /dev/null +++ b/controllers/main.py @@ -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///'], 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//'], 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) diff --git a/data/sign_item_type_data.xml b/data/sign_item_type_data.xml old mode 100644 new mode 100755 diff --git a/models/__init__.py b/models/__init__.py old mode 100644 new mode 100755 diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc index b5f89a1..df41190 100644 Binary files a/models/__pycache__/__init__.cpython-312.pyc and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/sign_item.cpython-312.pyc b/models/__pycache__/sign_item.cpython-312.pyc index 8139981..db9e88c 100644 Binary files a/models/__pycache__/sign_item.cpython-312.pyc and b/models/__pycache__/sign_item.cpython-312.pyc differ diff --git a/models/__pycache__/sign_item_type.cpython-312.pyc b/models/__pycache__/sign_item_type.cpython-312.pyc index 2c56f07..200604d 100644 Binary files a/models/__pycache__/sign_item_type.cpython-312.pyc and b/models/__pycache__/sign_item_type.cpython-312.pyc differ diff --git a/models/__pycache__/sign_request.cpython-312.pyc b/models/__pycache__/sign_request.cpython-312.pyc index f4cf83f..ec34735 100644 Binary files a/models/__pycache__/sign_request.cpython-312.pyc and b/models/__pycache__/sign_request.cpython-312.pyc differ diff --git a/models/sign_item.py b/models/sign_item.py old mode 100644 new mode 100755 index 7beac3b..f52cc41 --- a/models/sign_item.py +++ b/models/sign_item.py @@ -6,12 +6,12 @@ class SignItem(models.Model): _inherit = "sign.item" 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 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_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') def _onchange_type_id_sequence(self): diff --git a/models/sign_item_type.py b/models/sign_item_type.py old mode 100644 new mode 100755 diff --git a/models/sign_request.py b/models/sign_request.py old mode 100644 new mode 100755 index 20ba013..bf98748 --- a/models/sign_request.py +++ b/models/sign_request.py @@ -20,6 +20,9 @@ from odoo import api, fields, models, _, Command from odoo.exceptions import UserError, ValidationError 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 def _fix_image_transparency(image): pixels = image.load() @@ -83,18 +86,23 @@ class SignRequest(models.Model): return super(SignRequest, self)._sign() 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. + _logger.info("Starting _generate_completed_document for request %s", self.id) 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: + _logger.info("No sign items, copying original attachment") self.completed_document = self.template_id.attachment_id.datas else: 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.getNumPages() - except: + num_pages = old_pdf.getNumPages() + _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!")) isEncrypted = old_pdf.isEncrypted @@ -109,6 +117,7 @@ class SignRequest(models.Model): 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] + _logger.info("Fetching item values for %s items", len(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'], @@ -116,12 +125,13 @@ class SignRequest(models.Model): ) values = { sign_item.id : { - 'value': values[0], - 'frame': frame_values[0], - 'frame_has_hash': frame_has_hashes[0], + 'value': v[0] if v else None, + 'frame': f[0] if f else None, + '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()): page = old_pdf.getPage(p) @@ -182,46 +192,49 @@ class SignRequest(models.Model): 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) + 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 == "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) + if value: + 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 + if value: + 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) @@ -240,13 +253,14 @@ class SignRequest(models.Model): 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) + elif item.type_id.item_type in ["signature", "initial", "image"]: + if value: + 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() @@ -269,6 +283,19 @@ class SignRequest(models.Model): except PdfReadError: 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() + + # 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) diff --git a/static/src/js/sign_backend_patch.js b/static/src/js/sign_backend_patch.js old mode 100644 new mode 100755 diff --git a/static/src/js/sign_item_custom_popover_patch.js b/static/src/js/sign_item_custom_popover_patch.js old mode 100644 new mode 100755 diff --git a/static/src/xml/sign_item_custom_popover_patch.xml b/static/src/xml/sign_item_custom_popover_patch.xml old mode 100644 new mode 100755 diff --git a/static/src/xml/sign_item_sequence.xml b/static/src/xml/sign_item_sequence.xml old mode 100644 new mode 100755 diff --git a/views/sign_item_views.xml b/views/sign_item_views.xml old mode 100644 new mode 100755 index 5e8e49c..4d7747b --- a/views/sign_item_views.xml +++ b/views/sign_item_views.xml @@ -5,12 +5,12 @@ sign.item - - - - - - + + + + + +