Skip to content

Commit c828a2f

Browse files
committed
[ADD] stamp_sign: add company stamp field
This commit introduces the stamp_sign module, which adds a new Stamp field type to the core Sign application. before: The Sign application only supported standard text signatures and initials. There was no built-in way to apply a company-style stamp with structured information like an address and logo. after: A new Stamp type is available in the Sign editor. Users can drag a stamp field onto the document. During the signing process, clicking the field opens a dialog to input company details (name, address, VAT, logo). This information is rendered into a stamp image and applied to the document. The final signed PDF includes stamp. impact: This extends the sign module with a new stamp type, improving its utility for official business documents that require a formal company stamp.
1 parent fbf9ee9 commit c828a2f

18 files changed

+860
-0
lines changed

awesome_gallery/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
'version': '0.1',
1313
'application': True,
1414
'category': 'Tutorials/AwesomeGallery',
15+
'author': 'arkp',
1516
'installable': True,
1617
'depends': ['web', 'contacts'],
1718
'data': [

awesome_kanban/__manifest__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@
1212
'version': '0.1',
1313
'application': True,
1414
'category': 'Tutorials/AwesomeKanban',
15+
'author': 'arkp',
1516
'installable': True,
1617
'depends': ['web', 'crm'],
1718
'data': [

stamp_sign/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import models
4+
from . import controllers

stamp_sign/__manifest__.py

Lines changed: 26 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,26 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
{
4+
"name": "Stamp Sign",
5+
"version": "1.0",
6+
"depends": ["sign"],
7+
"category": "Sign",
8+
"author": "arkp",
9+
"data": [
10+
"data/sign_data.xml",
11+
"views/sign_request_templates.xml",
12+
],
13+
"assets": {
14+
"web.assets_backend": [
15+
"stamp_sign/static/src/components/sign_request/*",
16+
"stamp_sign/static/src/dialogs/*",
17+
],
18+
"sign.assets_public_sign": [
19+
"stamp_sign/static/src/components/sign_request/*",
20+
"stamp_sign/static/src/dialogs/*",
21+
],
22+
},
23+
"installable": True,
24+
"application": True,
25+
"license": "LGPL-3",
26+
}

stamp_sign/controllers/__init__.py

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,3 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import main

stamp_sign/controllers/main.py

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import http
4+
from odoo.addons.sign.controllers.main import Sign # type: ignore
5+
6+
7+
class Sign(Sign):
8+
def get_document_qweb_context(self, sign_request_id, token, **post):
9+
data = super().get_document_qweb_context(sign_request_id, token, **post)
10+
company_logo = http.request.env.user.company_id.logo
11+
if company_logo:
12+
data["logo"] = "data:image/png;base64,%s" % company_logo.decode()
13+
else:
14+
data["logo"] = False
15+
16+
return data
17+
18+
@http.route(["/sign/update_user_signature"], type="json", auth="user")
19+
def update_signature(
20+
self, sign_request_id, role, signature_type=None, datas=None, frame_datas=None
21+
):
22+
user = http.request.env.user
23+
if not user or signature_type not in [
24+
"sign_signature",
25+
"sign_initials",
26+
]:
27+
return False
28+
29+
sign_request_item_sudo = (
30+
http.request.env["sign.request.item"]
31+
.sudo()
32+
.search(
33+
[("sign_request_id", "=", sign_request_id), ("role_id", "=", role)],
34+
limit=1,
35+
)
36+
)
37+
38+
allowed = sign_request_item_sudo.partner_id.id == user.partner_id.id
39+
if not allowed:
40+
return False
41+
if datas:
42+
user[signature_type] = datas[datas.find(",") + 1 :]
43+
else:
44+
user[signature_type] = False
45+
46+
if frame_datas:
47+
user[signature_type + "_frame"] = frame_datas[frame_datas.find(",") + 1 :]
48+
else:
49+
user[signature_type + "_frame"] = False
50+
51+
return True

stamp_sign/data/sign_data.xml

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,12 @@
1+
<?xml version="1.0" encoding="UTF-8"?>
2+
<odoo>
3+
<record model="sign.item.type" id="stamp_item_type">
4+
<field name="name">Stamp</field>
5+
<field name="item_type">stamp</field>
6+
<field name="tip">stamp</field>
7+
<field name="placeholder">Stamp</field>
8+
<field name="default_width" type="float">0.300</field>
9+
<field name="default_height" type="float">0.10</field>
10+
<field name="icon">fa-legal</field>
11+
</record>
12+
</odoo>

stamp_sign/models/__init__.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,4 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from . import sign_template
4+
from . import sign_request

stamp_sign/models/sign_request.py

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

stamp_sign/models/sign_template.py

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,9 @@
1+
# Part of Odoo. See LICENSE file for full copyright and licensing details.
2+
3+
from odoo import fields, models
4+
5+
6+
class SignItemType(models.Model):
7+
_inherit = "sign.item.type"
8+
9+
item_type = fields.Selection(selection_add=[("stamp", "Stamp")], ondelete={"stamp": "set default"})

0 commit comments

Comments
 (0)