commit 9335de6fd4ec07c3cebba7676004f15630b9b9b3 Author: Suherdy Yacob Date: Mon Dec 29 13:48:22 2025 +0700 first commit diff --git a/README.rst b/README.rst new file mode 100755 index 0000000..263e45c --- /dev/null +++ b/README.rst @@ -0,0 +1,42 @@ +.. image:: https://img.shields.io/badge/license-LGPL--3-green.svg + :target: https://www.gnu.org/licenses/lgpl-3.0-standalone.html + :alt: License: LGPL-3 + +File Upload In Survey +===================== +This module is used for attachment of files in Survey Form + +Company +------- +* `Cybrosys Techno Solutions `__ + +License +------- +General Public License v3.0 (LGPL v3) +(https://www.gnu.org/licenses/lgpl-3.0-standalone.html) + +Credits +------- +Developer: (V17) Mohammed Dilshad Tk@ Cybrosys, Contact: odoo@cybrosys.com + +Contacts +-------- +* Mail Contact : odoo@cybrosys.com +* Website : https://cybrosys.com + +Bug Tracker +----------- +Bugs are tracked on GitHub Issues. In case of trouble, please check there if your issue has already been reported. + +Maintainer +========== +.. image:: https://cybrosys.com/images/logo.png + :target: https://cybrosys.com + +This module is maintained by Cybrosys Technologies. + +For support and more information, please visit `Our Website `__ + +Further information +=================== +HTML Description: ``__ diff --git a/__init__.py b/__init__.py new file mode 100755 index 0000000..0650744 --- /dev/null +++ b/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/__manifest__.py b/__manifest__.py new file mode 100755 index 0000000..2752e69 --- /dev/null +++ b/__manifest__.py @@ -0,0 +1,25 @@ +{ + 'name': "Image File Upload In Survey", + 'version': "18.0.1.0.3", + 'category': 'Extra Tools', + 'summary': 'Attachment of Image Files in Survey Form', + 'description': 'This module is used for attachments of image files in Survey Form, ' + 'You can also add multiple image file attachment to Survey Form .', + 'author': 'Suherdy Yacob', + 'depends': ['survey'], + 'assets': { + 'survey.survey_assets': [ + 'survey_upload_image/static/src/js/survey_form_attachment.js', + 'survey_upload_image/static/src/js/SurveyFormWidget.js', + ], + }, + 'data': [ + 'views/survey_question_views.xml', + 'views/survey_user_views.xml', + 'views/survey_templates.xml', + ], + 'license': 'LGPL-3', + 'installable': True, + 'auto_install': False, + 'application': False, +} diff --git a/__pycache__/__init__.cpython-312.pyc b/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..ca75b88 Binary files /dev/null and b/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__init__.py b/models/__init__.py new file mode 100755 index 0000000..74ee499 --- /dev/null +++ b/models/__init__.py @@ -0,0 +1,3 @@ +from . import survey_question +from . import survey_user_input_line +from . import survey_user_input diff --git a/models/__pycache__/__init__.cpython-312.pyc b/models/__pycache__/__init__.cpython-312.pyc new file mode 100644 index 0000000..0777634 Binary files /dev/null and b/models/__pycache__/__init__.cpython-312.pyc differ diff --git a/models/__pycache__/survey_question.cpython-312.pyc b/models/__pycache__/survey_question.cpython-312.pyc new file mode 100644 index 0000000..7c9c3c3 Binary files /dev/null and b/models/__pycache__/survey_question.cpython-312.pyc differ diff --git a/models/__pycache__/survey_user_input.cpython-312.pyc b/models/__pycache__/survey_user_input.cpython-312.pyc new file mode 100644 index 0000000..4d53a52 Binary files /dev/null and b/models/__pycache__/survey_user_input.cpython-312.pyc differ diff --git a/models/__pycache__/survey_user_input_line.cpython-312.pyc b/models/__pycache__/survey_user_input_line.cpython-312.pyc new file mode 100644 index 0000000..e4053c2 Binary files /dev/null and b/models/__pycache__/survey_user_input_line.cpython-312.pyc differ diff --git a/models/survey_question.py b/models/survey_question.py new file mode 100755 index 0000000..b6cc575 --- /dev/null +++ b/models/survey_question.py @@ -0,0 +1,86 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Mohammed Dilshad Tk (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU LESSER +# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. +# +# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE +# (LGPL v3) along with this program. +# If not, see . +# +############################################################################## +from odoo import fields, models + + +class SurveyQuestion(models.Model): + """ + This class extends the 'survey.question' model to add new functionality + for file uploads. + """ + _inherit = 'survey.question' + + question_type = fields.Selection( + selection_add=[('upload_file', 'Upload File')], + help='Select the type of question to create.') + upload_multiple_file = fields.Boolean(string='Upload Multiple File', + help='Check this box if you want to ' + 'allow users to upload ' + 'multiple files') + + def validate_question(self, answer, comment=None): + """Validate question answer.""" + self.ensure_one() + if self.question_type == 'upload_file': + if self.constr_mandatory: + # Answer comes as a JSON string '[dataURLs, fileNames]' or raw list from previous steps? + # The controller passes what it received. + # If we parsed it in `_save_lines`, that's too late. Validation happens before. + + # We need to handle the string if it's not parsed yet, or parsed if Odoo does something. + # Standard Odoo validation receives raw inputs usually. + import logging + from odoo.http import request + _logger = logging.getLogger(__name__) + + # Fallback: Check for prefixed param if answer is empty + # because standard controller looks for str(id) + if not answer or answer == '[]': + # Try multiple key variants including empty string (common issue with t-att-name) + keys_to_check = [f'upload_{self.id}', str(self.id), ''] + for key in keys_to_check: + val = request.params.get(key) + if val and val != '[]': + answer = val + break + + is_answered = False + if answer and answer != '[]': + import json + try: + # If answer is a string, try parsing it + if isinstance(answer, str): + answer_data = json.loads(answer) + else: + answer_data = answer + + # Check if we have dataURLs (first element of list) + if isinstance(answer_data, list) and len(answer_data) > 0 and answer_data[0]: + is_answered = True + except Exception as e: + _logger.error(f"VALIDATE QUESTION {self.id}: Error parsing answer: {e}") + is_answered = False + + if not is_answered: + return {self.id: "CUSTOM TAG: This question requires an answer."} + return {} + return super(SurveyQuestion, self).validate_question(answer, comment) diff --git a/models/survey_user_input.py b/models/survey_user_input.py new file mode 100755 index 0000000..c3bb8f9 --- /dev/null +++ b/models/survey_user_input.py @@ -0,0 +1,166 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Mohammed Dilshad Tk (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU LESSER +# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. +# +# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE +# (LGPL v3) along with this program. +# If not, see . +# +############################################################################## +from odoo import models + + +class SurveyUserInput(models.Model): + """ + This class extends the 'survey.user_input' model to add custom + functionality for saving user answers. + + Methods: + _save_lines: Save the user's answer for the given question + _save_line_file:Save the user's file upload answer for the given + question + _get_line_answer_file_upload_values: + Get the values to use when creating or updating a user input line + for a file upload answer + """ + _inherit = "survey.user_input" + + def _save_lines(self, question, answer, comment=None, + overwrite_existing=False): + """Save the user's answer for the given question.""" + old_answers = self.env['survey.user_input.line'].search([ + ('user_input_id', '=', self.id), + ('question_id', '=', question.id), ]) + if question.question_type == 'upload_file': + res = self._save_line_simple_answers(question, old_answers, answer) + else: + res = super(SurveyUserInput, self)._save_lines(question, answer, comment, + overwrite_existing) + return res + + def _save_line_simple_answers(self, question, old_answers, answer): + """ Save the user's file upload answer for the given question.""" + vals = self._get_line_answer_file_upload_values(question, + 'upload_file', answer) + if old_answers: + old_answers.write(vals) + return old_answers + else: + return self.env['survey.user_input.line'].create(vals) + + def _get_line_answer_file_upload_values(self, question, answer_type, + answer): + """Get the values to use when creating or updating a user input line + for a file upload answer. + Auto-compress images if they are uploaded.""" + vals = { + 'user_input_id': self.id, + 'question_id': question.id, + 'skipped': False, + 'answer_type': answer_type, + } + if answer_type == 'upload_file': + # --------------------------------------------------------- + # RETRIEVAL LOGIC: Same as in validate_question + # If standard controller failed to pass the answer (because key mismatch), + # we try to fetch it from request.params ourselves. + # --------------------------------------------------------- + import logging + _logger = logging.getLogger(__name__) + + if not answer or answer == '[]': + from odoo.http import request + if request: + keys_to_check = [f'upload_{question.id}', str(question.id), ''] + for key in keys_to_check: + val = request.params.get(key) + if val and val != '[]': + answer = val + break + + if isinstance(answer, str): + import json + try: + answer = json.loads(answer) + except Exception as e: + _logger.error(f"SAVE QUESTION {question.id}: JSON Decode Error: {e}") + pass + + if not answer or not isinstance(answer, list) or len(answer) < 2: + return vals # Skipped=False, but no files. + + file_data = answer[0] + file_name = answer[1] + attachment_ids = [] + + for i in range(len(answer[1])): + data = file_data[i] + fname = file_name[i] + + # Validation: Restrict to PNG, JPG, JPEG + if not fname.lower().endswith(('.png', '.jpg', '.jpeg')): + from odoo.exceptions import UserError + raise UserError(f"Only image files (PNG, JPG, JPEG) are allowed. Invalid file: {fname}") + + # Auto-compression logic + try: + # Check if file is an image based on extension or simple check + if fname.lower().endswith(('.png', '.jpg', '.jpeg')): + import base64 + import io + from PIL import Image + + # Decode base64 + image_stream = io.BytesIO(base64.b64decode(data)) + img = Image.open(image_stream) + + # Convert to RGB if RGBA/P to avoid issues saving as JPEG + if img.mode in ('RGBA', 'P'): + img = img.convert('RGB') + + # Resize if too large (max 1024x1024) + max_size = (1024, 1024) + img.thumbnail(max_size, Image.Resampling.LANCZOS) + + # Compress with lower quality + output_stream = io.BytesIO() + img.save(output_stream, format='JPEG', quality=50, optimize=True) + data = base64.b64encode(output_stream.getvalue()) + except Exception as e: + _logger.error(f"SAVE QUESTION {question.id}: Compression Error: {e}") + pass + + attachment = self.env['ir.attachment'].create({ + 'name': fname, + 'type': 'binary', + 'datas': data, + }) + attachment_ids.append(attachment.id) + + # Use Command tuples for Many2many relationship + # (6, 0, ids) replaces all existing records with the new list + vals['value_file_data_ids'] = [(6, 0, attachment_ids)] + return vals + + def action_delete_uploaded_files(self): + """Action to delete uploaded files for selected surveys.""" + for record in self: + file_answers = record.user_input_line_ids.filtered( + lambda l: l.answer_type == 'upload_file' and l.value_file_data_ids + ) + for line in file_answers: + # Unlink attachments + line.value_file_data_ids.unlink() + diff --git a/models/survey_user_input_line.py b/models/survey_user_input_line.py new file mode 100755 index 0000000..093e566 --- /dev/null +++ b/models/survey_user_input_line.py @@ -0,0 +1,61 @@ +# -*- coding: utf-8 -*- +############################################################################## +# +# Cybrosys Technologies Pvt. Ltd. +# +# Copyright (C) 2025-TODAY Cybrosys Technologies() +# Author: Mohammed Dilshad Tk (odoo@cybrosys.com) +# +# You can modify it under the terms of the GNU LESSER +# GENERAL PUBLIC LICENSE (LGPL v3), Version 3. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU LESSER GENERAL PUBLIC LICENSE (LGPL v3) for more details. +# +# You should have received a copy of the GNU LESSER GENERAL PUBLIC LICENSE +# (LGPL v3) along with this program. +# If not, see . +# +############################################################################## +from odoo import api, fields, models + + +class SurveyUserInputLine(models.Model): + """ + This class extends the 'survey.user_input.line' model to add additional + fields and constraints for file uploads. + Methods: + _check_answer_type_skipped:Check that a line's answer type is + not set to 'upload_file' if the line is skipped + """ + _inherit = "survey.user_input.line" + + answer_type = fields.Selection( + selection_add=[('upload_file', 'Upload File')], + help="The type of answer for this question (upload_file if the user " + "is uploading a file).") + value_file_data_ids = fields.Many2many('ir.attachment', + help="The attachments " + "corresponding to the user's " + "file upload answer, if any.") + + @api.constrains('skipped', 'answer_type') + def _check_answer_type_skipped(self): + """ Check that a line's answer type is not set to 'upload_file' if + the line is skipped.""" + for line in self: + if line.answer_type != 'upload_file': + super(SurveyUserInputLine, line)._check_answer_type_skipped() + + @api.depends('answer_type', 'value_file_data_ids') + def _compute_display_name(self): + """Override to include file names in the display name.""" + super()._compute_display_name() + for line in self: + if line.answer_type == 'upload_file': + if line.value_file_data_ids: + line.display_name = ', '.join(line.value_file_data_ids.mapped('name')) + else: + line.display_name = 'No file uploaded' diff --git a/static/src/js/SurveyFormWidget.js b/static/src/js/SurveyFormWidget.js new file mode 100755 index 0000000..e4c8e7d --- /dev/null +++ b/static/src/js/SurveyFormWidget.js @@ -0,0 +1,13 @@ +/** @odoo-module */ +import SurveyFormWidget from '@survey/js/survey_form'; +SurveyFormWidget.include({ + /** Get all question answers by question type */ + _prepareSubmitValues(formData, params) { + this._super(...arguments); + this.$('[data-question-type]').each(function () { + if ($(this).data('questionType') === 'upload_file'){ + params[this.name] = [$(this).data('oe-data'), $(this).data('oe-file_name')]; + } + }); + }, +}); diff --git a/static/src/js/survey_form_attachment.js b/static/src/js/survey_form_attachment.js new file mode 100755 index 0000000..5e62de7 --- /dev/null +++ b/static/src/js/survey_form_attachment.js @@ -0,0 +1,134 @@ +/** @odoo-module */ +import publicWidget from "@web/legacy/js/public/public_widget"; +import SurveyFormWidget from '@survey/js/survey_form'; +import SurveyPreloadImageMixin from "@survey/js/survey_preload_image_mixin"; +/** Extends publicWidget to create "SurveyFormUpload" */ +publicWidget.registry.SurveyFormUpload = publicWidget.Widget.extend(SurveyPreloadImageMixin, { + selector: '.o_survey_form', + events: { + 'change .o_survey_upload_file': '_onFileChange', + }, + init() { + this._super(...arguments); + // this.rpc = this.bindService("rpc"); + }, + /** On adding file function */ + _onFileChange: function (event) { + var self = this; + var files = event.target.files; + var fileNames = []; + var dataURLs = []; + + var $target = $(event.target); + // Find the container for this specific question + var $container = $target.closest('.o_survey_upload_container'); + + // Find elements scoped to this container + var $fileList = $container.find('.o_survey_upload_list'); + var fileListEl = $fileList[0]; + + var $hiddenInput = $container.find('input.o_survey_upload_file_value'); + + // Clear existing file list and delete button + if (fileListEl) { + fileListEl.innerHTML = ''; + } + + if (files.length === 0) { + $target.attr('data-oe-data', ''); + $target.attr('data-oe-file_name', ''); + $hiddenInput.val(''); + return; + } + + var loadedFiles = 0; + var totalFiles = files.length; + + // Create container for previews + var previewContainer = document.createElement('div'); + previewContainer.className = 'o_survey_file_previews d-flex flex-wrap gap-2 mb-2'; + if (fileListEl) fileListEl.appendChild(previewContainer); + + // Function to finish processing when all files are read + var checkAllFilesLoaded = function () { + if (loadedFiles === totalFiles) { + // Get question ID from data attribute - checking both input and hidden for flexibility + var questionId = $target.attr('data-question-id') || $hiddenInput.attr('data-question-id'); + + var finalPayload = JSON.stringify([dataURLs, fileNames]); + $target.attr('data-oe-data', JSON.stringify(dataURLs)); + $target.attr('data-oe-file_name', JSON.stringify(fileNames)); + + // Set name dynamically to ensure backend finds it with the correct key + if (questionId) { + $hiddenInput.attr('name', 'upload_' + questionId); + } + $hiddenInput.val(finalPayload); + + // Create delete button only once + var deleteBtn = document.createElement('button'); + deleteBtn.className = 'btn btn-danger btn-sm mt-2'; + deleteBtn.textContent = 'Delete All'; + deleteBtn.type = 'button'; // Prevent form submission + deleteBtn.addEventListener('click', function () { + // Clear file list + if (fileListEl) fileListEl.innerHTML = ''; + // Clear input field attributes + $target.attr('data-oe-data', ''); + $target.attr('data-oe-file_name', ''); + $hiddenInput.val(''); + // Reset file input + $target.val(''); + }); + if (fileListEl) fileListEl.appendChild(deleteBtn); + } + }; + + for (let i = 0; i < files.length; i++) { + var reader = new FileReader(); + reader.readAsDataURL(files[i]); + reader.onload = function (e) { + var file = files[i]; + var filename = file.name; + var dataURL = e.target.result.split(',')[1]; /** split base64 data */ + + // Ensure order is preserved might be tricky with async, but simple push is ok for now + // or use index if strict order needed. + // Detailed handling often requires mapping index. + fileNames.push(filename); + dataURLs.push(dataURL); + + // Create preview element + var previewItem = document.createElement('div'); + previewItem.className = 'card p-1'; + previewItem.style.width = '100px'; + + if (file.type.startsWith('image/')) { + var img = document.createElement('img'); + img.src = e.target.result; // Use full data URL for preview + img.className = 'card-img-top'; + img.style.height = '80px'; + img.style.objectFit = 'cover'; + img.title = filename; + previewItem.appendChild(img); + } else { + var icon = document.createElement('div'); + icon.className = 'text-center p-3'; + icon.innerHTML = ''; + previewItem.appendChild(icon); + } + + var nameDiv = document.createElement('div'); + nameDiv.className = 'card-body p-1 text-truncate small'; + nameDiv.textContent = filename; + previewItem.appendChild(nameDiv); + + previewContainer.appendChild(previewItem); + + loadedFiles++; + checkAllFilesLoaded(); + } + } + }, +}); +export default publicWidget.registry.SurveyFormUpload; diff --git a/views/survey_question_views.xml b/views/survey_question_views.xml new file mode 100755 index 0000000..39e9672 --- /dev/null +++ b/views/survey_question_views.xml @@ -0,0 +1,22 @@ + + + + + survey.question.view.form.inherit.survey.upload.file + survey.question + + + +
+

Upload Files + +

+
+
+ + + +
+
+
diff --git a/views/survey_templates.xml b/views/survey_templates.xml new file mode 100755 index 0000000..36fe216 --- /dev/null +++ b/views/survey_templates.xml @@ -0,0 +1,69 @@ + + + + + + + + + + + diff --git a/views/survey_user_views.xml b/views/survey_user_views.xml new file mode 100755 index 0000000..f448de8 --- /dev/null +++ b/views/survey_user_views.xml @@ -0,0 +1,49 @@ + + + + + survey.user_input.line.view.form.inherit.survey.upload.image + survey.user_input.line + + + + + + + + + + + + + + + + + ir.attachment.form.survey.upload + ir.attachment + +
+ + + + + + + + +
+
+
+ + + Delete Uploaded Files + + + list,form + code + + action = records.action_delete_uploaded_files() + + +