Skip to content

Commit c507818

Browse files
committed
[IMP] sign_stamp: Enhance document signing with stamp
- Extended res.users, sign.request, and sign.template models to support stamp functionality. - Added a Stamp button in sign templates for inserting stamps during signing. - Enhanced sign request components to handle stamp-related logic seamlessly. - Updated name_and_signature component with a dialog for stamp input, image upload, and drawing. - Modified sign.request.template view to auto-fetch and display stored stamp data. - Ensured proper storage, retrieval, and display of stamp details across views and components. - Inherited sign._doc_sign template to add hidden fields for signer’s company, address, city, country, VAT, and logo. - Ensured additional signer details are available for document customization without modifying core files.
1 parent 4c650f3 commit c507818

18 files changed

+1091
-0
lines changed

sign_stamp/__init__.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
from . import models
2+
from . import controllers

sign_stamp/__manifest__.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
{
2+
'name': 'Sign Stamp',
3+
'version': '1.0',
4+
'depends': ['sign'],
5+
'data': [
6+
'data/sign_data.xml',
7+
'views/sign_request_templates.xml',
8+
],
9+
'application': True,
10+
'installable': True,
11+
'license': 'OEEL-1',
12+
'assets': {
13+
'web.assets_backend': [
14+
'sign_stamp/static/src/components/**/*',
15+
'sign_stamp/static/src/dialogs/*',
16+
],
17+
'sign.assets_public_sign': [
18+
'sign_stamp/static/src/components/**/*',
19+
'sign_stamp/static/src/dialogs/*',
20+
]
21+
},
22+
}

sign_stamp/controllers/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
from . import main

sign_stamp/controllers/main.py

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,32 @@
1+
from odoo import http
2+
from odoo.addons.sign.controllers.main import Sign
3+
4+
5+
class Sign(Sign):
6+
7+
def get_document_qweb_context(self, sign_request_id, token, **post):
8+
data = super().get_document_qweb_context(sign_request_id, token, **post)
9+
current_request_item = data['current_request_item']
10+
sign_item_types = data['sign_item_types']
11+
data['logo'] = 'data:image/png;base64,%s' % http.request.env.user.company_id.logo.decode()
12+
13+
if current_request_item:
14+
for item_type in sign_item_types:
15+
if item_type['item_type'] == 'stamp':
16+
user_stamp = current_request_item._get_user_stamp()
17+
user_stamp_frame = current_request_item._get_user_stamp_frame()
18+
item_type['auto_value'] = 'data:image/png;base64,%s' % user_stamp.decode() if user_stamp else False
19+
item_type['frame_value'] = 'data:image/png;base64,%s' % user_stamp_frame.decode() if user_stamp_frame else False
20+
return data
21+
22+
@http.route(["/sign/update_user_signature"], type="json", auth="user")
23+
def update_signature(self, sign_request_id, role, signature_type=None, datas=None, frame_datas=None):
24+
sign_request_item_sudo = http.request.env['sign.request.item'].sudo().search([('sign_request_id', '=', sign_request_id), ('role_id', '=', role)], limit=1)
25+
user = http.request.env.user
26+
allowed = sign_request_item_sudo.partner_id.id == user.partner_id.id
27+
if not allowed or signature_type not in ['sign_signature', 'sign_initials', 'sign_stamp'] or not user:
28+
return False
29+
user[signature_type] = datas[datas.find(',') + 1:]
30+
if frame_datas:
31+
user[signature_type + '_frame'] = frame_datas[frame_datas.find(',') + 1:]
32+
return True

sign_stamp/data/sign_data.xml

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,11 @@
1+
<odoo>
2+
<record model="sign.item.type" id="sign_item_type_stamp">
3+
<field name="name">Stamp</field>
4+
<field name="placeholder">Stamp</field>
5+
<field name="item_type">stamp</field>
6+
<field name="tip">Upload company stamp</field>
7+
<field name="default_width" type="float">0.250</field>
8+
<field name="default_height" type="float">0.100</field>
9+
<field name="icon">fa-certificate</field>
10+
</record>
11+
</odoo>

