Skip to content

Commit d3ecb20

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 d3ecb20

17 files changed

+913
-0
lines changed

stamp_sign/__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+
from . import models
3+
from . import controllers

stamp_sign/__manifest__.py

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

stamp_sign/controllers/__init__.py

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

stamp_sign/controllers/main.py

Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
from odoo import http
2+
from odoo.addons.sign.controllers.main import Sign # type: ignore
3+
4+
5+
class Sign(Sign):
6+
def get_document_qweb_context(self, sign_request_id, token, **post):
7+
data = super().get_document_qweb_context(sign_request_id, token, **post)
8+
current_request_item = data["current_request_item"]
9+
sign_item_types = data["sign_item_types"]
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+
if current_request_item:
17+
user_stamp = current_request_item._get_user_signature_asset("stamp_sign_stamp")
18+
user_stamp_frame = current_request_item._get_user_signature_asset("stamp_sign_stamp_frame")
19+
20+
encoded_user_stamp = (
21+
"data:image/png;base64,%s" % user_stamp.decode()
22+
if user_stamp
23+
else False
24+
)
25+
encoded_user_stamp_frame = (
26+
"data:image/png;base64,%s" % user_stamp_frame.decode()
27+
if user_stamp_frame
28+
else False
29+
)
30+
stamp_item_type = next(
31+
(
32+
item_type
33+
for item_type in sign_item_types
34+
if item_type["item_type"] == "stamp"
35+
),
36+
None,
37+
)
38+
if stamp_item_type:
39+
stamp_item_type["auto_value"] = encoded_user_stamp
40+
stamp_item_type["frame_value"] = encoded_user_stamp_frame
41+
42+
return data
43+
44+
@http.route(["/sign/update_user_signature"], type="json", auth="user")
45+
def update_signature(
46+
self, sign_request_id, role, signature_type=None, datas=None, frame_datas=None
47+
):
48+
user = http.request.env.user
49+
if not user or signature_type not in [
50+
"sign_signature",
51+
"sign_initials",
52+
"stamp_sign_stamp",
53+
]:
54+
return False
55+
56+
sign_request_item_sudo = (
57+
http.request.env["sign.request.item"]
58+
.sudo()
59+
.search(
60+
[("sign_request_id", "=", sign_request_id), ("role_id", "=", role)],
61+
limit=1,
62+
)
63+
)
64+
65+
allowed = sign_request_item_sudo.partner_id.id == user.partner_id.id
66+
if not allowed:
67+
return False
68+
user[signature_type] = datas[datas.find(",") + 1 :]
69+
if frame_datas:
70+
user[signature_type + "_frame"] = frame_datas[frame_datas.find(",") + 1 :]
71+
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+
from . import sign_template
3+
from . import res_users
4+
from . import sign_request

stamp_sign/models/res_users.py

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
from odoo import models, fields
2+
3+
SIGN_USER_FIELDS = ["stamp_sign_stamp","stamp_sign_stamp_frame"]
4+
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+
stamp_sign_stamp = fields.Binary(
18+
string="Company Stamp", copy=False, groups="base.group_user"
19+
)
20+
stamp_sign_stamp_frame = fields.Binary(
21+
string="Company Stamp Frame", copy=False, groups="base.group_user"
22+
)

stamp_sign/models/sign_request.py

