first commit

This commit is contained in:
admin.suherdy 2025-12-17 14:32:07 +07:00
commit 923ac57db6
17 changed files with 637 additions and 0 deletions

2
__init__.py Normal file
View File

@ -0,0 +1,2 @@
# -*- coding: utf-8 -*-
from . import models

35
__manifest__.py Normal file
View File

@ -0,0 +1,35 @@
# -*- coding: utf-8 -*-
{
'name': "Sign Sequence Field",
'summary': "Add a Sequence field type to Sign templates to auto-generate numbers",
'description': """
This module extends the Odoo Sign app to add a new field type 'Sequence'.
When dropped onto a template, it allows linking to an ir.sequence.
Upon document completion, the sequence number is generated and stamped on the document.
User can edit the sequence format directly from the field properties.
""",
'author': "Mapan",
'category': 'Sign',
'version': '0.1',
'depends': ['sign'],
'data': [
'data/sign_item_type_data.xml',
'views/sign_item_views.xml',
],
'assets': {
'web.assets_frontend': [
'sign_sequence_field/static/src/xml/sign_item_sequence.xml',
],
'web.assets_backend': [
'sign_sequence_field/static/src/xml/sign_item_sequence.xml',
'sign_sequence_field/static/src/js/sign_item_custom_popover_patch.js',
'sign_sequence_field/static/src/js/sign_backend_patch.js',
'sign_sequence_field/static/src/xml/sign_item_custom_popover_patch.xml',
],
'sign.assets_public_sign': [
'sign_sequence_field/static/src/xml/sign_item_sequence.xml',
]
},
'demo': [],
'license': 'LGPL-3',
}

Binary file not shown.

View File

@ -0,0 +1,12 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sign_item_type_sequence" model="sign.item.type">
<field name="name">Sequence</field>
<field name="item_type">sequence</field>
<field name="icon">fa-sort-numeric-asc</field>
<field name="tip">Auto-generated sequence number</field>
<field name="placeholder">Sequence Number</field>
<field name="default_width">0.2</field>
<field name="default_height">0.05</field>
</record>
</odoo>

4
models/__init__.py Normal file
View File

@ -0,0 +1,4 @@
# -*- coding: utf-8 -*-
from . import sign_item_type
from . import sign_item
from . import sign_request

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

33
models/sign_item.py Normal file
View File

@ -0,0 +1,33 @@
# -*- coding: utf-8 -*-
from odoo import api, fields, models, _
from odoo.exceptions import UserError
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_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")
@api.onchange('type_id')
def _onchange_type_id_sequence(self):
if self.type_id.item_type == 'sequence' and not self.sequence_id:
# Optional: auto-create or encourage selection?
# For now, just leave empty for user to select.
pass
def action_create_sequence(self):
""" Helper to create a new sequence for this item if needed """
self.ensure_one()
if not self.sequence_id:
seq = self.env['ir.sequence'].create({
'name': f"Sign Sequence - {self.template_id.name}",
'code': f"sign.item.{self.id}",
'prefix': 'SEQ-',
'padding': 4,
})
self.sequence_id = seq

7
models/sign_item_type.py Normal file
View File

@ -0,0 +1,7 @@
# -*- coding: utf-8 -*-
from odoo import fields, models
class SignItemType(models.Model):
_inherit = "sign.item.type"
item_type = fields.Selection(selection_add=[('sequence', "Sequence")], ondelete={'sequence': 'cascade'})

274
models/sign_request.py Normal file
View File