sign_stamp/models/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
from . import res_users
2+
from . import sign_template
3+
from . import sign_request

sign_stamp/models/res_users.py

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,18 @@
1+
from odoo import fields, models
2+
3+
4+
SIGN_USER_FIELDS = ['sign_stamp']
5+
6+
class ResUsers(models.Model):
7+
_inherit = 'res.users'
8+
9+
@property
10+
def SELF_READABLE_FIELDS(self):
11+
return super().SELF_READABLE_FIELDS + SIGN_USER_FIELDS
12+
13+
@property
14+
def SELF_WRITEABLE_FIELDS(self):
15+
return super().SELF_WRITEABLE_FIELDS + SIGN_USER_FIELDS
16+
17+
sign_stamp = fields.Binary(string="Digital Stamp", copy=False, groups="base.group_user")
18+
sign_stamp_frame = fields.Binary(string="Digital Stamp Frame", copy=False, groups="base.group_user")

sign_stamp/models/sign_request.py

Lines changed: 242 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,242 @@
1+
import base64
2+
import io
3+
import time
4+
5+
from PIL import UnidentifiedImageError
6+
from reportlab.lib.utils import ImageReader
7+
from reportlab.pdfgen import canvas
8+
from reportlab.platypus import Paragraph
9+
from reportlab.lib.styles import ParagraphStyle
10+
from reportlab.pdfbase.pdfmetrics import stringWidth
11+
12+
from odoo import _, models, Command
13+
from odoo.tools import format_date
14+
from odoo.exceptions import UserError, ValidationError
15+
from PyPDF2 import PdfFileReader, PdfFileWriter
16+
try:
17+
from PyPDF2.errors import PdfReadError
18+
except ImportError:
19+
from PyPDF2.utils import PdfReadError
20+
from odoo.tools.pdf import reshape_text
21+
22+
23+
def _fix_image_transparency(image):
24+
pixels = image.load()
25+
for x in range(image.size[0]):
26+
for y in range(image.size[1]):
27+
if pixels[x, y] == (0, 0, 0, 0):
28+
pixels[x, y] = (255, 255, 255, 0)
29+
30+
31+
class SignRequest(models.Model):
32+
_inherit = "sign.request"
33+
34+
def _generate_completed_document(self, password=""):
35+
self.ensure_one()
36+
if self.state != 'signed':
37+
raise UserError(_("The completed document cannot be created because the sign request is not fully signed"))
38+
if not self.template_id.sign_item_ids:
39+
self.completed_document = self.template_id.attachment_id.datas
40+
else:
41+
try:
42+
old_pdf = PdfFileReader(io.BytesIO(base64.b64decode(self.template_id.attachment_id.datas)), strict=False, overwriteWarnings=False)
43+
old_pdf.getNumPages()
44+
except:
45+
raise ValidationError(_("ERROR: Invalid PDF file!"))
46+
47+
isEncrypted = old_pdf.isEncrypted
48+
if isEncrypted and not old_pdf.decrypt(password):
49+
return
50+
51+
font = self._get_font()
52+
normalFontSize = self._get_normal_font_size()
53+
54+
packet = io.BytesIO()
55+
can = canvas.Canvas(packet, pagesize=self.get_page_size(old_pdf))
56+
itemsByPage = self.template_id._get_sign_items_by_page()
57+
items_ids = [id for items in itemsByPage.values() for id in items.ids]
58+
values_dict = self.env['sign.request.item.value']._read_group(
59+
[('sign_item_id', 'in', items_ids), ('sign_request_id', '=', self.id)],
60+
groupby=['sign_item_id'],
61+
aggregates=['value:array_agg', 'frame_value:array_agg', 'frame_has_hash:array_agg']
62+
)
63+
values = {
64+
sign_item.id: {
65+
'value': values[0],
66+
'frame': frame_values[0],
67+
'frame_has_hash': frame_has_hashes[0],
68+
}
69+
for sign_item, values, frame_values, frame_has_hashes in values_dict
70+
}
71+
72+
for p in range(0, old_pdf.getNumPages()):
73+
page = old_pdf.getPage(p)
74+
width = float(abs(page.mediaBox.getWidth()))
75+
height = float(abs(page.mediaBox.getHeight()))
76+
77+
rotation = page['/Rotate'] if '/Rotate' in page else 0
78+
if rotation and isinstance(rotation, int):
79+
can.rotate(rotation)
80+
if rotation == 90:
81+
width, height = height, width
82+
can.translate(0, -height)
83+
elif rotation == 180:
84+
can.translate(-width, -height)
85+
elif rotation == 270:
86+
width, height = height, width
87+
can.translate(-width, 0)
88+
89+
items = itemsByPage[p + 1] if p + 1 in itemsByPage else []
90+
for item in items:
91+
value_dict = values.get(item.id)
92+
if not value_dict:
93+
continue
94+
value = value_dict['value']
95+
frame = value_dict['frame']
96+
97+
if frame:
98+
try:
99+
image_reader = ImageReader(io.BytesIO(base64.b64decode(frame[frame.find(',') + 1:])))
100+
except UnidentifiedImageError:
101+
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
102+
_fix_image_transparency(image_reader._image)
103+
can.drawImage(
104+
image_reader,
105+
width * item.posX,
106+
height * (1-item.posY-item.height),
107+
width * item.width,
108+
height * item.height,
109+
'auto',
110+
True
111+
)
112+
113+
if item.type_id.item_type == "text":
114+
value = reshape_text(value)
115+
can.setFont(font, height*item.height * 0.8)
116+
if item.alignment == "left":
117+
can.drawString(width * item.posX, height * (1 - item.posY - item.height * 0.9), value)
118+
elif item.alignment == "right":
119+
can.drawRightString(width * (item.posX + item.width), height * (1 - item.posY - item.height * 0.9), value)
120+
else:
121+
can.drawCentredString(width * (item.posX + item.width / 2), height * (1 - item.posY - item.height * 0.9), value)
122+
123+
elif item.type_id.item_type == "selection":
124+
content = []
125+
for option in item.option_ids:
126+
if option.id != int(value):
127+
content.append("<strike>%s</strike>" % (option.value))
128+
else:
129+
content.append(option.value)
130+
font_size = height * normalFontSize * 0.8
131+
text = " / ".join(content)
132+
string_width = stringWidth(text.replace("<strike>", "").replace("</strike>", ""), font, font_size)
133+
p = Paragraph(text, ParagraphStyle(name='Selection Paragraph', fontName=font, fontSize=font_size, leading=12))
134+
posX = width * (item.posX + item.width * 0.5) - string_width // 2
135+
posY = height * (1 - item.posY - item.height * 0.5) - p.wrap(width, height)[1] // 2
136+
p.drawOn(can, posX, posY)
137+
138+
elif item.type_id.item_type == "textarea":
139+
font_size = height * normalFontSize * 0.8
140+
can.setFont(font, font_size)
141+
lines = value.split('\n')
142+
y = (1 - item.posY)
143+
for line in lines:
144+
empty_space = width * item.width - can.stringWidth(line, font, font_size)
145+
x_shift = 0
146+
if item.alignment == 'center':
147+
x_shift = empty_space / 2
148+
elif item.alignment == 'right':
149+
x_shift = empty_space
150+
y -= normalFontSize * 0.9
151+
line = reshape_text(line)
152+
can.drawString(width * item.posX + x_shift, height * y, line)
153+
y -= normalFontSize * 0.1
154+
155+
elif item.type_id.item_type == "checkbox":
156+
can.setFont(font, height * item.height * 0.8)
157+
value = 'X' if value == 'on' else ''
158+
can.drawString(width * item.posX, height * (1 - item.posY - item.height * 0.9), value)
159+
elif item.type_id.item_type == "radio":
160+
x = width * item.posX
161+
y = height * (1 - item.posY)
162+
w = item.width * width
163+
h = item.height * height
164+
c_x = x + w * 0.5
165+
c_y = y - h * 0.5
166+
can.circle(c_x, c_y, h * 0.5)
167+
if value == "on":
168+
can.circle(x_cen=c_x, y_cen=c_y, r=h * 0.5 * 0.75, fill=1)
169+
elif item.type_id.item_type in ["signature", "initial", "stamp"]:
170+
try:
171+
image_reader = ImageReader(io.BytesIO(base64.b64decode(value[value.find(',') + 1:])))
172+
except UnidentifiedImageError:
173+
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
174+
_fix_image_transparency(image_reader._image)
175+
can.drawImage(image_reader, width * item.posX, height * (1 - item.posY - item.height), width * item.width, height * item.height, 'auto', True)
176+
177+
can.showPage()
178+
179+
can.save()
180+
181+
item_pdf = PdfFileReader(packet, overwriteWarnings=False)
182+
new_pdf = PdfFileWriter()
183+
184+
for p in range(0, old_pdf.getNumPages()):
185+
page = old_pdf.getPage(p)
186+
page.mergePage(item_pdf.getPage(p))
187+
new_pdf.addPage(page)
188+
189+
if isEncrypted:
190+
new_pdf.encrypt(password)
191+
192+
try:
193+
output = io.BytesIO()
194+
new_pdf.write(output)
195+
except PdfReadError:
196+
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
197+
198+
self.completed_document = base64.b64encode(output.getvalue())
199+
output.close()
200+
201+
attachment = self.env['ir.attachment'].create({
202+
'name': "%s.pdf" % self.reference if self.reference.split('.')[-1] != 'pdf' else self.reference,
203+
'datas': self.completed_document,
204+
'type': 'binary',
205+
'res_model': self._name,
206+
'res_id': self.id,
207+
})
208+
209+
public_user = self.env.ref('base.public_user', raise_if_not_found=False)
210+
if not public_user:
211+
public_user = self.env.user
212+
pdf_content, __ = self.env["ir.actions.report"].with_user(public_user).sudo()._render_qweb_pdf(
213+
'sign.action_sign_request_print_logs',
214+
self.ids,
215+
data={'format_date': format_date, 'company_id': self.communication_company_id}
216+
)
217+
attachment_log = self.env['ir.attachment'].create({
218+
'name': "Certificate of completion - %s.pdf" % time.strftime('%Y-%m-%d - %H:%M:%S'),
219+
'raw': pdf_content,
220+
'type': 'binary',
221+
'res_model': self._name,
222+
'res_id': self.id,
223+
})
224+
self.completed_document_attachment_ids = [Command.set([attachment.id, attachment_log.id])]
225+
226+
227+
class SignRequestItem(models.Model):
228+
_inherit = "sign.request.item"
229+
230+
def _get_user_stamp(self):
231+
self.ensure_one()
232+
sign_user = self.partner_id.user_ids[:1]
233+
if sign_user:
234+
return sign_user['sign_stamp']
235+
return False
236+
237+
def _get_user_stamp_frame(self):
238+
self.ensure_one()
239+
sign_user = self.partner_id.user_ids[:1]
240+
if sign_user:
241+
return sign_user['sign_stamp_frame']
242+
return False

sign_stamp/models/sign_template.py

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,10 @@
1+
from odoo import models, fields
2+
3+
4+
class SignItemType(models.Model):
5+
_inherit = "sign.item.type"
6+
7+
item_type = fields.Selection(
8+
selection_add=[('stamp', "Stamp")],
9+
ondelete={'stamp': 'cascade'}
10+
)

0 commit comments

Comments
 (0)