first commit

This commit is contained in:
Suherdy Yacob 2025-12-29 13:48:22 +07:00
commit 9335de6fd4
17 changed files with 671 additions and 0 deletions

42
README.rst Executable file
View File

@ -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 <https://cybrosys.com/>`__
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 <https://cybrosys.com/>`__
Further information
===================
HTML Description: `<static/description/index.html>`__

1
__init__.py Executable file
View File

@ -0,0 +1 @@
from . import models

25
__manifest__.py Executable file
View File

@ -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,
}

Binary file not shown.

3
models/__init__.py Executable file
View File

@ -0,0 +1,3 @@
from . import survey_question
from . import survey_user_input_line
from . import survey_user_input

Binary file not shown.

Binary file not shown.

Binary file not shown.

86
models/survey_question.py Executable file
View File

@ -0,0 +1,86 @@
# -*- coding: utf-8 -*-
##############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# 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 <http://www.gnu.org/licenses/>.
#
##############################################################################
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)

166
models/survey_user_input.py Executable file
View File

@ -0,0 +1,166 @@
# -*- coding: utf-8 -*-
##############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# 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 <http://www.gnu.org/licenses/>.
#
##############################################################################
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()

View File

@ -0,0 +1,61 @@
# -*- coding: utf-8 -*-
##############################################################################
#
# Cybrosys Technologies Pvt. Ltd.
#
# Copyright (C) 2025-TODAY Cybrosys Technologies(<https://www.cybrosys.com>)
# 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 <http://www.gnu.org/licenses/>.
#
##############################################################################
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'

View File

@ -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')];
}
});
},
});

View File

@ -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 = '<i class="fa fa-file fa-2x"></i>';
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;

22
views/survey_question_views.xml Executable file
View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inherited form view if survey.question-->
<record id="survey_question_form" model="ir.ui.view">
<field name="name">survey.question.view.form.inherit.survey.upload.file</field>
<field name="model">survey.question</field>
<field name="inherit_id" ref="survey.survey_question_form"/>
<field name="arch" type="xml">
<xpath expr="//div[hasclass('o_preview_questions')]"
position="inside">
<div invisible="question_type != 'upload_file'">
<p class="o_upload_file">Upload Files
<i class="fa fa-upload"/>
</p>
</div>
</xpath>
<xpath expr="//field[@name='constr_mandatory']" position="after">
<field name="upload_multiple_file" invisible="question_type != 'upload_file'"/>
</xpath>
</field>
</record>
</odoo>

69
views/survey_templates.xml Executable file
View File

@ -0,0 +1,69 @@
<?xml version="1.0" encoding="UTF-8" ?>
<odoo>
<!-- If question_type= upload file then t-call template-->
<template id="question_page_upload_answer"
inherit_id="survey.question_container">
<xpath expr="//div[@role='alert']" position="before">
<t t-if="question.question_type == 'upload_file'">
<t t-call="survey_upload_image.multi_upload_file"/>
</t>
</xpath>
</template>
<!-- Answer View-->
<template id="multi_upload_file">
<div class="o_survey_upload_container">
<div class="o_survey_upload_box" id="survey_upload_box">
<div class="o_survey_upload_box_header">Upload Files</div>
<br/>
<div class="o_survey_upload_box_body">
<input type="file" class="o_survey_upload_file"
data-oe-data=""
data-oe-file_name=""
accept="image/png, image/jpeg, image/jpg, .png, .jpg, .jpeg"
t-att-data-question-type="question.question_type"
t-att-multiple="question.upload_multiple_file"
t-att-data-question-id="question.id"
/>
<input type="hidden" class="o_survey_upload_file_value" name="temp_upload_name" t-att-data-question-id="question.id"/>
</div>
</div>
<br/>
<div class="o_survey_upload_list"/>
<t t-if="question.upload_multiple_file == False">
<div class="o_survey_upload_note">Note: You can only upload one
file.
</div>
</t>
<t t-if="question.upload_multiple_file == True">
<div class="o_survey_upload_note">Note: You can upload
Multiple files.
</div>
</t>
</div>
</template>
<!--Show the answer in print page if question type = upload file then t-call template-->
<template id="survey_page_print_upload_answer"
inherit_id="survey.survey_page_print">
<xpath expr="//div[hasclass('o_survey_question_error')]"
position="before">
<t t-if="question.question_type == 'upload_file'">
<t t-call="survey_upload_image.multi_upload_answer"/>
</t>
</xpath>
</template>
<!--Answer Value attachments-->
<template id="multi_upload_answer">
<t t-if="answer_lines.value_file_data_ids">
<div>
<t t-foreach="answer_lines.value_file_data_ids"
t-as="attachment">
<a t-attf-href="/web/content/{{ attachment.id }}?download=true">
<i class="fa fa-download"/>
<t t-esc="attachment.name"/>
</a>
<br/>
</t>
</div>
</t>
</template>
</odoo>

49
views/survey_user_views.xml Executable file
View File

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<!-- Inherited the survey.user_input.line to add fields -->
<record id="survey_user_input_line_view_form" model="ir.ui.view">
<field name="name">survey.user_input.line.view.form.inherit.survey.upload.image</field>
<field name="model">survey.user_input.line</field>
<field name="inherit_id" ref="survey.survey_user_input_line_view_form"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='suggested_answer_id']" position="after">
<field name="value_file_data_ids" context="{'form_view_ref': 'survey_upload_image.view_attachment_form_survey_upload'}">
<list>
<field name="datas" widget="image" options="{'size': [50, 50]}" string="Preview"/>
<field name="name"/>
<field name="file_size"/>
<field name="type"/>
</list>
</field>
</xpath>
</field>
</record>
<record id="view_attachment_form_survey_upload" model="ir.ui.view">
<field name="name">ir.attachment.form.survey.upload</field>
<field name="model">ir.attachment</field>
<field name="arch" type="xml">
<form string="Attachment">
<sheet>
<group>
<field name="name"/>
<field name="datas" widget="image" options="{'size': [400, 400]}"/>
<field name="file_size"/>
<field name="mimetype"/>
</group>
</sheet>
</form>
</field>
</record>
<record id="action_survey_user_input_delete_files" model="ir.actions.server">
<field name="name">Delete Uploaded Files</field>
<field name="model_id" ref="survey.model_survey_user_input"/>
<field name="binding_model_id" ref="survey.model_survey_user_input"/>
<field name="binding_view_types">list,form</field>
<field name="state">code</field>
<field name="code">
action = records.action_delete_uploaded_files()
</field>
</record>
</odoo>