commit 923ac57db6bec1e8adf4a6ffb62ae2cc7eff11d7 Author: admin.suherdy Date: Wed Dec 17 14:32:07 2025 +0700 first commit diff --git a/__init__.py b/__init__.py new file mode 100644 index 0000000..a0fdc10 --- /dev/null +++ b/__init__.py @@ -0,0 +1,2 @@ +# -*- coding: utf-8 -*- +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100644 index 0000000..4cc69b5 --- /dev/null +++ b/__manifest__.py @@ -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', +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..199e5ff Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/data/sign_item_type_data.xml b/data/sign_item_type_data.xml new file mode 100644 index 0000000..ea35b50 --- /dev/null +++ b/data/sign_item_type_data.xml @@ -0,0 +1,12 @@ + + + + Sequence + sequence + fa-sort-numeric-asc + Auto-generated sequence number + Sequence Number + 0.2 + 0.05 + + diff --git a/models/__init__.py b/models/__init__.py new file mode 100644 index 0000000..06a9a02 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,4 @@ +# -*- coding: utf-8 -*- +from . import sign_item_type +from . import sign_item +from . import sign_request diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..b5f89a1 Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/sign_item.cpython-312.pyc b/models/__pycache__/sign_item.cpython-312.pyc new file mode 100644 index 0000000..8139981 Binary files /dev/null and b/models/__pycache__/sign_item.cpython-312.pyc differ diff --git a/models/__pycache__/sign_item_type.cpython-312.pyc b/models/__pycache__/sign_item_type.cpython-312.pyc new file mode 100644 index 0000000..2c56f07 Binary files /dev/null and b/models/__pycache__/sign_item_type.cpython-312.pyc differ diff --git a/models/__pycache__/sign_request.cpython-312.pyc b/models/__pycache__/sign_request.cpython-312.pyc new file mode 100644 index 0000000..f4cf83f Binary files /dev/null and b/models/__pycache__/sign_request.cpython-312.pyc differ diff --git a/models/sign_item.py b/models/sign_item.py new file mode 100644 index 0000000..7beac3b --- /dev/null +++ b/models/sign_item.py @@ -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 diff --git a/models/sign_item_type.py b/models/sign_item_type.py new file mode 100644 index 0000000..8ffa20e --- /dev/null +++ b/models/sign_item_type.py @@ -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'}) diff --git a/models/sign_request.py b/models/sign_request.py new file mode 100644 index 0000000..20ba013 --- /dev/null +++ b/models/sign_request.py @@ -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("%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 + + 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() + diff --git a/static/src/js/sign_backend_patch.js b/static/src/js/sign_backend_patch.js new file mode 100644 index 0000000..e7ba4c9 --- /dev/null +++ b/static/src/js/sign_backend_patch.js @@ -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]; + } +}); diff --git a/static/src/js/sign_item_custom_popover_patch.js b/static/src/js/sign_item_custom_popover_patch.js new file mode 100644 index 0000000..620e4af --- /dev/null +++ b/static/src/js/sign_item_custom_popover_patch.js @@ -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, +}; diff --git a/static/src/xml/sign_item_custom_popover_patch.xml b/static/src/xml/sign_item_custom_popover_patch.xml new file mode 100644 index 0000000..52ef259 --- /dev/null +++ b/static/src/xml/sign_item_custom_popover_patch.xml @@ -0,0 +1,49 @@ + + + + + +
+ + + + + + +
+
+ + + +
+ +
+ +
+
+ + + +
+ +
+ +
+
+
+ +
+ +
+
+
+ +
+ +
+
+
+
+
+
+
diff --git a/static/src/xml/sign_item_sequence.xml b/static/src/xml/sign_item_sequence.xml new file mode 100644 index 0000000..c718d7b --- /dev/null +++ b/static/src/xml/sign_item_sequence.xml @@ -0,0 +1,38 @@ + + + + + + + + +
+ + + + + + + + + +
+
+
+ + +
+
+ + + + + + + + + +
+
+
+
diff --git a/views/sign_item_views.xml b/views/sign_item_views.xml new file mode 100644 index 0000000..5e8e49c --- /dev/null +++ b/views/sign_item_views.xml @@ -0,0 +1,17 @@ + + + + sign.item.view.form.inherit.sequence + sign.item + + + + + + + + + + + +