Lines changed: 249 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,249 @@
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+
9+
from odoo import _, models, Command
10+
from odoo.tools import format_date
11+
from odoo.exceptions import UserError, ValidationError
12+
from odoo.tools.pdf import PdfFileReader, PdfFileWriter
13+
14+
try:
15+
from PyPDF2.errors import PdfReadError # type: ignore
16+
except ImportError:
17+
from PyPDF2.utils import PdfReadError
18+
19+
20+
def _fix_image_transparency(image):
21+
pixels = image.load()
22+
for x in range(image.size[0]):
23+
for y in range(image.size[1]):
24+
if pixels[x, y] == (0, 0, 0, 0):
25+
pixels[x, y] = (255, 255, 255, 0)
26+
27+
28+
class SignRequest(models.Model):
29+
_inherit = "sign.request"
30+
31+
def _generate_completed_document(self, password=""):
32+
self.ensure_one()
33+
self._validate_document_state()
34+
35+
if not self.template_id.sign_item_ids:
36+
self._copy_template_to_completed_document()
37+
else:
38+
old_pdf = self._load_template_pdf(password)
39+
new_pdf_data = self._create_signed_overlay(old_pdf)
40+
self._merge_pdfs_and_store(old_pdf, new_pdf_data, password)
41+
42+
attachment = self._create_attachment_from_completed_doc()
43+
log_attachment = self._create_completion_certificate()
44+
self._attach_completed_documents(attachment, log_attachment)
45+
46+
def _validate_document_state(self):
47+
if self.state != "signed":
48+
raise UserError(
49+
_(
50+
"The completed document cannot be created because the sign request is not fully signed"
51+
)
52+
)
53+
54+
def _copy_template_to_completed_document(self):
55+
self.completed_document = self.template_id.attachment_id.datas
56+
57+
def _load_template_pdf(self, password):
58+
try:
59+
pdf_reader = PdfFileReader(
60+
io.BytesIO(base64.b64decode(self.template_id.attachment_id.datas)),
61+
strict=False,
62+
overwriteWarnings=False,
63+
)
64+
pdf_reader.getNumPages()
65+
except PdfReadError:
66+
raise ValidationError(_("ERROR: Invalid PDF file!"))
67+
68+
if pdf_reader.isEncrypted and not pdf_reader.decrypt(password):
69+
return
70+
71+
return pdf_reader
72+
73+
def _create_signed_overlay(self, old_pdf):
74+
font = self._get_font()
75+
normalFontSize = self._get_normal_font_size()
76+
packet = io.BytesIO()
77+
can = canvas.Canvas(packet, pagesize=self.get_page_size(old_pdf))
78+
items_by_page, values = self._collect_items_and_values()
79+
80+
for p in range(0, old_pdf.getNumPages()):
81+
page = old_pdf.getPage(p)
82+
width, height = self._get_page_dimensions(page)
83+
self._apply_page_rotation(can, page, width, height)
84+
85+
for item in items_by_page.get(p + 1, []):
86+
self._draw_item(
87+
can, item, values.get(item.id), width, height, font, normalFontSize
88+
)
89+
can.showPage()
90+
91+
can.save()
92+
return PdfFileReader(packet, overwriteWarnings=False)
93+
94+
def _collect_items_and_values(self):
95+
items_by_page = self.template_id._get_sign_items_by_page()
96+
item_ids = [id for items in items_by_page.values() for id in items.ids]
97+
values_dict = self.env["sign.request.item.value"]._read_group(
98+
[("sign_item_id", "in", item_ids), ("sign_request_id", "=", self.id)],
99+
groupby=["sign_item_id"],
100+
aggregates=[
101+
"value:array_agg",
102+
"frame_value:array_agg",
103+
"frame_has_hash:array_agg",
104+
],
105+
)
106+
values = {
107+
item: {"value": vals[0], "frame": frames[0], "frame_has_hash": hashes[0]}
108+
for item, vals, frames, hashes in values_dict
109+
}
110+
return items_by_page, values
111+
112+
def _get_page_dimensions(self, page):
113+
width = float(abs(page.mediaBox.getWidth()))
114+
height = float(abs(page.mediaBox.getHeight()))
115+
return width, height
116+
117+
def _apply_page_rotation(self, can, page, width, height):
118+
rotation = page.get("/Rotate", 0)
119+
if isinstance(rotation, int):
120+
can.rotate(rotation)
121+
if rotation == 90:
122+
width, height = height, width
123+
can.translate(0, -height)
124+
elif rotation == 180:
125+
can.translate(-width, -height)
126+
elif rotation == 270:
127+
width, height = height, width
128+
can.translate(-width, 0)
129+
130+
def _draw_item(self, can, item, value_dict, width, height, font, normalFontSize):
131+
if not value_dict:
132+
return
133+
134+
value, frame = value_dict["value"], value_dict["frame"]
135+
if frame:
136+
self._draw_image(can, frame, item, width, height)
137+
138+
draw_method = getattr(self, f"_draw_{item.type_id.item_type}", None)
139+
if draw_method:
140+
draw_method(can, item, value, width, height, font, normalFontSize)
141+
142+
def _draw_image(self, can, frame_data, item, width, height):
143+
try:
144+
image_reader = ImageReader(
145+
io.BytesIO(base64.b64decode(frame_data.split(",")[1]))
146+
)
147+
except UnidentifiedImageError:
148+
raise ValidationError(
149+
_(
150+
"There was an issue downloading your document. Please contact an administrator."
151+
)
152+
)
153+
154+
_fix_image_transparency(image_reader._image)
155+
can.drawImage(
156+
image_reader,
157+
width * item.posX,
158+
height * (1 - item.posY - item.height),
159+
width * item.width,
160+
height * item.height,
161+
"auto",
162+
True,
163+
)
164+
165+
def _draw_signature(self, can, item, value, width, height, *_):
166+
self._draw_image(can, value, item, width, height)
167+
168+
_draw_initial = _draw_signature
169+
_draw_stamp = _draw_signature
170+
171+
def _merge_pdfs_and_store(self, old_pdf, overlay_pdf, password):
172+
new_pdf = PdfFileWriter()
173+
for i in range(old_pdf.getNumPages()):
174+
page = old_pdf.getPage(i)
175+
page.mergePage(overlay_pdf.getPage(i))
176+
new_pdf.addPage(page)
177+
if old_pdf.isEncrypted:
178+
new_pdf.encrypt(password)
179+
180+
output = io.BytesIO()
181+
try:
182+
new_pdf.write(output)
183+
except PdfReadError:
184+
raise ValidationError(
185+
_(
186+
"There was an issue downloading your document. Please contact an administrator."
187+
)
188+
)
189+
self.completed_document = base64.b64encode(output.getvalue())
190+
output.close()
191+
192+
def _create_attachment_from_completed_doc(self):
193+
filename = (
194+
self.reference
195+
if self.reference.endswith(".pdf")
196+
else f"{self.reference}.pdf"
197+
)
198+
return self.env["ir.attachment"].create(
199+
{
200+
"name": filename,
201+
"datas": self.completed_document,
202+
"type": "binary",
203+
"res_model": self._name,
204+
"res_id": self.id,
205+
}
206+
)
207+
208+
def _create_completion_certificate(self):
209+
public_user = (
210+
self.env.ref("base.public_user", raise_if_not_found=False) or self.env.user
211+
)
212+
pdf_content, _ = (
213+
self.env["ir.actions.report"]
214+
.with_user(public_user)
215+
.sudo()
216+
._render_qweb_pdf(
217+
"sign.action_sign_request_print_logs",
218+
self.ids,
219+
data={
220+
"format_date": format_date,
221+
"company_id": self.communication_company_id,
222+
},
223+
)
224+
)
225+
return self.env["ir.attachment"].create(
226+
{
227+
"name": f"Certificate of completion - {time.strftime('%Y-%m-%d - %H:%M:%S')}.pdf",
228+
"raw": pdf_content,
229+
"type": "binary",
230+
"res_model": self._name,
231+
"res_id": self.id,
232+
}
233+
)
234+
235+
def _attach_completed_documents(self, doc_attachment, log_attachment):
236+
self.completed_document_attachment_ids = [
237+
Command.set([doc_attachment.id, log_attachment.id])
238+
]
239+
240+
241+
class SignRequestItem(models.Model):
242+
_inherit = "sign.request.item"
243+
244+
def _get_user_signature_asset(self, asset_type):
245+
self.ensure_one()
246+
sign_user = self.partner_id.user_ids[:1]
247+
if sign_user and asset_type in ["stamp_sign_stamp", "stamp_sign_stamp_frame"]:
248+
return sign_user[asset_type]
249+
return False

0 commit comments

Comments
 (0)