@ -0,0 +1,274 @@
# -*- coding: utf-8 -*-
import base64
import io
import os
import time
from dateutil.relativedelta import relativedelta
from datetime import timedelta
from reportlab.lib.utils import ImageReader
from reportlab.pdfbase import pdfmetrics
from reportlab.pdfbase.ttfonts import TTFont
from reportlab.rl_config import TTFSearchPath
from reportlab.pdfgen import canvas
from reportlab.platypus import Paragraph
from reportlab.lib.styles import ParagraphStyle
from reportlab.pdfbase.pdfmetrics import stringWidth
from PIL import UnidentifiedImageError
from odoo import api, fields, models, _, Command
from odoo.exceptions import UserError, ValidationError
from odoo.tools.pdf import PdfFileReader, PdfFileWriter, PdfReadError, reshape_text
# Helper function copied from sign/models/sign_request.py
def _fix_image_transparency(image):
pixels = image.load()
for x in range(image.size[0]):
for y in range(image.size[1]):
if pixels[x, y] == (0, 0, 0, 0):
pixels[x, y] = (255, 255, 255, 0)
class SignRequest(models.Model):
_inherit = "sign.request"
def _sign(self):
""" Override to generate sequence numbers when request is signed """
# We perform sequence generation BEFORE calling super()._sign()
# because super()._sign() triggers _send_completed_document() immediately.
# We need the sequence values to be ready before the document is generated and sent.
# We only generate if we are about to complete the signing process.
# The logic in _post_fill_request_item calls _sign only when all items are completed.
for request in self:
# Generate sequence numbers for sequence items
for sign_item in request.template_id.sign_item_ids:
if sign_item.type_id.item_type == 'sequence' and sign_item.sequence_id:
# Find the responsible request item (signer)
# Use role_id to match.
# We check if there is a signer for this role in this request.
request_items = request.request_item_ids.filtered(lambda r: r.role_id == sign_item.responsible_id)
if not request_items:
continue
# We only generate if it hasn't been generated yet.
existing_value = request.env['sign.request.item.value'].search([
('sign_request_item_id', 'in', request_items.ids),
('sign_item_id', '=', sign_item.id)
], limit=1)
# If it exists but value is empty/false OR it equals the placeholder "Sequence Number"
# we must regenerate it.
if not existing_value or not existing_value.value or existing_value.value == "Sequence Number":
new_seq = sign_item.sequence_id.next_by_id()
if new_seq:
if existing_value:
# Update existing empty record
existing_value.write({'value': new_seq})
else:
# Create value for the first matching signer
request.env['sign.request.item.value'].create({
'sign_request_item_id': request_items[0].id,
'sign_item_id': sign_item.id,
'value': new_seq
})
# Rename the document if requested
# User asked to put sequence in prefix of document name.
# request.reference is the document name.
# Check if already renamed to avoid double prefixing
if not request.reference.startswith(f"{new_seq} - "):
request.write({'reference': f"{new_seq} - {request.reference}"})
return super(SignRequest, self)._sign()
def _generate_completed_document(self, password=""):
# We need to override this entire method to handle 'sequence' item type
# as the original method doesn't have hooks or generic handling.
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:
self.completed_document = self.template_id.attachment_id.datas
else:
try:
old_pdf = PdfFileReader(io.BytesIO(base64.b64decode(self.template_id.attachment_id.datas)), strict=False, overwriteWarnings=False)
old_pdf.getNumPages()
except:
raise ValidationError(_("ERROR: Invalid PDF file!"))
isEncrypted = old_pdf.isEncrypted
if isEncrypted and not old_pdf.decrypt(password):
# password is not correct
return
font = self._get_font()
normalFontSize = self._get_normal_font_size()
packet = io.BytesIO()
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]
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'],
aggregates=['value:array_agg', 'frame_value:array_agg', 'frame_has_hash:array_agg']
)
values = {
sign_item.id : {
'value': values[0],
'frame': frame_values[0],
'frame_has_hash': frame_has_hashes[0],
}
for sign_item, values, frame_values, frame_has_hashes in values_dict
}
for p in range(0, old_pdf.getNumPages()):
page = old_pdf.getPage(p)
# Absolute values are taken as it depends on the MediaBox template PDF metadata, they may be negative
width = float(abs(page.mediaBox.getWidth()))
height = float(abs(page.mediaBox.getHeight()))
# Set page orientation (either 0, 90, 180 or 270)
rotation = page['/Rotate'] if '/Rotate' in page else 0
if rotation and isinstance(rotation, int):
can.rotate(rotation)
# Translate system so that elements are placed correctly
# despite of the orientation
if rotation == 90:
width, height = height, width
can.translate(0, -height)
elif rotation == 180:
can.translate(-width, -height)
elif rotation == 270:
width, height = height, width
can.translate(-width, 0)
items = itemsByPage[p + 1] if p + 1 in itemsByPage else []
for item in items:
value_dict = values.get(item.id)
if not value_dict:
continue
# only get the 1st
value = value_dict['value']
frame = value_dict['frame']
if frame:
try:
image_reader = ImageReader(io.BytesIO(base64.b64decode(frame[frame.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
)
# LOGIC FOR SEQUENCE TYPE (Same as text)
if item.type_id.item_type == "sequence":
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 == "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)
elif item.type_id.item_type == "selection":
content = []
for option in item.option_ids:
if option.id != int(value):
content.append("<strike>%s</strike>" % (option.value))
else:
content.append(option.value)
font_size = height * normalFontSize * 0.8
text = " / ".join(content)
string_width = stringWidth(text.replace("<strike>", "").replace("</strike>", ""), 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
elif item.type_id.item_type == "checkbox":
can.setFont(font, height*item.height*0.8)
value = 'X' if value == 'on' else ''
can.drawString(width*item.posX, height*(1-item.posY-item.height*0.9), value)
elif item.type_id.item_type == "radio":
x = width * item.posX
y = height * (1 - item.posY)
w = item.width * width
h = item.height * height
# Calculate the center of the sign item rectangle.
c_x = x + w * 0.5
c_y = y - h * 0.5
# Draw the outer empty circle.
can.circle(c_x, c_y, h * 0.5)
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)
can.showPage()
can.save()
item_pdf = PdfFileReader(packet, overwriteWarnings=False)
new_pdf = PdfFileWriter()
for p in range(0, old_pdf.getNumPages()):
page = old_pdf.getPage(p)
page.mergePage(item_pdf.getPage(p))
new_pdf.addPage(page)
if isEncrypted:
new_pdf.encrypt(password)
try:
output = io.BytesIO()
new_pdf.write(output)
except PdfReadError:
raise ValidationError(_("There was an issue downloading your document. Please contact an administrator."))
self.completed_document = base64.b64encode(output.getvalue())
output.close()

View File

@ -0,0 +1,105 @@
/** @odoo-module **/
import { SignTemplateIframe } from "@sign/backend_components/sign_template/sign_template_iframe";
import { SignTemplateBody } from "@sign/backend_components/sign_template/sign_template_body";
// IMPORTANT: We must import SignItemCustomPopover to pass it to this.popover.add
import { SignItemCustomPopover } from "@sign/backend_components/sign_template/sign_item_custom_popover";
import { patch } from "@web/core/utils/patch";
patch(SignTemplateIframe.prototype, {
async openSignItemPopup(signItem) {
const shouldOpenNewPopover = !(signItem.data.id in this.closePopoverFns);
this.closePopover();
if (shouldOpenNewPopover) {
if (signItem.data.id in this.negativeIds) {
await this.negativeIds[signItem.data.id];
}
const header_title = signItem.data.type === "radio" ? "Radio Button" : signItem.data.type_id?.[1] || signItem.data.name;
const closeFn = this.popover.add(
signItem.el,
SignItemCustomPopover,
{
debug: this.env.debug,
responsible: signItem.data.responsible,
roles: this.signRolesById,
alignment: signItem.data.alignment,
required: signItem.data.required,
header_title: header_title,
placeholder: signItem.data.placeholder,
id: signItem.data.id,
type: signItem.data.type,
option_ids: signItem.data.option_ids,
num_options: this.getSignItemById(signItem.data.id).data.num_options,
radio_set_id: signItem.data.radio_set_id,
// PATCH START
sequence_id: signItem.data.sequence_id,
sequence_prefix: signItem.data.sequence_prefix,
sequence_padding: signItem.data.sequence_padding,
sequence_number_next: signItem.data.sequence_number_next,
// PATCH END
onValidate: (data) => {
this.updateSignItem(signItem, data);
this.closePopover();
},
onDelete: () => {
this.closePopover();
this.deleteSignItem(signItem);
},
onClose: () => {
this.closePopover();
},
updateSelectionOptions: (ids) => this.updateSelectionOptions(ids),
updateRoles: (id) => this.updateRoles(id),
},
{
position: "right",
onClose: () => {
this.closePopoverFns = {};
},
closeOnClickAway: (target) => !target.closest(".modal"),
popoverClass: "sign-popover",
}
);
this.closePopoverFns[signItem.data.id] = {
close: closeFn,
signItem,
};
}
},
updateSignItem(signItem, data) {
// Ensure sequence fields are not filtered out if they were missing from initial load
const sequenceFields = ['sequence_id', 'sequence_prefix', 'sequence_padding', 'sequence_number_next'];
for (const field of sequenceFields) {
if (field in data && !(field in signItem.data)) {
signItem.data[field] = false; // Initialize with dummy value to pass the check
}
}
return super.updateSignItem(signItem, data);
}
});
patch(SignTemplateBody.prototype, {
prepareTemplateData() {
const [updatedSignItems, Id2UpdatedItem] = super.prepareTemplateData();
const items = this.iframe?.signItems ?? {};
for (const page in items) {
for (const id in items[page]) {
const signItem = items[page][id].data;
// updatedSignItems is keyed by ID. Check if this item is in the updated list.
if (updatedSignItems[id]) {
if (signItem.sequence_id) {
// specific handling for Many2one: take ID if it's an array
updatedSignItems[id].sequence_id = Array.isArray(signItem.sequence_id) ? signItem.sequence_id[0] : signItem.sequence_id;
}
// For related fields, we want to save them if they changed.
if (signItem.sequence_prefix !== undefined) updatedSignItems[id].sequence_prefix = signItem.sequence_prefix;
if (signItem.sequence_padding !== undefined) updatedSignItems[id].sequence_padding = signItem.sequence_padding;
if (signItem.sequence_number_next !== undefined) updatedSignItems[id].sequence_number_next = signItem.sequence_number_next;
}
}
}
return [updatedSignItems, Id2UpdatedItem];
}
});

View File

@ -0,0 +1,61 @@
/** @odoo-module **/
import { SignItemCustomPopover } from "@sign/backend_components/sign_template/sign_item_custom_popover";
import { patch } from "@web/core/utils/patch";
patch(SignItemCustomPopover.prototype, {
setup() {
super.setup();
// Add sequence fields to the loaded fields
this.signItemFieldsGet.sequence_id = { type: "many2one", relation: "ir.sequence", string: "Sequence" };
this.signItemFieldsGet.sequence_prefix = { type: "char", string: "Prefix" };
this.signItemFieldsGet.sequence_padding = { type: "integer", string: "Padding" };
this.signItemFieldsGet.sequence_number_next = { type: "integer", string: "Next Number" };
// Initialize state from props
this.state.sequence_id = this.props.sequence_id;
this.state.sequence_prefix = this.props.sequence_prefix;
this.state.sequence_padding = this.props.sequence_padding;
this.state.sequence_number_next = this.props.sequence_number_next;
},
get recordProps() {
// We need to intercept onRecordChanged to sync changes to this.state
const originalRecordProps = super.recordProps;
const originalOnRecordChanged = originalRecordProps.onRecordChanged;
return {
...originalRecordProps,
onRecordChanged: async (record, changes) => {
if (originalOnRecordChanged) {
await originalOnRecordChanged(record, changes);
}
// Sync sequence fields to state if changed
if ("sequence_id" in changes) {
this.state.sequence_id = changes.sequence_id;
// When sequence changes, we might want to reload prefix/padding/etc if they are computed?
// They are related fields, so 'record' should have them updated.
// But 'changes' might only contain the changed field.
// We should check the record data for the others.
// Actually, if they are related fields, the `Record` model updates them.
// But `changes` object passed here contains changed values.
}
if ("sequence_prefix" in changes) this.state.sequence_prefix = changes.sequence_prefix;
if ("sequence_padding" in changes) this.state.sequence_padding = changes.sequence_padding;
if ("sequence_number_next" in changes) this.state.sequence_number_next = changes.sequence_number_next;
}
};
}
});
import { CharField } from "@web/views/fields/char/char_field";
import { IntegerField } from "@web/views/fields/integer/integer_field";
// Patch static components
const originalComponents = SignItemCustomPopover.components;
SignItemCustomPopover.components = {
...originalComponents,
CharField,
IntegerField,
};

View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sign.SignItemCustomPopover.sequence" t-inherit="sign.SignItemCustomPopover" t-inherit-mode="extension">
<xpath expr="//div[hasclass('d-flex', 'p-2', 'flex-column')]/div[1]" position="before">
<!-- Insert inside the main column, before the placeholder input -->
<div t-if="props.type === 'sequence'" class="mb-3 border-bottom pb-3">
<label class="fw-bold mb-2">Sequence Settings</label>
<!-- We need to access the record from the scope, but the scope is inside <Record> component -->
<!-- The Record component is below this div in original template? -->
<!-- Wait, the original template has <div t-if="props.debug"> then <Record> -->
<!-- I should insert INSIDE the <Record> component to access the `record` variable -->
</div>
</xpath>
<xpath expr="//div[@id='o_sign_responsible_select_input']/.." position="after">
<t t-if="props.type === 'sequence'">
<div class="mb-2">
<label class="fw-bold">Sequence</label>
<div class="o_field_widget d-block">
<Many2OneField t-props="getMany2XProps(record, 'sequence_id')" update="(value) => record.update(value)"/>
</div>
</div>
<!-- Only show details if sequence is selected -->
<!-- Note: record.data.sequence_id is an array [id, display_name] usually in M2O -->
<t t-if="record.data.sequence_id">
<div class="mb-2">
<label class="fw-bold">Prefix</label>
<div class="o_field_widget d-block">
<CharField name="'sequence_prefix'" record="record" readonly="false"/>
</div>
</div>
<div class="mb-2">
<label class="fw-bold">Next Number</label>
<div class="o_field_widget d-block">
<IntegerField name="'sequence_number_next'" record="record" readonly="false"/>
</div>
</div>
<div class="mb-2">
<label class="fw-bold">Padding</label>
<div class="o_field_widget d-block">
<IntegerField name="'sequence_padding'" record="record" readonly="false"/>
</div>
</div>
</t>
</t>
</xpath>
</t>
</templates>

View File

@ -0,0 +1,38 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="sign.signItem" t-inherit="sign.signItem" t-inherit-mode="extension">
<xpath expr="//t[@t-if=&quot;type == 'selection'&quot;]" position="after">
<t t-if="type == 'sequence'" t-call="sign.sequenceSignItem"/>
</xpath>
<!-- This second xpath seems to be for the editor drag-drop preview or specific state -->
<xpath expr="//div[@t-if=&quot;type == 'selection'&quot;]" position="after">
<div t-if="type == 'sequence'" t-att-title="role" t-attf-class="{{classes}} o_sign_sign_item" t-att-style="style" t-att-data-value="value" style="text-align:center; display:flex; align-items:center; justify-content:center;">
<t t-if="value">
<span t-esc="value" style="font-family:monospace;"/>
</t>
<t t-else="">
<span class="o_placeholder">
<t t-esc="placeholder"/>
</span>
</t>
<t t-if="isSignItemEditable" t-call="sign.signItemConfiguration"/>
</div>
</xpath>
</t>
<t t-name="sign.sequenceSignItem">
<div t-att-title="role" t-attf-class="{{classes}} o_sign_sign_item" t-att-data-id="id" t-att-style="style" style="text-align:center; display:flex; align-items:center; justify-content:center;">
<div class="sign_item_body">
<t t-if="value">
<span t-esc="value" style="font-family:monospace;"/>
</t>
<t t-else="">
<span class="o_placeholder">
<t t-esc="placeholder"/>
</span>
</t>
<t t-if="editMode || isSignItemEditable" t-call="sign.signItemConfiguration"/>
</div>
</div>
</t>
</templates>

17
views/sign_item_views.xml Normal file
View File

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="sign_item_view_form" model="ir.ui.view">
<field name="name">sign.item.view.form.inherit.sequence</field>
<field name="model">sign.item</field>
<field name="inherit_id" ref="sign.sign_item_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='type_id']" position="after">
<field name="item_type" invisible="1"/>
<field name="sequence_id" invisible="item_type != 'sequence'"/>
<field name="sequence_prefix" invisible="item_type != 'sequence'"/>
<field name="sequence_padding" invisible="item_type != 'sequence'"/>
<field name="sequence_number_next" invisible="item_type != 'sequence'"/>
</xpath>
</field>
</record>
</odoo>