From 1b840d015d9b821a0986bad3f0142e7ff7d828fb Mon Sep 17 00:00:00 2001 From: Suherdy Yacob Date: Wed, 7 Jan 2026 15:42:50 +0700 Subject: [PATCH] feat: Implement sequence and image item types for Odoo Sign, including backend rendering and frontend integration. --- __init__.py | 1 + __manifest__.py | 0 __pycache__/__init__.cpython-312.pyc | Bin 225 -> 207 bytes controllers/__init__.py | 2 + controllers/main.py | 109 +++++++++++++++ data/sign_item_type_data.xml | 0 models/__init__.py | 0 models/__pycache__/__init__.cpython-312.pyc | Bin 309 -> 291 bytes models/__pycache__/sign_item.cpython-312.pyc | Bin 2217 -> 2295 bytes .../sign_item_type.cpython-312.pyc | Bin 672 -> 654 bytes .../__pycache__/sign_request.cpython-312.pyc | Bin 15392 -> 16001 bytes models/sign_item.py | 4 +- models/sign_item_type.py | 0 models/sign_request.py | 129 +++++++++++------- static/src/js/sign_backend_patch.js | 0 .../src/js/sign_item_custom_popover_patch.js | 0 .../xml/sign_item_custom_popover_patch.xml | 0 static/src/xml/sign_item_sequence.xml | 0 views/sign_item_views.xml | 12 +- 19 files changed, 198 insertions(+), 59 deletions(-) mode change 100644 => 100755 __init__.py mode change 100644 => 100755 __manifest__.py create mode 100644 controllers/__init__.py create mode 100644 controllers/main.py mode change 100644 => 100755 data/sign_item_type_data.xml mode change 100644 => 100755 models/__init__.py mode change 100644 => 100755 models/sign_item.py mode change 100644 => 100755 models/sign_item_type.py mode change 100644 => 100755 models/sign_request.py mode change 100644 => 100755 static/src/js/sign_backend_patch.js mode change 100644 => 100755 static/src/js/sign_item_custom_popover_patch.js mode change 100644 => 100755 static/src/xml/sign_item_custom_popover_patch.xml mode change 100644 => 100755 static/src/xml/sign_item_sequence.xml mode change 100644 => 100755 views/sign_item_views.xml 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 199e5ff8e94ef46f9735cb4e195a50b153005785..349f5e37a56810554673a1a949a947b5914ff99d 100644 GIT binary patch delta 36 qcmaFJc%G5#G%qg~0}%YqoyZl;=cAvIpPQ;*T$+(ulu|jdHU$91)(hDH delta 54 zcmX@l_>ht7G%qg~0}${VPUH$!h||x=&rQ`YF3m_SN~zSVOia#Ca>z|ANX*kq&ezLK InV6jd0H|3JM*si- 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 b5f89a190443b2272bf679c455e5e5236def0565..df411901de1c198a165be0fcf256ef21165f7928 100644 GIT binary patch delta 36 qcmdnWw3vzOG%qg~0}%YpoyawdFIYb#KQ~psxHKcRD5Y}ZkwO5#mkcKW delta 54 zcmZ3?w3UhLG%qg~0}u!qPUM=UkgA`NpPQ;*T$+(ulv1fznV6iP76oLX3#nwOj!pP8apT$+}aS+UuViJ6fvL_Z@xH&ws5G$XYr zrE+p1a|ff~=3mUVOpFqfE!b)pg(q)i%i`sSYX|C`EX|(5xM*?zH ze4j0Aaujl=Ii@ojPF~L;!Du@91c!{U45M;KS$|Du%~u8>vq&6hJph}j BHgf;~ diff --git a/models/__pycache__/sign_item_type.cpython-312.pyc b/models/__pycache__/sign_item_type.cpython-312.pyc index 2c56f07c6df7737b95e5651a761815eba61bb61d..200604d04f3477dd2c37fdb398dd9d62e052e097 100644 GIT binary patch delta 38 scmZ3$+Q-UunwOW00SNx&ZsclWc$h~B7%y+*c(E}yV8hsug$0~e zt+drrZK9Djpk_CAS6iT|8Y8Cy+LdssRv}tNJC{k~sT)=K(I4ACl|$0*qg%Ci*iE90 zuI8M39^XCZ%sF?0@BC;%_<35I07H+HKjiw(up#_4N7$nt^-eg{zUfg1VV{jKoc|ue zOR~uK@dEOjR39iJfv}dmnU;F!N{Z)FGUgYNUQt5?7YaFmilnClJoz&WTgaRjx;x2` zNEA_tX1K{^^Cx`x6@2Le`RVchfcs?V#2!2LGwZ{TzC=Mp{mlHI3Q&S2;y{t|xR z#$Vz^)cI2Y?2w^?LO#vK=K1ccq(&qnpA?(`y2v*Le+1ll%w0}GzKCBXMzwH%%EryX z+`l60l0@KrR+FQ@3D`PenBBnI*sI5MD7M4))5sUu9TO$0Qo2ftVL|#`8q{juXzpk=z{1|~c2M(axJ8h33ZY~CE$7=Ws?Q4v(tKge!i@}xHwR~EyW zdwDuWB3%`Rglp5ts&AujJrL#Xf?e4c@_X`oDU`W4X0}4JmAW_#&BM?*4A0uBk+DaT zaVEcTH8+$S*6rmR5JpU*&4dzdxM8b&N58F)n(y}C>4()9U^!{lDg%W89oN5D4-Z zseDx*(%Xny)<08d3xjYL)qCxGV7iY zU~+Xh7Z#t1N?~!sax)_>UNwXaYff5Pxm^EPQ5p7c&Tq^|Tc{2bt?P>E2B2<$8gx>w zF)CGU!WC>%WaP(@-^9QAywZ;*+(hgJj$jCZ^oZvn5G4PhH`4fm+KJxTK%o& z_2w<^j$m65?SfTCs5C-p^KKh8Xn}1OD77rtqmVhUWLSoK=&FB{ACAiIi@$yZxLtzj-Lt3XYcUl1&3%N%+GWl>zZj|p-NK`s+eX~XtS z_lBEoLhao%cg|2QHDXd}*(+772q*j3hre6ds0!dMMEc!Q&!rxSlT3qjO&NvVU`SV-~8whCRpC|Alt~! z;`x>HYs#&pE&GmZ+qHdoH%H2$?1dSsSVjfRs9-b| zo4FggF--&1G(?AKP0MZ>)U;Eko;^*^72~p>y-xOyl~NXz!x*%TF&W}mh7x8dnPb@u zn$fJ4EC!)EuR5Qm@N|M_7%wyBJ;8G!rZsFTHWXWPI}6(jTV6V?tew(ac#r$=km~D1 zfLyA2t8@m(WQKhVuwUe4#cdnPPv6Xritb=widxx>(byz7$b=N?6*Xmb1w;z==jtp% z#=Dzwk|+_}PpRV(W!x{Nbv&Pl`_Gg@Upcm)l1b!q_RDg8NBGY~xjqg5S$VGiC^@6A z1#$9sdaW`8-M?S|1Y-{8TsA)EYLZTUH3C{WNgaSFWN+*u^pY!xAG7@Bh{*5>5bPtK e{pI@l$!K-!@j5Q(09Q_4Q(jg63p>~xc>W*zN;W$H delta 1586 zcmY*ZZA?>F7{2GWrBDivfxxw&lon|DsC=)bqF_M>t|9|$1;s)ORE2&zECcG2?bkHX zoJ`vvrjY%hI1`Iq2K+FTElSB|xQB|pVQgVP7MEq&iXYQtS+ZM}n0WKv`#yQjecp4Q z_ndn^nV7ni{(V{+2cxf-ry4Ea)y}2gW{54yA?vVN>AW~(c9`5{Zezc-@6fQ(W*jN& zx0YE#^l!q8L zkv*P%?gX?Gdqd((7Za4hTRH!LRS2>d8H_81@P76*o!!uwQ=B3>UxZ=TfI}6k-em>Dr(r9ER)e?bcV(hmvO#a>3E+W+Dx_? zoz79K!?ek^UNH6AOaqQd*im{Kd5Yhv zE+g5e8i@4`qrPEs#E$yxsLPIY4$|d*!XMjCR4PaO@}O)XYl&DS!c{BkWp%{3tU;>d zr~*E!D)I0qw+Vrt@8ib^B_fo;x+qakNu*vjRiK!$PFtU0OyYpInG(tTZlC+k;UMd) zmUU4LB`%yvpH3&0+DOtGca@8jog3n=?f==pM|a0($2Y{9cnU3!b1+$P;0%)1E%YO4 zgExf|N&REKu|Q!|RN?V5w+btQRr8uTO_&pDj8-0xRh~qZw8?(bJQ%GUBF~MGHg~je z%*);4%ce|sbmXB<@~nxpUW#MbVY~@{fnlZ1_;U;s9KpSSt{!0CO_@yzYUf+#TEdyA zN{foLNYoVJl6nI=YCs}`mq{B6WCS`qcMY?KSpnT2-XbsTM5-1fZ$YBgh;eOTb%5+V zMfUVXb^WBtjC2F!x${VO9*NG=@?657>`R`?oH7NpPjbuOZlZdfI6z4iZ<)^J`BQzV zQ`&(1ac&hQmd&J2rv|j1sJPtAq69*(E74zHH*G*Fe}-|08n2d8D1*{@#hd~uGz-op z&!Q*dB0CLaw<&tij1*>XD^*bFJwl1{{gb}QVD7p|MJcKmz^rnbP36gEgww)c?RuV? zCa2gGUoa&M=A*o7#IN=?Y~?G1%xHdHH9gInU0XG;np2@-&B7=uZjL08C)!szWQYDS z-|&{&*YIpF{edA^D+D>gx^<#HEL|yCE{QejQKO#hF+XlJLw&_b&(wur(}HwKz9^qN z9`1^qh`83=tM1iq(r`LvFd;fVFrg+hIe38_w#SA?5gmi0q}xNUYy-WruM+7PzVjxH zfnzv5ro%fDsS7`a|BYcpHy&?gB>qo4u47;sSvR@Yxi`||A15;=K$<8%$$-dC#^!f4nQ;gapxP0^lvz)d6)c_dB zSv-d%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 - - - - - - + + + + + +