first push message
This commit is contained in:
@@ -0,0 +1,2 @@
|
||||
from . import controllers
|
||||
from . import models
|
||||
@@ -0,0 +1,38 @@
|
||||
{
|
||||
'name': "survey_extra_fields",
|
||||
|
||||
'summary': "Short (1 phrase/line) summary of the module's purpose",
|
||||
|
||||
'description': """
|
||||
Long description of module's purpose
|
||||
""",
|
||||
|
||||
'author': "My Company",
|
||||
'website': "https://www.yourcompany.com",
|
||||
|
||||
# Categories can be used to filter modules in modules listing
|
||||
# Check https://github.com/odoo/odoo/blob/15.0/odoo/addons/base/data/ir_module_category_data.xml
|
||||
# for the full list
|
||||
'category': 'Uncategorized',
|
||||
'version': '0.1',
|
||||
|
||||
# any module necessary for this one to work correctly
|
||||
"data": [
|
||||
"views/survey_question_views.xml",
|
||||
"views/survey_templates.xml",
|
||||
"views/survey_user_input_views.xml",
|
||||
"data/survey_cron_views.xml",
|
||||
"views/survey_survey_cron_views.xml",
|
||||
],
|
||||
"assets": {
|
||||
"web.assets_frontend": [
|
||||
"survey_extra_fields/static/src/js/survey_many2many_select2.js",
|
||||
],
|
||||
"survey.survey_assets": [
|
||||
"survey_extra_fields/static/src/js/survey_color_field.js",
|
||||
"survey_extra_fields/static/src/js/survey_signature_field.js",
|
||||
"survey_extra_fields/static/src/js/survey_range_field.js",
|
||||
],
|
||||
},
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,48 @@
|
||||
from odoo import http
|
||||
from odoo.http import request
|
||||
import base64
|
||||
import json
|
||||
|
||||
class SurveyFileController(http.Controller):
|
||||
|
||||
@http.route('/survey/upload_file', type='http', auth='public', methods=['POST'], csrf=False)
|
||||
def upload_file(self, **kwargs):
|
||||
try:
|
||||
file = request.httprequest.files.get('file')
|
||||
if not file:
|
||||
return json.dumps({'error': 'No file provided'})
|
||||
|
||||
# Create temporary attachment
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': file.filename,
|
||||
'datas': base64.b64encode(file.read()),
|
||||
'res_model': 'survey.user_input.line',
|
||||
'public': False,
|
||||
})
|
||||
|
||||
return json.dumps({'attachment_id': attachment.id, 'filename': file.filename})
|
||||
except Exception as e:
|
||||
return json.dumps({'error': str(e)})
|
||||
|
||||
@http.route('/survey/save_signature', type='json', auth='public', methods=['POST'])
|
||||
def save_signature(self, signature_data, question_id, **kwargs):
|
||||
try:
|
||||
if not signature_data or not signature_data.startswith('data:image/'):
|
||||
return {'error': 'Invalid signature data'}
|
||||
|
||||
# Extract base64 data from data URL
|
||||
header, data = signature_data.split(',', 1)
|
||||
image_data = base64.b64decode(data)
|
||||
|
||||
# Create attachment for signature
|
||||
attachment = request.env['ir.attachment'].sudo().create({
|
||||
'name': f'signature_question_{question_id}.png',
|
||||
'datas': base64.b64encode(image_data),
|
||||
'res_model': 'survey.user_input.line',
|
||||
'mimetype': 'image/png',
|
||||
'public': False,
|
||||
})
|
||||
|
||||
return {'attachment_id': attachment.id, 'success': True}
|
||||
except Exception as e:
|
||||
return {'error': str(e)}
|
||||
@@ -0,0 +1,30 @@
|
||||
<odoo>
|
||||
<data>
|
||||
<!--
|
||||
<record id="object0" model="survey_extra_fields.survey_extra_fields">
|
||||
<field name="name">Object 0</field>
|
||||
<field name="value">0</field>
|
||||
</record>
|
||||
|
||||
<record id="object1" model="survey_extra_fields.survey_extra_fields">
|
||||
<field name="name">Object 1</field>
|
||||
<field name="value">10</field>
|
||||
</record>
|
||||
|
||||
<record id="object2" model="survey_extra_fields.survey_extra_fields">
|
||||
<field name="name">Object 2</field>
|
||||
<field name="value">20</field>
|
||||
</record>
|
||||
|
||||
<record id="object3" model="survey_extra_fields.survey_extra_fields">
|
||||
<field name="name">Object 3</field>
|
||||
<field name="value">30</field>
|
||||
</record>
|
||||
|
||||
<record id="object4" model="survey_extra_fields.survey_extra_fields">
|
||||
<field name="name">Object 4</field>
|
||||
<field name="value">40</field>
|
||||
</record>
|
||||
-->
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import survey_question
|
||||
from . import survey_user_input
|
||||
from . import survey_user_input_line
|
||||
from . import survey_survey
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -0,0 +1,631 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
import re
|
||||
from datetime import datetime
|
||||
|
||||
|
||||
class SurveyQuestion(models.Model):
|
||||
_inherit = 'survey.question'
|
||||
|
||||
question_type = fields.Selection(
|
||||
selection_add=[
|
||||
('color', 'Color'),
|
||||
('email', 'Email'),
|
||||
('url', 'URL'),
|
||||
('time', 'Time'),
|
||||
('range', 'Range'),
|
||||
('week', 'Week'),
|
||||
('password', 'Password'),
|
||||
('file', 'File'),
|
||||
('signature', 'Signature'),
|
||||
('month', 'Month'),
|
||||
('address', 'Address'),
|
||||
('name', 'Name'),
|
||||
('many2one', 'Many2one'),
|
||||
('many2many', 'Many2many'),
|
||||
],
|
||||
ondelete={
|
||||
'color': 'cascade',
|
||||
'email': 'cascade',
|
||||
'url': 'cascade',
|
||||
'time': 'cascade',
|
||||
'range': 'cascade',
|
||||
'week': 'cascade',
|
||||
'password': 'cascade',
|
||||
'file': 'cascade',
|
||||
'signature': 'cascade',
|
||||
'month': 'cascade',
|
||||
'address': 'cascade',
|
||||
'name': 'cascade',
|
||||
'many2one': 'cascade',
|
||||
'many2many': 'cascade',
|
||||
}
|
||||
)
|
||||
|
||||
|
||||
|
||||
# Time field specific
|
||||
time_validate = fields.Boolean('Enable Validation')
|
||||
time_min = fields.Char('Min Time (HH:MM)')
|
||||
time_max = fields.Char('Max Time (HH:MM)')
|
||||
time_step = fields.Integer('Time Step (minutes)', default=15)
|
||||
time_error_msg = fields.Char('Validation Error Message', default="Invalid time selected")
|
||||
|
||||
# Range field specific
|
||||
range_min = fields.Float('Range Min', default=0)
|
||||
range_max = fields.Float('Range Max', default=100)
|
||||
range_step = fields.Float('Range Step', default=1)
|
||||
validate_range = fields.Boolean('Validate Entry')
|
||||
|
||||
# Week field specific
|
||||
validate_week_entry = fields.Boolean('Validate Week Entry', default=True)
|
||||
week_min = fields.Char('Min Week', help="Minimum selectable week (YYYY-WW)")
|
||||
week_max = fields.Char('Max Week', help="Maximum selectable week (YYYY-WW)")
|
||||
week_step = fields.Integer('Week Step', default=1, help="Step between selectable weeks")
|
||||
week_error_msg = fields.Char('Week Validation Error', default='Invalid week value.')
|
||||
|
||||
# Password specific
|
||||
validate_password = fields.Boolean('Validate Password Entry', default=True)
|
||||
password_min_length = fields.Integer('Min Password Length', default=1)
|
||||
password_max_length = fields.Integer('Max Password Length', default=8)
|
||||
password_error_msg = fields.Char('Password Validation Error', default='Invalid password length.')
|
||||
|
||||
# File field specific
|
||||
file_max_size = fields.Float('Max File Size (MB)', default=10.0)
|
||||
file_allowed_types = fields.Char('Allowed File Types', help="Comma-separated extensions (e.g., pdf,jpg,png)")
|
||||
|
||||
# Signature field specific
|
||||
signature_width = fields.Integer('Canvas Width', default=400)
|
||||
signature_height = fields.Integer('Canvas Height', default=200)
|
||||
|
||||
# Month field specific
|
||||
validate_month_entry = fields.Boolean('Validate Entry', default=True)
|
||||
month_min = fields.Char('Min Month', help="Minimum selectable month (YYYY-MM)")
|
||||
month_max = fields.Char('Max Month', help="Maximum selectable month (YYYY-MM)")
|
||||
month_step = fields.Integer('Month Step', default=1, help="Step between selectable months")
|
||||
month_error_msg = fields.Char('Month Validation Error', default='Invalid month value.')
|
||||
|
||||
# Address field specific
|
||||
address_enable_street = fields.Boolean('Enable Street', default=True)
|
||||
address_enable_street2 = fields.Boolean('Enable Street 2', default=True)
|
||||
address_enable_zip = fields.Boolean('Enable Zip', default=True)
|
||||
address_enable_city = fields.Boolean('Enable City', default=True)
|
||||
address_enable_state = fields.Boolean('Enable State', default=True)
|
||||
address_enable_country = fields.Boolean('Enable Country', default=True)
|
||||
address_label_street = fields.Char('Street Label', default='Street')
|
||||
address_label_street2 = fields.Char('Street 2 Label', default='Street 2')
|
||||
address_label_zip = fields.Char('Zip Label', default='Zip')
|
||||
address_label_city = fields.Char('City Label', default='City')
|
||||
address_label_state = fields.Char('State Label', default='State')
|
||||
address_label_country = fields.Char('Country Label', default='Country')
|
||||
|
||||
# Name field specific
|
||||
name_middle_optional = fields.Boolean('Middle Name Optional', default=True, help="If enabled, middle name is optional even when question is mandatory")
|
||||
|
||||
# Many2one field specific
|
||||
many2one_model = fields.Char('Model Name', help="Model to select records from (e.g., res.partner)")
|
||||
|
||||
# Many2many field specific
|
||||
many2many_model = fields.Char('Model Name', help="Model to select records from (e.g., res.partner)")
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Time Field Config Check
|
||||
# -------------------------
|
||||
@api.constrains('time_min', 'time_max', 'time_step')
|
||||
def _check_time_format(self):
|
||||
for question in self:
|
||||
if question.question_type == 'time':
|
||||
# Validate time format for each relevant field
|
||||
for field_name in ['time_min', 'time_max']:
|
||||
value = getattr(question, field_name)
|
||||
if value:
|
||||
try:
|
||||
datetime.strptime(value, "%H:%M")
|
||||
except ValueError:
|
||||
raise ValidationError(_("%s must be in HH:MM format") % field_name)
|
||||
|
||||
# Validate time order and step logic
|
||||
if question.time_min and question.time_max:
|
||||
min_time = datetime.strptime(question.time_min, "%H:%M")
|
||||
max_time = datetime.strptime(question.time_max, "%H:%M")
|
||||
|
||||
# Ensure max is not before min
|
||||
if max_time < min_time:
|
||||
raise ValidationError(_("Time Max cannot be earlier than Time Min."))
|
||||
|
||||
# Validate step compatibility (step must fit in range)
|
||||
if question.time_step and question.time_step > 0:
|
||||
total_minutes = int((max_time - min_time).total_seconds() / 60)
|
||||
if total_minutes < question.time_step:
|
||||
raise ValidationError(_(
|
||||
"Time Step must be smaller than or equal to the difference "
|
||||
"between Time Min and Time Max."
|
||||
))
|
||||
|
||||
# Ensure step is positive
|
||||
if question.time_step is not None and question.time_step <= 0:
|
||||
raise ValidationError(_("Time Step must be greater than 0."))
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Range Field Config Check
|
||||
# -------------------------
|
||||
@api.constrains('range_min', 'range_max', 'range_step')
|
||||
def _check_range_config(self):
|
||||
for question in self:
|
||||
if question.question_type == 'range':
|
||||
if question.range_max < question.range_min:
|
||||
raise ValidationError(_("Range Max cannot be less than Range Min."))
|
||||
if question.range_step <= 0:
|
||||
raise ValidationError(_("Range Step must be greater than 0."))
|
||||
if question.range_step > (question.range_max - question.range_min):
|
||||
raise ValidationError(_("Range Step cannot be greater than the range (Max - Min)."))
|
||||
|
||||
# -------------------------
|
||||
# Week Field Config Check
|
||||
# -------------------------
|
||||
@api.constrains('week_min', 'week_max', 'week_step')
|
||||
def _check_week_config(self):
|
||||
week_regex = r'^\d{4}-W\d{2}$'
|
||||
for question in self:
|
||||
if question.question_type == 'week':
|
||||
min_val = question.week_min
|
||||
max_val = question.week_max
|
||||
if min_val and not re.match(week_regex, min_val):
|
||||
raise ValidationError(_("Min Week must be in YYYY-WW format."))
|
||||
if max_val and not re.match(week_regex, max_val):
|
||||
raise ValidationError(_("Max Week must be in YYYY-WW format."))
|
||||
if min_val and max_val:
|
||||
min_year, min_week = map(int, min_val.split('-W'))
|
||||
max_year, max_week = map(int, max_val.split('-W'))
|
||||
min_date = datetime.strptime(f'{min_year}-W{min_week}-1', "%Y-W%W-%w")
|
||||
max_date = datetime.strptime(f'{max_year}-W{max_week}-1', "%Y-W%W-%w")
|
||||
if max_date < min_date:
|
||||
raise ValidationError(_("Max Week cannot be earlier than Min Week."))
|
||||
|
||||
# Calculate total number of weeks in the range
|
||||
total_weeks = (max_year - min_year) * 52 + (max_week - min_week) + 1
|
||||
if question.week_step > total_weeks:
|
||||
raise ValidationError(_("Week Step cannot be greater than the total number of weeks in the range."))
|
||||
|
||||
if question.week_step <= 0:
|
||||
raise ValidationError(_("Week Step must be greater than 0."))
|
||||
|
||||
# -------------------------
|
||||
# Password Field Config Check
|
||||
# -------------------------
|
||||
|
||||
@api.constrains('password_min_length', 'password_max_length')
|
||||
def _check_password_limits(self):
|
||||
for question in self:
|
||||
if question.question_type == 'password':
|
||||
if question.password_min_length < 1:
|
||||
raise ValidationError(_("Minimum password length must be at least 1."))
|
||||
if question.password_max_length < question.password_min_length:
|
||||
raise ValidationError(_("Maximum password length cannot be less than minimum length."))
|
||||
|
||||
@api.constrains('file_max_size')
|
||||
def _check_file_size(self):
|
||||
for question in self:
|
||||
if question.question_type == 'file' and question.file_max_size <= 0:
|
||||
raise ValidationError(_('File size must be greater than 0 MB.'))
|
||||
|
||||
# -------------------------
|
||||
# Month Field Config Check
|
||||
# -------------------------
|
||||
@api.constrains('month_min', 'month_max', 'month_step')
|
||||
def _check_month_config(self):
|
||||
month_regex = r'^\d{4}-(0[1-9]|1[0-2])$' # Strict month validation (01-12)
|
||||
for question in self:
|
||||
if question.question_type == 'month':
|
||||
min_val = question.month_min
|
||||
max_val = question.month_max
|
||||
if min_val and not re.match(month_regex, min_val):
|
||||
raise ValidationError(_("Min Month must be in YYYY-MM format with valid month (01-12)."))
|
||||
if max_val and not re.match(month_regex, max_val):
|
||||
raise ValidationError(_("Max Month must be in YYYY-MM format with valid month (01-12)."))
|
||||
if min_val and max_val and min_val > max_val:
|
||||
raise ValidationError(_("Max Month cannot be earlier than Min Month."))
|
||||
if question.month_step <= 0:
|
||||
raise ValidationError(_("Month Step must be greater than 0."))
|
||||
|
||||
# Validate step against range
|
||||
if min_val and max_val and question.month_step > 1:
|
||||
min_year, min_month = map(int, min_val.split('-'))
|
||||
max_year, max_month = map(int, max_val.split('-'))
|
||||
total_months = (max_year - min_year) * 12 + (max_month - min_month)
|
||||
if question.month_step > total_months:
|
||||
raise ValidationError(_("Month Step (%s) cannot be greater than the total months in range (%s).") % (question.month_step, total_months))
|
||||
|
||||
# -------------------------
|
||||
# Address Field Config Check
|
||||
# -------------------------
|
||||
@api.constrains('address_enable_street', 'address_enable_street2', 'address_enable_zip', 'address_enable_city', 'address_enable_state', 'address_enable_country')
|
||||
def _check_address_config(self):
|
||||
for question in self:
|
||||
if question.question_type == 'address':
|
||||
enabled_fields = [
|
||||
question.address_enable_street,
|
||||
question.address_enable_street2,
|
||||
question.address_enable_zip,
|
||||
question.address_enable_city,
|
||||
question.address_enable_state,
|
||||
question.address_enable_country
|
||||
]
|
||||
if not any(enabled_fields):
|
||||
raise ValidationError(_('At least one address sub-field must be enabled.'))
|
||||
|
||||
# -------------------------
|
||||
# Many2one Field Config Check
|
||||
|
||||
@api.constrains('many2one_model', 'question_type')
|
||||
def _check_many2one_model(self):
|
||||
for question in self:
|
||||
if question.question_type == 'many2one':
|
||||
if not question.many2one_model:
|
||||
raise ValidationError(_('Model name is required for Many2one field type.'))
|
||||
|
||||
# Convert display name to technical name
|
||||
converted_model = question._convert_model_name(question.many2one_model)
|
||||
|
||||
# Check if model exists in ir.model (installed)
|
||||
model_record = self.env['ir.model'].search([('model', '=', converted_model)], limit=1)
|
||||
if not model_record:
|
||||
raise ValidationError(_('Model "%s" does not exist.') % question.many2one_model)
|
||||
|
||||
# Check if model is accessible in environment
|
||||
try:
|
||||
self.env[converted_model]
|
||||
# Update field with technical name if conversion happened
|
||||
if converted_model != question.many2one_model:
|
||||
question.many2one_model = converted_model
|
||||
except KeyError:
|
||||
raise ValidationError(_('Model "%s" is not accessible or has incorrect name.') % question.many2one_model)
|
||||
|
||||
@api.constrains('many2many_model', 'question_type')
|
||||
def _check_many2many_model(self):
|
||||
for question in self:
|
||||
if question.question_type == 'many2many':
|
||||
if not question.many2many_model:
|
||||
raise ValidationError(_('Model name is required for Many2many field type.'))
|
||||
|
||||
# Convert display name to technical name
|
||||
converted_model = question._convert_model_name(question.many2many_model)
|
||||
|
||||
# Check if model exists in ir.model (installed)
|
||||
model_record = self.env['ir.model'].search([('model', '=', converted_model)], limit=1)
|
||||
if not model_record:
|
||||
raise ValidationError(_('Model "%s" does not exist.') % question.many2many_model)
|
||||
|
||||
# Check if model is accessible in environment
|
||||
try:
|
||||
self.env[converted_model]
|
||||
# Update field with technical name if conversion happened
|
||||
if converted_model != question.many2many_model:
|
||||
question.many2many_model = converted_model
|
||||
except KeyError:
|
||||
raise ValidationError(_('Model "%s" is not accessible or has incorrect name.') % question.many2many_model)
|
||||
|
||||
|
||||
# -------------------------
|
||||
# Answer Validations
|
||||
# -------------------------
|
||||
def validate_question(self, answer, comment=None):
|
||||
if self.question_type == 'color':
|
||||
return self._validate_color(answer)
|
||||
elif self.question_type == 'email':
|
||||
return self._validate_email(answer)
|
||||
elif self.question_type == 'url':
|
||||
return self._validate_url(answer)
|
||||
elif self.question_type == 'time':
|
||||
return self._validate_time(answer)
|
||||
elif self.question_type == 'range':
|
||||
return self._validate_range(answer)
|
||||
elif self.question_type == 'week':
|
||||
return self._validate_week(answer)
|
||||
elif self.question_type == 'password':
|
||||
return self._validate_password(answer)
|
||||
elif self.question_type == 'file':
|
||||
return self._validate_file(answer)
|
||||
elif self.question_type == 'signature':
|
||||
return self._validate_signature(answer)
|
||||
elif self.question_type == 'month':
|
||||
return self._validate_month(answer)
|
||||
elif self.question_type == 'address':
|
||||
return self._validate_address(answer)
|
||||
elif self.question_type == 'name':
|
||||
return self._validate_name(answer)
|
||||
elif self.question_type == 'many2one':
|
||||
return self._validate_many2one(answer)
|
||||
elif self.question_type == 'many2many':
|
||||
return self._validate_many2many(answer)
|
||||
return super().validate_question(answer, comment)
|
||||
|
||||
# Color validation
|
||||
def _validate_color(self, answer):
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
if answer and not re.match(r'^#[0-9A-Fa-f]{6}$', answer):
|
||||
return {self.id: _('Please select a valid color.')}
|
||||
return {}
|
||||
|
||||
# Email validation
|
||||
def _validate_email(self, answer):
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
email_regex = r'^[A-Za-z0-9._%+-]+@[A-Za-z0-9.-]+\.[A-Za-z]{2,}$'
|
||||
if answer and not re.match(email_regex, answer):
|
||||
return {self.id: _('Please enter a valid email address.')}
|
||||
return {}
|
||||
|
||||
# URL validation
|
||||
def _validate_url(self, answer):
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
url_regex = r'^https?://[^\s]+$'
|
||||
if answer and not re.match(url_regex, answer):
|
||||
return {self.id: _('Please enter a valid URL (e.g., https://example.com)')}
|
||||
return {}
|
||||
|
||||
# Time validation
|
||||
def _validate_time(self, answer):
|
||||
if self.constr_mandatory and not answer:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
if not answer:
|
||||
return {}
|
||||
try:
|
||||
time_obj = datetime.strptime(answer.strip(), "%H:%M")
|
||||
except ValueError:
|
||||
return {self.id: self.time_error_msg or _('Invalid time format (HH:MM).')}
|
||||
if self.time_validate:
|
||||
if self.time_min and time_obj < datetime.strptime(self.time_min, "%H:%M"):
|
||||
return {self.id: self.time_error_msg}
|
||||
if self.time_max and time_obj > datetime.strptime(self.time_max, "%H:%M"):
|
||||
return {self.id: self.time_error_msg}
|
||||
# Step check
|
||||
if self.time_step:
|
||||
min_time = datetime.strptime(self.time_min or "00:00", "%H:%M")
|
||||
diff_minutes = int((time_obj - min_time).total_seconds() / 60)
|
||||
if diff_minutes % self.time_step != 0:
|
||||
return {self.id: self.time_error_msg}
|
||||
return {}
|
||||
|
||||
# Range validation
|
||||
def _validate_range(self, answer):
|
||||
if answer is None and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
try:
|
||||
val = float(answer)
|
||||
if val < self.range_min or val > self.range_max:
|
||||
return {self.id: _('Value must be between %s and %s') % (self.range_min, self.range_max)}
|
||||
if ((val - self.range_min) % self.range_step) != 0:
|
||||
return {self.id: _('Value must respect the step of %s') % self.range_step}
|
||||
except (ValueError, TypeError):
|
||||
return {self.id: _('Please enter a valid number.')}
|
||||
return {}
|
||||
|
||||
# Week validation
|
||||
def _validate_week(self, answer):
|
||||
if not self.validate_week_entry:
|
||||
return {}
|
||||
errors = {}
|
||||
week_regex = r'^\d{4}-W\d{2}$'
|
||||
if not answer:
|
||||
if self.constr_mandatory:
|
||||
errors[self.id] = self.constr_error_msg or _('This question requires an answer.')
|
||||
return errors
|
||||
if not re.match(week_regex, answer):
|
||||
errors[self.id] = self.week_error_msg or _('Week must be in YYYY-WW format.')
|
||||
return errors
|
||||
|
||||
year, week = map(int, answer.split('-W'))
|
||||
|
||||
# Range validation - check min/max directly
|
||||
if self.week_min:
|
||||
min_year, min_week = map(int, self.week_min.split('-W'))
|
||||
if year < min_year or (year == min_year and week < min_week):
|
||||
errors[self.id] = self.week_error_msg or _('Week is before minimum allowed week.')
|
||||
return errors
|
||||
|
||||
if self.week_max:
|
||||
max_year, max_week = map(int, self.week_max.split('-W'))
|
||||
if year > max_year or (year == max_year and week > max_week):
|
||||
errors[self.id] = self.week_error_msg or _('Week is after maximum allowed week.')
|
||||
return errors
|
||||
|
||||
# Step validation - only if within range and min is set
|
||||
if self.week_min and self.week_step > 1:
|
||||
min_year, min_week = map(int, self.week_min.split('-W'))
|
||||
weeks_from_min = (year - min_year) * 52 + (week - min_week)
|
||||
if weeks_from_min % self.week_step != 0:
|
||||
errors[self.id] = self.week_error_msg or _('Week does not match step interval.')
|
||||
|
||||
return errors
|
||||
|
||||
# for password validation
|
||||
def _validate_password(self, answer):
|
||||
"""Server-side validation for password field"""
|
||||
if not self.validate_password:
|
||||
return {}
|
||||
|
||||
if not answer:
|
||||
if self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires a password.')}
|
||||
return {}
|
||||
|
||||
if not isinstance(answer, str):
|
||||
return {self.id: self.password_error_msg or _('Invalid password format.')}
|
||||
|
||||
length = len(answer.strip())
|
||||
|
||||
if self.password_min_length and length < self.password_min_length:
|
||||
return {self.id: self.password_error_msg or _('Password must be at least %s characters.') % self.password_min_length}
|
||||
|
||||
if self.password_max_length and length > self.password_max_length:
|
||||
return {self.id: self.password_error_msg or _('Password cannot exceed %s characters.') % self.password_max_length}
|
||||
|
||||
return {}
|
||||
|
||||
# for file validation
|
||||
def _validate_file(self, answer):
|
||||
"""File validation"""
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires a file upload.')}
|
||||
return {}
|
||||
|
||||
# for signature validation
|
||||
def _validate_signature(self, answer):
|
||||
"""Signature validation"""
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires a signature.')}
|
||||
# Check if it's a valid signature (base64 data URL or attachment ID)
|
||||
if answer and not (answer.startswith('data:image/') or answer.isdigit()):
|
||||
return {self.id: _('Invalid signature data.')}
|
||||
return {}
|
||||
|
||||
# for month validation
|
||||
def _validate_month(self, answer):
|
||||
"""Month validation - always validates format, range, and step"""
|
||||
month_regex = r'^\d{4}-\d{2}$'
|
||||
if not answer:
|
||||
if self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
return {}
|
||||
|
||||
# Always validate format
|
||||
if not re.match(month_regex, answer):
|
||||
return {self.id: self.month_error_msg or _('Month must be in YYYY-MM format.')}
|
||||
|
||||
# Always validate range/step (backend validation)
|
||||
# Range validation
|
||||
if self.month_min and answer < self.month_min:
|
||||
return {self.id: self.month_error_msg or _('Month is before minimum allowed month.')}
|
||||
|
||||
if self.month_max and answer > self.month_max:
|
||||
return {self.id: self.month_error_msg or _('Month is after maximum allowed month.')}
|
||||
|
||||
# Step validation
|
||||
if self.month_min and self.month_step > 1:
|
||||
min_year, min_month = map(int, self.month_min.split('-'))
|
||||
year, month = map(int, answer.split('-'))
|
||||
months_from_min = (year - min_year) * 12 + (month - min_month)
|
||||
if months_from_min % self.month_step != 0:
|
||||
return {self.id: self.month_error_msg or _('Month does not match step interval.')}
|
||||
|
||||
return {}
|
||||
|
||||
# Address validation
|
||||
def _validate_address(self, answer):
|
||||
"""Address validation - check if at least one enabled field is filled when mandatory"""
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
|
||||
if answer and self.constr_mandatory:
|
||||
# Parse JSON answer to check if at least one enabled field has value
|
||||
import json
|
||||
try:
|
||||
addr_data = json.loads(answer) if isinstance(answer, str) else answer
|
||||
enabled_fields = []
|
||||
if self.address_enable_street: enabled_fields.append('street')
|
||||
if self.address_enable_street2: enabled_fields.append('street2')
|
||||
if self.address_enable_zip: enabled_fields.append('zip')
|
||||
if self.address_enable_city: enabled_fields.append('city')
|
||||
if self.address_enable_state: enabled_fields.append('state')
|
||||
if self.address_enable_country: enabled_fields.append('country')
|
||||
|
||||
has_value = any(addr_data.get(field, '').strip() for field in enabled_fields)
|
||||
if not has_value:
|
||||
return {self.id: self.constr_error_msg or _('At least one address field must be filled.')}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return {self.id: _('Invalid address format.')}
|
||||
|
||||
return {}
|
||||
|
||||
# Name validation
|
||||
def _validate_name(self, answer):
|
||||
"""Name validation - check first and last name when mandatory"""
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
|
||||
if answer and self.constr_mandatory:
|
||||
import json
|
||||
try:
|
||||
name_data = json.loads(answer) if isinstance(answer, str) else answer
|
||||
first_name = name_data.get('first_name', '').strip()
|
||||
last_name = name_data.get('last_name', '').strip()
|
||||
middle_name = name_data.get('middle_name', '').strip()
|
||||
|
||||
if not first_name:
|
||||
return {self.id: self.constr_error_msg or _('First name is required.')}
|
||||
if not last_name:
|
||||
return {self.id: self.constr_error_msg or _('Last name is required.')}
|
||||
if not self.name_middle_optional and not middle_name:
|
||||
return {self.id: self.constr_error_msg or _('Middle name is required.')}
|
||||
except (json.JSONDecodeError, AttributeError):
|
||||
return {self.id: _('Invalid name format.')}
|
||||
|
||||
return {}
|
||||
|
||||
# Many2one validation
|
||||
def _validate_many2one(self, answer):
|
||||
"""Many2one validation - check if selected record exists"""
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
|
||||
if answer and self.many2one_model:
|
||||
try:
|
||||
record_id = int(answer)
|
||||
record = self.env[self.many2one_model].browse(record_id)
|
||||
if not record.exists():
|
||||
return {self.id: _('Selected record does not exist.')}
|
||||
except (ValueError, KeyError):
|
||||
return {self.id: _('Invalid record selection.')}
|
||||
|
||||
return {}
|
||||
|
||||
# Many2many validation
|
||||
def _validate_many2many(self, answer):
|
||||
"""Many2many validation - check if selected records exist"""
|
||||
if not answer and self.constr_mandatory:
|
||||
return {self.id: self.constr_error_msg or _('This question requires an answer.')}
|
||||
|
||||
if answer and self.many2many_model:
|
||||
try:
|
||||
# Answer should be comma-separated IDs or list
|
||||
if isinstance(answer, str):
|
||||
record_ids = [int(x.strip()) for x in answer.split(',') if x.strip()]
|
||||
else:
|
||||
record_ids = answer if isinstance(answer, list) else [answer]
|
||||
|
||||
for record_id in record_ids:
|
||||
record = self.env[self.many2many_model].browse(record_id)
|
||||
if not record.exists():
|
||||
return {self.id: _('Selected record does not exist.')}
|
||||
except (ValueError, KeyError):
|
||||
return {self.id: _('Invalid record selection.')}
|
||||
|
||||
return {}
|
||||
|
||||
def _convert_model_name(self, model_name):
|
||||
"""Convert display name to technical name by searching ir.model"""
|
||||
if not model_name:
|
||||
return model_name
|
||||
|
||||
# If already technical name, return as is
|
||||
try:
|
||||
self.env[model_name]
|
||||
return model_name
|
||||
except KeyError:
|
||||
pass
|
||||
|
||||
# Search for model by display name
|
||||
model_record = self.env['ir.model'].search([
|
||||
('name', '=', model_name)
|
||||
], limit=1)
|
||||
|
||||
if model_record:
|
||||
return model_record.model
|
||||
|
||||
return model_name
|
||||
@@ -0,0 +1,82 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
import logging
|
||||
from odoo import api, fields, models, _
|
||||
from odoo.exceptions import ValidationError
|
||||
|
||||
_logger = logging.getLogger(__name__)
|
||||
|
||||
class SurveySurvey(models.Model):
|
||||
_inherit = 'survey.survey'
|
||||
|
||||
enable_cron = fields.Boolean('Enable Cron')
|
||||
scheduled_date = fields.Datetime('Scheduled Date')
|
||||
cron_status = fields.Selection([
|
||||
('pending', 'In Progress'),
|
||||
('done', 'Done')
|
||||
], string='Cron Status', readonly=True, default='pending')
|
||||
existing_contact_ids = fields.Many2many('res.partner', string='Existing Contacts')
|
||||
|
||||
@api.constrains('enable_cron', 'scheduled_date', 'existing_contact_ids', 'access_mode')
|
||||
def _check_cron_access_mode(self):
|
||||
for survey in self:
|
||||
if survey.enable_cron and survey.access_mode != 'token':
|
||||
raise ValidationError(_('Enable Cron is only available when Access Mode is "Invited people only".'))
|
||||
|
||||
# Rule 1: If enable_cron is True, scheduled_date is mandatory
|
||||
if survey.enable_cron and not survey.scheduled_date:
|
||||
raise ValidationError(_('Scheduled Date is mandatory if "Enable Cron" is selected.'))
|
||||
|
||||
# Rule 2: If scheduled_date is set, at least one contact must be selected
|
||||
if survey.scheduled_date and not survey.existing_contact_ids:
|
||||
raise ValidationError(_('You must select at least one contact if Scheduled Date is set.'))
|
||||
|
||||
@api.model
|
||||
def _cron_send_scheduled_surveys(self):
|
||||
"""Cron job to send scheduled surveys using template rendering"""
|
||||
now = fields.Datetime.now()
|
||||
surveys = self.search([
|
||||
('enable_cron', '=', True),
|
||||
('cron_status', '=', 'pending'),
|
||||
('access_mode', '=', 'token'),
|
||||
])
|
||||
|
||||
# Get the default survey invite template
|
||||
mail_template = self.env.ref('survey.mail_template_user_input_invite', raise_if_not_found=True)
|
||||
|
||||
for survey in surveys:
|
||||
if not survey.scheduled_date or survey.scheduled_date <= now:
|
||||
if survey.existing_contact_ids:
|
||||
for partner in survey.existing_contact_ids:
|
||||
# Create or get survey.user_input for this partner
|
||||
user_input = self.env['survey.user_input'].sudo().search([
|
||||
('survey_id', '=', survey.id),
|
||||
('partner_id', '=', partner.id),
|
||||
], limit=1)
|
||||
if not user_input:
|
||||
user_input = self.env['survey.user_input'].sudo().create({
|
||||
'survey_id': survey.id,
|
||||
'partner_id': partner.id,
|
||||
'email': partner.email,
|
||||
})
|
||||
|
||||
# Send the email using the template
|
||||
mail_template.sudo().send_mail(user_input.id, force_send=True)
|
||||
_logger.info("Survey '%s' sent to %s via template.", survey.title, partner.email)
|
||||
|
||||
# Mark cron as done
|
||||
survey.write({'cron_status': 'done'})
|
||||
|
||||
def write(self, vals):
|
||||
if 'scheduled_date' in vals:
|
||||
for survey in self:
|
||||
if survey.cron_status == 'done' and vals.get('scheduled_date'):
|
||||
vals['cron_status'] = 'pending'
|
||||
return super().write(vals)
|
||||
|
||||
@api.onchange('access_mode')
|
||||
def _onchange_access_mode(self):
|
||||
if self.access_mode != 'token':
|
||||
self.enable_cron = False
|
||||
self.scheduled_date = False
|
||||
self.cron_status = 'pending'
|
||||
self.existing_contact_ids = False
|
||||
@@ -0,0 +1,33 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, _
|
||||
from odoo.exceptions import UserError
|
||||
|
||||
class SurveyUserInput(models.Model):
|
||||
_inherit = 'survey.user_input'
|
||||
|
||||
def _save_lines(self, question, answer, comment=None, overwrite_existing=True):
|
||||
"""Override to handle custom field types"""
|
||||
if question.question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']:
|
||||
# Handle custom field types as text (char_box)
|
||||
old_answers = self.env['survey.user_input.line'].search([
|
||||
('user_input_id', '=', self.id),
|
||||
('question_id', '=', question.id)
|
||||
])
|
||||
|
||||
# if old_answers and not overwrite_existing:
|
||||
# raise UserError(_("This answer cannot be overwritten."))
|
||||
|
||||
# Special handling for many2one to save model,id format
|
||||
if question.question_type == 'many2one' and answer and question.many2one_model:
|
||||
answer = f"{question.many2one_model},{answer}"
|
||||
|
||||
vals = self._get_line_answer_values(question, answer, 'char_box')
|
||||
|
||||
if old_answers:
|
||||
old_answers.write(vals)
|
||||
return old_answers
|
||||
else:
|
||||
return self.env['survey.user_input.line'].create(vals)
|
||||
|
||||
# fallback to super for other question types
|
||||
return super()._save_lines(question, answer, comment, overwrite_existing)
|
||||
@@ -0,0 +1,230 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from odoo import models, fields, api
|
||||
from markupsafe import Markup
|
||||
|
||||
class SurveyUserInputLine(models.Model):
|
||||
_inherit = 'survey.user_input.line'
|
||||
|
||||
file_display = fields.Html('File Display', compute='_compute_file_display')
|
||||
signature_display = fields.Html('Signature Display', compute='_compute_signature_display')
|
||||
many2one_display = fields.Html('Many2one Display', compute='_compute_many2one_display')
|
||||
many2many_display = fields.Html('Many2many Display', compute='_compute_many2many_display')
|
||||
extra_field_display = fields.Html('Extra Field Display', compute='_compute_extra_field_display')
|
||||
show_value_char_box = fields.Boolean('Show Value Char Box', compute='_compute_show_value_char_box')
|
||||
show_file_display = fields.Boolean('Show File Display', compute='_compute_show_file_display')
|
||||
show_signature_display = fields.Boolean('Show Signature Display', compute='_compute_show_signature_display')
|
||||
show_many2one_display = fields.Boolean('Show Many2one Display', compute='_compute_show_many2one_display')
|
||||
show_many2many_display = fields.Boolean('Show Many2many Display', compute='_compute_show_many2many_display')
|
||||
show_extra_field_display = fields.Boolean('Show Extra Field Display', compute='_compute_show_extra_field_display')
|
||||
answer_type_display = fields.Char('Answer Type Display', compute='_compute_answer_type_display')
|
||||
|
||||
@api.depends('value_char_box', 'question_id.question_type', 'answer_type')
|
||||
def _compute_file_display(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'file':
|
||||
if line.value_char_box and line.value_char_box.isdigit():
|
||||
attachment = self.env['ir.attachment'].sudo().browse(int(line.value_char_box))
|
||||
if attachment.exists():
|
||||
line.file_display = Markup(f'<a href="/web/content/{attachment.id}?download=true" target="_blank" class="text-primary text-decoration-underline">{attachment.name}</a>')
|
||||
else:
|
||||
line.file_display = 'File not found'
|
||||
else:
|
||||
line.file_display = line.value_char_box or 'No file'
|
||||
else:
|
||||
line.file_display = False
|
||||
|
||||
@api.depends('value_char_box', 'question_id.question_type', 'answer_type')
|
||||
def _compute_signature_display(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'signature':
|
||||
if line.value_char_box and line.value_char_box.startswith('data:image/'):
|
||||
line.signature_display = Markup(f'<img src="{line.value_char_box}" alt="Signature" style="max-width: 300px; border: 1px solid #ccc;"/>')
|
||||
else:
|
||||
line.signature_display = 'No signature'
|
||||
else:
|
||||
line.signature_display = False
|
||||
|
||||
@api.depends('value_char_box', 'question_id.question_type', 'answer_type')
|
||||
def _compute_many2one_display(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'many2one':
|
||||
if line.value_char_box and ',' in line.value_char_box:
|
||||
model_name, record_id = line.value_char_box.split(',', 1)
|
||||
try:
|
||||
record = self.env[model_name].sudo().browse(int(record_id))
|
||||
if record.exists():
|
||||
line.many2one_display = record.display_name
|
||||
else:
|
||||
line.many2one_display = 'Record not found'
|
||||
except:
|
||||
line.many2one_display = line.value_char_box
|
||||
else:
|
||||
line.many2one_display = line.value_char_box or 'No selection'
|
||||
else:
|
||||
line.many2one_display = False
|
||||
|
||||
@api.depends('value_char_box', 'question_id.question_type', 'answer_type')
|
||||
def _compute_many2many_display(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'many2many':
|
||||
if line.value_char_box and line.question_id.many2many_model:
|
||||
record_ids = line.value_char_box.split(',') if line.value_char_box else []
|
||||
names = []
|
||||
for record_id in record_ids:
|
||||
try:
|
||||
record = self.env[line.question_id.many2many_model].sudo().browse(int(record_id.strip()))
|
||||
if record.exists():
|
||||
names.append(record.display_name)
|
||||
except:
|
||||
continue
|
||||
line.many2many_display = ', '.join(names) if names else 'No selections'
|
||||
else:
|
||||
line.many2many_display = line.value_char_box or 'No selections'
|
||||
else:
|
||||
line.many2many_display = False
|
||||
|
||||
@api.depends('value_char_box', 'question_id.question_type', 'answer_type')
|
||||
def _compute_extra_field_display(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'month', 'address', 'name']:
|
||||
if line.question_id.question_type == 'color':
|
||||
line.extra_field_display = Markup(f'<div style="display: inline-block; width: 30px; height: 30px; background-color: {line.value_char_box}; border: 1px solid #ccc; border-radius: 4px;"></div> {line.value_char_box}')
|
||||
elif line.question_id.question_type == 'address':
|
||||
try:
|
||||
import json
|
||||
addr_data = json.loads(line.value_char_box) if line.value_char_box else {}
|
||||
parts = []
|
||||
if addr_data.get('street'): parts.append(f"<strong>Street:</strong> {addr_data['street']}")
|
||||
if addr_data.get('street2'): parts.append(f"<strong>Street2:</strong> {addr_data['street2']}")
|
||||
if addr_data.get('zip'): parts.append(f"<strong>Zip:</strong> {addr_data['zip']}")
|
||||
if addr_data.get('city'): parts.append(f"<strong>City:</strong> {addr_data['city']}")
|
||||
if addr_data.get('state'): parts.append(f"<strong>State:</strong> {addr_data['state']}")
|
||||
if addr_data.get('country'): parts.append(f"<strong>Country:</strong> {addr_data['country']}")
|
||||
line.extra_field_display = Markup('<br/>'.join(parts)) if parts else line.value_char_box or 'No address'
|
||||
except:
|
||||
line.extra_field_display = line.value_char_box or 'No address'
|
||||
elif line.question_id.question_type == 'name':
|
||||
try:
|
||||
import json
|
||||
name_data = json.loads(line.value_char_box) if line.value_char_box else {}
|
||||
parts = []
|
||||
if name_data.get('first_name'): parts.append(f"<strong>First Name:</strong> {name_data['first_name']}")
|
||||
if name_data.get('middle_name'): parts.append(f"<strong>Middle Name:</strong> {name_data['middle_name']}")
|
||||
if name_data.get('last_name'): parts.append(f"<strong>Last Name:</strong> {name_data['last_name']}")
|
||||
line.extra_field_display = Markup('<br/>'.join(parts)) if parts else line.value_char_box or 'No name'
|
||||
except:
|
||||
line.extra_field_display = line.value_char_box or 'No name'
|
||||
else:
|
||||
line.extra_field_display = line.value_char_box or ''
|
||||
else:
|
||||
line.extra_field_display = False
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_show_value_char_box(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']:
|
||||
line.show_value_char_box = False
|
||||
else:
|
||||
line.show_value_char_box = True
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_show_file_display(self):
|
||||
for line in self:
|
||||
line.show_file_display = (line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'file')
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_show_signature_display(self):
|
||||
for line in self:
|
||||
line.show_signature_display = (line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'signature')
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_show_many2one_display(self):
|
||||
for line in self:
|
||||
line.show_many2one_display = (line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'many2one')
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_show_many2many_display(self):
|
||||
for line in self:
|
||||
line.show_many2many_display = (line.answer_type == 'char_box' and line.question_id and line.question_id.question_type == 'many2many')
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_show_extra_field_display(self):
|
||||
for line in self:
|
||||
line.show_extra_field_display = (line.answer_type == 'char_box' and line.question_id and line.question_id.question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'month', 'address', 'name'])
|
||||
|
||||
@api.depends('answer_type', 'question_id.question_type')
|
||||
def _compute_answer_type_display(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']:
|
||||
type_mapping = {
|
||||
'color': 'Color',
|
||||
'email': 'Email',
|
||||
'url': 'URL',
|
||||
'time': 'Time',
|
||||
'range': 'Range',
|
||||
'week': 'Week',
|
||||
'password': 'Password',
|
||||
'file': 'File',
|
||||
'signature': 'Signature',
|
||||
'month': 'Month',
|
||||
'address': 'Address',
|
||||
'name': 'Name',
|
||||
'many2one': 'Selection',
|
||||
'many2many': 'Multiple Selection'
|
||||
}
|
||||
line.answer_type_display = type_mapping.get(line.question_id.question_type, 'Text')
|
||||
else:
|
||||
type_mapping = {
|
||||
'text_box': 'Free Text',
|
||||
'char_box': 'Text',
|
||||
'numerical_box': 'Number',
|
||||
'scale': 'Number',
|
||||
'date': 'Date',
|
||||
'datetime': 'Datetime',
|
||||
'suggestion': 'Suggestion'
|
||||
}
|
||||
line.answer_type_display = type_mapping.get(line.answer_type, line.answer_type or '')
|
||||
|
||||
@api.depends(
|
||||
'answer_type', 'value_text_box', 'value_numerical_box',
|
||||
'value_char_box', 'value_date', 'value_datetime',
|
||||
'suggested_answer_id.value', 'matrix_row_id.value',
|
||||
'question_id.question_type'
|
||||
)
|
||||
def _compute_display_name(self):
|
||||
for line in self:
|
||||
if line.answer_type == 'char_box' and line.question_id and line.question_id.question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']:
|
||||
if line.question_id.question_type == 'file':
|
||||
if line.value_char_box and line.value_char_box.isdigit():
|
||||
attachment = self.env['ir.attachment'].sudo().browse(int(line.value_char_box))
|
||||
line.display_name = attachment.name if attachment.exists() else 'File not found'
|
||||
else:
|
||||
line.display_name = line.value_char_box or 'No file'
|
||||
elif line.question_id.question_type == 'many2one':
|
||||
if line.value_char_box and ',' in line.value_char_box:
|
||||
model_name, record_id = line.value_char_box.split(',', 1)
|
||||
try:
|
||||
record = self.env[model_name].sudo().browse(int(record_id))
|
||||
line.display_name = record.display_name if record.exists() else 'Record not found'
|
||||
except:
|
||||
line.display_name = line.value_char_box
|
||||
else:
|
||||
line.display_name = line.value_char_box or 'No selection'
|
||||
elif line.question_id.question_type == 'many2many':
|
||||
if line.value_char_box and line.question_id.many2many_model:
|
||||
record_ids = line.value_char_box.split(',') if line.value_char_box else []
|
||||
names = []
|
||||
for record_id in record_ids:
|
||||
try:
|
||||
record = self.env[line.question_id.many2many_model].sudo().browse(int(record_id.strip()))
|
||||
if record.exists():
|
||||
names.append(record.display_name)
|
||||
except:
|
||||
continue
|
||||
line.display_name = ', '.join(names) if names else 'No selections'
|
||||
else:
|
||||
line.display_name = line.value_char_box or 'No selections'
|
||||
else:
|
||||
line.display_name = line.value_char_box or ''
|
||||
else:
|
||||
super(SurveyUserInputLine, line)._compute_display_name()
|
||||
@@ -0,0 +1,2 @@
|
||||
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
|
||||
access_survey_extra_fields_survey_extra_fields,survey_extra_fields.survey_extra_fields,model_survey_extra_fields_survey_extra_fields,base.group_user,1,1,1,1
|
||||
|
@@ -0,0 +1,595 @@
|
||||
/** @odoo-module */
|
||||
|
||||
import { registry } from "@web/core/registry";
|
||||
|
||||
const interactions = registry.category("public.interactions");
|
||||
|
||||
function applyPatchTo(SurveyForm) {
|
||||
// Helper to initialize address and name fields using jQuery scoped to this.el
|
||||
function _initializeAddressFields() {
|
||||
const $root = $(this.el);
|
||||
$root.find('[data-question-type="address"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const existingData = $hiddenInput.val();
|
||||
if (existingData) {
|
||||
try {
|
||||
const addressData = JSON.parse(existingData);
|
||||
const $container = $hiddenInput.closest('.o_survey_answer_wrapper').find('.address-fields');
|
||||
$container.find('.address-street').val(addressData.street || '');
|
||||
$container.find('.address-street2').val(addressData.street2 || '');
|
||||
$container.find('.address-zip').val(addressData.zip || '');
|
||||
$container.find('.address-city').val(addressData.city || '');
|
||||
$container.find('.address-state').val(addressData.state || '');
|
||||
$container.find('.address-country').val(addressData.country || '');
|
||||
} catch (e) {
|
||||
console.error('Error parsing address data:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$root.find('[data-question-type="name"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const existingData = $hiddenInput.val();
|
||||
if (existingData) {
|
||||
try {
|
||||
const nameData = JSON.parse(existingData);
|
||||
const $container = $hiddenInput.closest('.o_survey_answer_wrapper').find('.name-fields');
|
||||
$container.find('.name-first').val(nameData.first_name || '');
|
||||
$container.find('.name-middle').val(nameData.middle_name || '');
|
||||
$container.find('.name-last').val(nameData.last_name || '');
|
||||
} catch (e) {
|
||||
console.error('Error parsing name data:', e);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
$root.find('.o_survey_question_many2many').off('change.custom_many2many').on('change.custom_many2many', function() {
|
||||
const selectedIds = Array.from(this.selectedOptions).map(option => option.value).filter(id => id);
|
||||
$(this).siblings('.many2many-data').val(selectedIds.join(','));
|
||||
});
|
||||
}
|
||||
|
||||
function displayErrors(ctx, errors) {
|
||||
// Prefer built-in methods if present (showErrors or _showErrors)
|
||||
if (typeof ctx.showErrors === 'function') {
|
||||
ctx.showErrors(errors);
|
||||
} else if (typeof ctx._showErrors === 'function') {
|
||||
ctx._showErrors(errors);
|
||||
} else {
|
||||
// fallback: simple inline display or alert
|
||||
// Try to mark elements with error class if possible
|
||||
try {
|
||||
Object.keys(errors).forEach(function (qid) {
|
||||
const msg = errors[qid];
|
||||
const $el = $('#' + qid);
|
||||
if ($el.length) {
|
||||
$el.addClass('o_survey_error');
|
||||
// append small error block if not present
|
||||
if ($el.find('.o_survey_inline_error').length === 0) {
|
||||
$el.append($('<div class="o_survey_inline_error"/>').text(msg));
|
||||
} else {
|
||||
$el.find('.o_survey_inline_error').text(msg);
|
||||
}
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
// last resort
|
||||
alert(Object.values(errors).join("\n"));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Wrap start
|
||||
const _origStart = SurveyForm.prototype.start;
|
||||
SurveyForm.prototype.start = function () {
|
||||
const res = _origStart && _origStart.apply(this, arguments);
|
||||
try {
|
||||
_initializeAddressFields.call(this);
|
||||
} catch (e) {
|
||||
console.error('Error in custom survey start initialiser:', e);
|
||||
}
|
||||
return res;
|
||||
};
|
||||
|
||||
// Wrap prepareSubmitValues
|
||||
const _origPrepare = SurveyForm.prototype.prepareSubmitValues;
|
||||
SurveyForm.prototype.prepareSubmitValues = function (formData, params) {
|
||||
_origPrepare && _origPrepare.call(this, formData, params);
|
||||
const $root = $(this.el);
|
||||
|
||||
$root.find('[data-question-type="color"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="email"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="url"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="time"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="range"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="week"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="password"]').each(function () { params[this.name] = this.value; });
|
||||
$root.find('[data-question-type="signature"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const signatureData = $hiddenInput.val();
|
||||
if (signatureData && signatureData.startsWith('data:image/')) {
|
||||
params[this.name] = signatureData;
|
||||
}
|
||||
});
|
||||
$root.find('[data-question-type="month"]').each(function () { params[this.name] = this.value; });
|
||||
|
||||
$root.find('[data-question-type="address"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const $addressContainer = $hiddenInput.closest('.o_survey_answer_wrapper').find('.address-fields');
|
||||
const addressData = {
|
||||
street: $addressContainer.find('.address-street').val() || '',
|
||||
street2: $addressContainer.find('.address-street2').val() || '',
|
||||
zip: $addressContainer.find('.address-zip').val() || '',
|
||||
city: $addressContainer.find('.address-city').val() || '',
|
||||
state: $addressContainer.find('.address-state').val() || '',
|
||||
country: $addressContainer.find('.address-country').val() || ''
|
||||
};
|
||||
params[this.name] = JSON.stringify(addressData);
|
||||
});
|
||||
|
||||
$root.find('[data-question-type="name"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const $nameContainer = $hiddenInput.closest('.o_survey_answer_wrapper').find('.name-fields');
|
||||
const nameData = {
|
||||
first_name: $nameContainer.find('.name-first').val() || '',
|
||||
middle_name: $nameContainer.find('.name-middle').val() || '',
|
||||
last_name: $nameContainer.find('.name-last').val() || ''
|
||||
};
|
||||
params[this.name] = JSON.stringify(nameData);
|
||||
});
|
||||
|
||||
$root.find('[data-question-type="many2one"]').each(function () { params[this.name] = this.value; });
|
||||
|
||||
$root.find('[data-question-type="many2many"]').each(function () {
|
||||
const selectedIds = Array.from(this.selectedOptions).map(option => option.value).filter(id => id);
|
||||
params[this.name] = selectedIds.join(',');
|
||||
});
|
||||
|
||||
$root.find('[data-question-type="file"]').each(function () {
|
||||
const $input = $(this);
|
||||
const files = $input[0].files;
|
||||
if (files && files.length > 0) {
|
||||
const fd = new FormData();
|
||||
fd.append('file', files[0]);
|
||||
// Keep synchronous ajax for parity with original behaviour
|
||||
$.ajax({
|
||||
url: '/survey/upload_file',
|
||||
type: 'POST',
|
||||
data: fd,
|
||||
processData: false,
|
||||
contentType: false,
|
||||
async: false
|
||||
}).done(function(response) {
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(response);
|
||||
} catch (e) {
|
||||
result = response;
|
||||
}
|
||||
if (result && result.attachment_id) {
|
||||
// original code used data-question-id to set
|
||||
params[$input.data('question-id')] = result.attachment_id;
|
||||
}
|
||||
}).fail(function (jqXHR, status, err) {
|
||||
console.error('File upload failed:', status, err);
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
return params;
|
||||
};
|
||||
|
||||
// Wrap validateForm
|
||||
const _origValidate = SurveyForm.prototype.validateForm;
|
||||
SurveyForm.prototype.validateForm = function (formEl, formData) {
|
||||
const origResult = _origValidate && _origValidate.call(this, formEl, formData);
|
||||
// If original validation failed, propagate false (keep original behavior)
|
||||
if (origResult === false) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const $form = $(formEl);
|
||||
const errors = {};
|
||||
|
||||
// Color fields
|
||||
$form.find('[data-question-type="color"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
if (questionRequired && !$input.val()) {
|
||||
errors[questionId] = 'Please select a color.';
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select a color.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
}
|
||||
});
|
||||
|
||||
// Email fields
|
||||
$form.find('[data-question-type="email"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const value = ($input.val() || '').trim();
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
|
||||
if (questionRequired && !value) {
|
||||
errors[questionId] = 'Please enter an email address.';
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please enter an email address.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
} else if (value && !emailRegex.test(value)) {
|
||||
errors[questionId] = 'Please enter a valid email address (e.g., user@example.com).';
|
||||
}
|
||||
});
|
||||
|
||||
// URL fields
|
||||
$form.find('[data-question-type="url"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const value = ($input.val() || '').trim();
|
||||
const urlRegex = /^https?:\/\/[^\s]+$/;
|
||||
if (questionRequired && !value) {
|
||||
errors[questionId] = 'Please enter a URL.';
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please enter a URL.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
} else if (value && !urlRegex.test(value)) {
|
||||
errors[questionId] = 'Please enter a valid URL starting with http:// or https:// (e.g., https://example.com).';
|
||||
}
|
||||
});
|
||||
|
||||
// Time fields with min/max/step
|
||||
$form.find('[data-question-type="time"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const value = $input.val();
|
||||
const validateTime = $input.data('validate-time');
|
||||
const step = parseInt($input.data('time-step'));
|
||||
const min = $input.data('time-min');
|
||||
const max = $input.data('time-max');
|
||||
|
||||
if (questionRequired && !value) {
|
||||
errors[questionId] = "Please select a time.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select a time.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
return;
|
||||
}
|
||||
if (value && validateTime) {
|
||||
const timeParts = value.split(':');
|
||||
if (timeParts.length !== 2) {
|
||||
errors[questionId] = "Please enter a valid time format (HH:MM).";
|
||||
return;
|
||||
}
|
||||
const hours = parseInt(timeParts[0], 10);
|
||||
const minutes = parseInt(timeParts[1], 10);
|
||||
|
||||
let minParts, maxParts;
|
||||
if (min) {
|
||||
minParts = min.split(':');
|
||||
if (hours < parseInt(minParts[0], 10) || (hours === parseInt(minParts[0], 10) && minutes < parseInt(minParts[1], 10))) {
|
||||
errors[questionId] = "Time must be after " + min + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
if (max) {
|
||||
maxParts = max.split(':');
|
||||
if (hours > parseInt(maxParts[0], 10) || (hours === parseInt(maxParts[0], 10) && minutes > parseInt(maxParts[1], 10))) {
|
||||
errors[questionId] = "Time must be before " + max + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
if (step && min) {
|
||||
const minTime = parseInt(minParts[0], 10) * 60 + parseInt(minParts[1], 10);
|
||||
const valueTime = hours * 60 + minutes;
|
||||
if ((valueTime - minTime) % step !== 0) {
|
||||
errors[questionId] = "Please select time in " + step + " minute intervals from " + min + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Range fields
|
||||
$form.find('[data-question-type="range"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const validateRange = $questionWrapper.data('validateRange');
|
||||
|
||||
const val = parseFloat($input.val());
|
||||
const min = parseFloat($input.attr('min'));
|
||||
const max = parseFloat($input.attr('max'));
|
||||
const step = parseFloat($input.attr('step') || 1);
|
||||
|
||||
if (questionRequired && !$input.val()) {
|
||||
errors[questionId] = "Please select a value.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select a value.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
} else if (validateRange && $input.val()) {
|
||||
if (!isNaN(min) && val < min || !isNaN(max) && val > max) {
|
||||
errors[questionId] = "Value must be between " + min + " and " + max + ".";
|
||||
} else {
|
||||
// step check (allow float rounding)
|
||||
if (step && !isNaN(min)) {
|
||||
const diff = (val - min) / step;
|
||||
const near = Math.round(diff);
|
||||
const eps = 1e-9;
|
||||
if (Math.abs(diff - near) > eps) {
|
||||
errors[questionId] = "Value must be in steps of " + step + " from " + min + ".";
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Week field validation
|
||||
$form.find('[data-question-type="week"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const minWeek = $input.data('weekMin');
|
||||
const maxWeek = $input.data('weekMax');
|
||||
const step = parseInt($input.data('weekStep') || 1, 10);
|
||||
const value = ($input.val() || '').trim();
|
||||
|
||||
if (questionRequired && !value) {
|
||||
errors[questionId] = "Please select a week.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select a week.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && minWeek && maxWeek) {
|
||||
const valParts = value.split('-W');
|
||||
const minParts = minWeek.split('-W');
|
||||
const maxParts = maxWeek.split('-W');
|
||||
|
||||
const valYear = parseInt(valParts[0], 10);
|
||||
const valWeek = parseInt(valParts[1], 10);
|
||||
const minYear = parseInt(minParts[0], 10);
|
||||
const minWeekNum = parseInt(minParts[1], 10);
|
||||
const maxYear = parseInt(maxParts[0], 10);
|
||||
const maxWeekNum = parseInt(maxParts[1], 10);
|
||||
|
||||
if (valYear < minYear || (valYear === minYear && valWeek < minWeekNum) ||
|
||||
valYear > maxYear || (valYear === maxYear && valWeek > maxWeekNum)) {
|
||||
errors[questionId] = "Please select a week between " + minWeek + " and " + maxWeek + ".";
|
||||
return;
|
||||
}
|
||||
|
||||
if (step > 1) {
|
||||
const diffWeeks = (valYear - minYear) * 52 + (valWeek - minWeekNum);
|
||||
if (diffWeeks % step !== 0) {
|
||||
errors[questionId] = "Please select a week in steps of " + step + " from " + minWeek + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Password fields
|
||||
$form.find('[data-question-type="password"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const required = $questionWrapper.data('required');
|
||||
const validate = $input.data('validate-password');
|
||||
const minLength = parseInt($input.data('password-min') || 0, 10);
|
||||
const maxLength = parseInt($input.data('password-max') || 4096, 10);
|
||||
|
||||
const val = $input.val() || '';
|
||||
|
||||
if (required && !val) {
|
||||
errors[questionId] = "Please enter a password.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please enter a password.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
} else if (validate && val) {
|
||||
if (val.length < minLength) {
|
||||
errors[questionId] = "Password must be at least " + minLength + " characters long.";
|
||||
} else if (val.length > maxLength) {
|
||||
errors[questionId] = "Password cannot exceed " + maxLength + " characters.";
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// File fields validation (size & allowed types)
|
||||
$form.find('[data-question-type="file"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const maxSize = parseFloat($input.data('max-size')) || 10; // MB
|
||||
const allowedTypes = $input.data('allowed-types'); // comma separated exts
|
||||
const files = $input[0].files;
|
||||
|
||||
if (questionRequired && (!files || files.length === 0)) {
|
||||
errors[questionId] = "Please select a file.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select a file.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
return;
|
||||
}
|
||||
|
||||
if (files && files.length > 0) {
|
||||
const file = files[0];
|
||||
const fileSizeMB = file.size / (1024 * 1024);
|
||||
|
||||
if (fileSizeMB > maxSize) {
|
||||
errors[questionId] = "File size must not exceed " + maxSize + " MB.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (allowedTypes) {
|
||||
const fileExt = (file.name.split('.').pop() || '').toLowerCase();
|
||||
const allowed = allowedTypes.toLowerCase().split(',').map(s => s.trim());
|
||||
if (!allowed.includes(fileExt)) {
|
||||
errors[questionId] = "Only " + allowedTypes + " files are allowed.";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Month fields
|
||||
$form.find('[data-question-type="month"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const validateEntry = $input.data('validate-month-entry');
|
||||
const minMonth = $input.data('month-min');
|
||||
const maxMonth = $input.data('month-max');
|
||||
const step = parseInt($input.data('month-step') || 1, 10);
|
||||
const value = ($input.val() || '').trim();
|
||||
|
||||
if (questionRequired && !value) {
|
||||
errors[questionId] = "Please select a month.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select a month.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
return;
|
||||
}
|
||||
|
||||
if (value && validateEntry) {
|
||||
const monthRegex = /^\d{4}-\d{2}$/;
|
||||
if (!monthRegex.test(value)) {
|
||||
errors[questionId] = "Please enter a valid month in YYYY-MM format.";
|
||||
return;
|
||||
}
|
||||
|
||||
if (minMonth && value < minMonth) {
|
||||
errors[questionId] = "Please select a month after " + minMonth + ".";
|
||||
return;
|
||||
}
|
||||
|
||||
if (maxMonth && value > maxMonth) {
|
||||
errors[questionId] = "Please select a month before " + maxMonth + ".";
|
||||
return;
|
||||
}
|
||||
|
||||
if (step > 1 && minMonth) {
|
||||
const minParts = minMonth.split('-');
|
||||
const valParts = value.split('-');
|
||||
const minYear = parseInt(minParts[0], 10);
|
||||
const minMonthNum = parseInt(minParts[1], 10);
|
||||
const valYear = parseInt(valParts[0], 10);
|
||||
const valMonthNum = parseInt(valParts[1], 10);
|
||||
|
||||
const monthsFromMin = (valYear - minYear) * 12 + (valMonthNum - minMonthNum);
|
||||
if (monthsFromMin % step !== 0) {
|
||||
errors[questionId] = "Please select a month in steps of " + step + " from " + minMonth + ".";
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Address fields required check (at least one non-empty)
|
||||
$form.find('[data-question-type="address"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const $questionWrapper = $hiddenInput.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const $addressContainer = $hiddenInput.closest('.o_survey_answer_wrapper').find('.address-fields');
|
||||
|
||||
if (questionRequired) {
|
||||
let hasValue = false;
|
||||
$addressContainer.find('input[type="text"]').each(function() {
|
||||
if ($(this).val().trim()) {
|
||||
hasValue = true;
|
||||
return false;
|
||||
}
|
||||
});
|
||||
|
||||
if (!hasValue) {
|
||||
errors[questionId] = "At least one address field must be filled.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "At least one address field must be filled.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// Name fields required check (first & last, middle optional)
|
||||
$form.find('[data-question-type="name"]').each(function () {
|
||||
const $hiddenInput = $(this);
|
||||
const $questionWrapper = $hiddenInput.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
const middleOptional = $hiddenInput.data('middle-optional');
|
||||
const $nameContainer = $hiddenInput.closest('.o_survey_answer_wrapper').find('.name-fields');
|
||||
|
||||
if (questionRequired) {
|
||||
const firstName = ($nameContainer.find('.name-first').val() || '').trim();
|
||||
const lastName = ($nameContainer.find('.name-last').val() || '').trim();
|
||||
const middleName = ($nameContainer.find('.name-middle').val() || '').trim();
|
||||
|
||||
if (!firstName) {
|
||||
errors[questionId] = "First name is required.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "First name is required.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
} else if (!lastName) {
|
||||
errors[questionId] = "Last name is required.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Last name is required.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
} else if (!middleOptional && !middleName) {
|
||||
errors[questionId] = "Middle name is required.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Middle name is required.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// many2one required
|
||||
$form.find('[data-question-type="many2one"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
|
||||
if (questionRequired && !$input.val()) {
|
||||
errors[questionId] = "Please select an option.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select an option.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
}
|
||||
});
|
||||
|
||||
// many2many required
|
||||
$form.find('[data-question-type="many2many"]').each(function () {
|
||||
const $input = $(this);
|
||||
const $questionWrapper = $input.closest(".js_question-wrapper");
|
||||
const questionId = $questionWrapper.attr('id');
|
||||
const questionRequired = $questionWrapper.data('required');
|
||||
|
||||
const val = $input.val() || [];
|
||||
if (questionRequired && val.length === 0) {
|
||||
errors[questionId] = "Please select at least one option.";
|
||||
var customErrorMsg = $questionWrapper.data('required-error') || "Please select at least one option.";
|
||||
errors[questionId] = customErrorMsg;
|
||||
}
|
||||
});
|
||||
|
||||
if (Object.keys(errors).length > 0) {
|
||||
displayErrors(this, errors);
|
||||
return false;
|
||||
}
|
||||
|
||||
return origResult;
|
||||
};
|
||||
}
|
||||
|
||||
// If the interaction is already registered, patch it now. Otherwise wait
|
||||
// for the public.interactions registry to be updated.
|
||||
if (interactions.contains("survey.SurveyForm")) {
|
||||
applyPatchTo(interactions.get("survey.SurveyForm"));
|
||||
} else {
|
||||
const handler = (ev) => {
|
||||
if (interactions.contains("survey.SurveyForm")) {
|
||||
interactions.removeEventListener("UPDATE", handler);
|
||||
applyPatchTo(interactions.get("survey.SurveyForm"));
|
||||
}
|
||||
};
|
||||
interactions.addEventListener("UPDATE", handler);
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
function loadSelect2() {
|
||||
if (!document.querySelector('link[href*="select2"]')) {
|
||||
const css = document.createElement('link');
|
||||
css.rel = 'stylesheet';
|
||||
css.href = 'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/css/select2.min.css';
|
||||
document.head.appendChild(css);
|
||||
}
|
||||
|
||||
if (typeof jQuery !== 'undefined' && !jQuery.fn.select2) {
|
||||
const script = document.createElement('script');
|
||||
script.src = 'https://cdn.jsdelivr.net/npm/select2@4.1.0-rc.0/dist/js/select2.min.js';
|
||||
script.onload = () => setTimeout(initSelect2, 100);
|
||||
document.head.appendChild(script);
|
||||
} else if (typeof jQuery !== 'undefined' && jQuery.fn.select2) {
|
||||
initSelect2();
|
||||
} else {
|
||||
setTimeout(loadSelect2, 100);
|
||||
}
|
||||
}
|
||||
|
||||
function initSelect2() {
|
||||
if (typeof jQuery === 'undefined' || !jQuery.fn.select2) {
|
||||
setTimeout(initSelect2, 100);
|
||||
return;
|
||||
}
|
||||
|
||||
jQuery('.many2many-select2:not(.select2-hidden-accessible)').select2({
|
||||
placeholder: "Select one or more options",
|
||||
allowClear: true,
|
||||
closeOnSelect: false,
|
||||
width: '100%'
|
||||
}).on('change', function() {
|
||||
const values = jQuery(this).val() || [];
|
||||
jQuery(this).closest('.o_survey_answer_wrapper').find('.many2many-data').val(values.join(','));
|
||||
}).trigger('change');
|
||||
|
||||
jQuery('.many2one-select2:not(.select2-hidden-accessible)').select2({
|
||||
placeholder: "-- Select an option --",
|
||||
allowClear: true,
|
||||
width: '100%'
|
||||
});
|
||||
}
|
||||
|
||||
if (typeof jQuery !== 'undefined') {
|
||||
jQuery(document).ready(loadSelect2);
|
||||
|
||||
new MutationObserver(mutations => {
|
||||
if (mutations.some(m => m.addedNodes.length &&
|
||||
Array.from(m.addedNodes).some(n => n.nodeType === 1 &&
|
||||
(n.querySelector('.many2many-select2') || n.querySelector('.many2one-select2'))))) {
|
||||
setTimeout(initSelect2, 50);
|
||||
}
|
||||
}).observe(document.body, { childList: true, subtree: true });
|
||||
} else {
|
||||
setTimeout(loadSelect2, 100);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
odoo.define('survey_extra_fields.survey_range_field', [], function (require) {
|
||||
'use strict';
|
||||
|
||||
$(document).ready(function() {
|
||||
$(document).on('input', '.o_survey_question_range', function() {
|
||||
var $range = $(this);
|
||||
var $valueDisplay = $range.closest('.o_survey_answer_wrapper').find('.range-value');
|
||||
$valueDisplay.text($range.val());
|
||||
});
|
||||
|
||||
// Initialize value on page load
|
||||
$('.o_survey_question_range').each(function() {
|
||||
var $range = $(this);
|
||||
var $valueDisplay = $range.closest('.o_survey_answer_wrapper').find('.range-value');
|
||||
$valueDisplay.text($range.val());
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,107 @@
|
||||
(function() {
|
||||
'use strict';
|
||||
|
||||
function initSignaturePads() {
|
||||
document.querySelectorAll('.signature-pad').forEach(function(canvas) {
|
||||
if (canvas.dataset.initialized) return;
|
||||
canvas.dataset.initialized = 'true';
|
||||
|
||||
var ctx = canvas.getContext('2d');
|
||||
var drawing = false;
|
||||
var hiddenInput = canvas.closest('.o_survey_answer_wrapper').querySelector('.signature-data');
|
||||
|
||||
// Set drawing style
|
||||
ctx.strokeStyle = '#000';
|
||||
ctx.lineWidth = 2;
|
||||
ctx.lineCap = 'round';
|
||||
ctx.lineJoin = 'round';
|
||||
|
||||
// Clear canvas
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
|
||||
// Load existing signature if available
|
||||
var existingData = hiddenInput.value;
|
||||
if (existingData && existingData.startsWith('data:image/')) {
|
||||
var img = new Image();
|
||||
img.onload = function() {
|
||||
ctx.drawImage(img, 0, 0);
|
||||
};
|
||||
img.src = existingData;
|
||||
}
|
||||
|
||||
function getMousePos(e) {
|
||||
var rect = canvas.getBoundingClientRect();
|
||||
return {
|
||||
x: e.clientX - rect.left,
|
||||
y: e.clientY - rect.top
|
||||
};
|
||||
}
|
||||
|
||||
canvas.addEventListener('mousedown', function(e) {
|
||||
drawing = true;
|
||||
var pos = getMousePos(e);
|
||||
ctx.beginPath();
|
||||
ctx.moveTo(pos.x, pos.y);
|
||||
});
|
||||
|
||||
canvas.addEventListener('mousemove', function(e) {
|
||||
if (!drawing) return;
|
||||
var pos = getMousePos(e);
|
||||
ctx.lineTo(pos.x, pos.y);
|
||||
ctx.stroke();
|
||||
});
|
||||
|
||||
function saveSignature() {
|
||||
if (drawing) {
|
||||
drawing = false;
|
||||
hiddenInput.value = canvas.toDataURL('image/png');
|
||||
}
|
||||
}
|
||||
|
||||
canvas.addEventListener('mouseup', saveSignature);
|
||||
canvas.addEventListener('mouseout', saveSignature);
|
||||
});
|
||||
|
||||
// Handle clear buttons
|
||||
document.querySelectorAll('.clear-signature').forEach(function(btn) {
|
||||
if (btn.dataset.initialized) return;
|
||||
btn.dataset.initialized = 'true';
|
||||
|
||||
btn.addEventListener('click', function(e) {
|
||||
e.preventDefault();
|
||||
var container = btn.closest('.signature-container');
|
||||
var canvas = container.querySelector('.signature-pad');
|
||||
var hiddenInput = container.closest('.o_survey_answer_wrapper').querySelector('.signature-data');
|
||||
|
||||
if (canvas) {
|
||||
var ctx = canvas.getContext('2d');
|
||||
ctx.fillStyle = 'white';
|
||||
ctx.fillRect(0, 0, canvas.width, canvas.height);
|
||||
hiddenInput.value = '';
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// Initialize on DOM ready
|
||||
if (document.readyState === 'loading') {
|
||||
document.addEventListener('DOMContentLoaded', initSignaturePads);
|
||||
} else {
|
||||
initSignaturePads();
|
||||
}
|
||||
|
||||
// Re-initialize when new content is added (for dynamic content)
|
||||
var observer = new MutationObserver(function(mutations) {
|
||||
mutations.forEach(function(mutation) {
|
||||
if (mutation.addedNodes.length) {
|
||||
initSignaturePads();
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
observer.observe(document.body, {
|
||||
childList: true,
|
||||
subtree: true
|
||||
});
|
||||
})();
|
||||
@@ -0,0 +1,199 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<record id="survey_question_form_view_inherit" model="ir.ui.view">
|
||||
<field name="name">survey.question.form.inherit</field>
|
||||
<field name="model">survey.question</field>
|
||||
<field name="inherit_id" ref="survey.survey_question_form"/>
|
||||
<field name="arch" type="xml">
|
||||
|
||||
<!-- Make Question Type a dropdown instead of radio -->
|
||||
<xpath expr="//field[@name='question_type']" position="attributes">
|
||||
<attribute name="widget"></attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- Hide Answers section in Options tab for custom fields -->
|
||||
<xpath expr="//page[@name='options']/group[1]/group[1]" position="attributes">
|
||||
<attribute name="invisible">question_type in ['color', 'email', 'url', 'time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- Hide original Answers tab for custom fields -->
|
||||
<xpath expr="//page[@name='answers']" position="attributes">
|
||||
<attribute name="invisible">is_page or question_type in ['text_box', 'color', 'email', 'url', 'time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']</attribute>
|
||||
</xpath>
|
||||
|
||||
<!-- Add new Configuration page for custom fields only -->
|
||||
<xpath expr="//page[@name='answers']" position="after">
|
||||
<page string="Configuration" name="custom_configuration"
|
||||
invisible="is_page or question_type not in ['time', 'range', 'week', 'password', 'file', 'signature', 'month', 'address', 'name', 'many2one', 'many2many']">
|
||||
<group>
|
||||
<!-- Configuration groups for custom fields -->
|
||||
</group>
|
||||
</page>
|
||||
</xpath>
|
||||
|
||||
<!-- Add preview for custom fields -->
|
||||
<xpath expr="//div[hasclass('o_preview_questions')]/div[@invisible="question_type != 'scale'"]" position="after">
|
||||
<div invisible="question_type != 'color'" role="img" aria-label="Color Picker" title="Color Picker">
|
||||
<span>Pick a color</span><br/>
|
||||
<i class="fa fa-eyedropper fa-2x" role="img" aria-label="Color" title="Color"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'email'" role="img" aria-label="Email Input" title="Email Input">
|
||||
<span>Enter your email</span><br/>
|
||||
<i class="fa fa-envelope fa-2x" role="img" aria-label="Email" title="Email"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'url'" role="img" aria-label="URL Input" title="URL Input">
|
||||
<span>Enter a website URL</span><br/>
|
||||
<i class="fa fa-link fa-2x" role="img" aria-label="URL" title="URL"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'time'" role="img" aria-label="Time Input" title="Time Input">
|
||||
<span>Select a time</span><br/>
|
||||
<i class="fa fa-clock-o fa-2x" role="img" aria-label="Time" title="Time"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'range'" role="img" aria-label="Range Slider" title="Range Slider">
|
||||
<span>Select a value</span><br/>
|
||||
<i class="fa fa-sliders fa-2x" role="img" aria-label="Range" title="Range"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'week'" role="img" aria-label="Week Input" title="Week Input">
|
||||
<span>Select a week</span><br/>
|
||||
<p class="o_datetime border-0">YYYY-W##
|
||||
<i class="fa fa-calendar" role="img" aria-label="Calendar" title="Calendar"/>
|
||||
</p>
|
||||
</div>
|
||||
<div invisible="question_type != 'password'" role="img" aria-label="Password Input" title="Password Input">
|
||||
<span>Enter password</span><br/>
|
||||
<i class="fa fa-lock fa-2x" role="img" aria-label="Password" title="Password"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'file'" role="img" aria-label="File Upload" title="File Upload">
|
||||
<span>Upload a file</span><br/>
|
||||
<i class="fa fa-upload fa-2x" role="img" aria-label="File" title="File"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'signature'" role="img" aria-label="Signature Pad" title="Signature Pad">
|
||||
<span>Sign here</span><br/>
|
||||
<i class="fa fa-pencil-square-o fa-2x" role="img" aria-label="Signature" title="Signature"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'month'" role="img" aria-label="Month Input" title="Month Input">
|
||||
<span>Select a month</span><br/>
|
||||
<p class="o_datetime border-0">YYYY-MM
|
||||
<i class="fa fa-calendar" role="img" aria-label="Calendar" title="Calendar"/>
|
||||
</p>
|
||||
</div>
|
||||
<div invisible="question_type != 'address'" role="img" aria-label="Address Input" title="Address Input">
|
||||
<span>Enter your address</span><br/>
|
||||
<i class="fa fa-map-marker fa-2x" role="img" aria-label="Address" title="Address"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'name'" role="img" aria-label="Name Input" title="Name Input">
|
||||
<span>Enter your name</span><br/>
|
||||
<i class="fa fa-user fa-2x" role="img" aria-label="Name" title="Name"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'many2one'" role="img" aria-label="Select Record" title="Select Record">
|
||||
<span>Select a record</span><br/>
|
||||
<i class="fa fa-list-ul fa-2x" role="img" aria-label="Many2one" title="Many2one"/>
|
||||
</div>
|
||||
<div invisible="question_type != 'many2many'" role="img" aria-label="Select Multiple Records" title="Select Multiple Records">
|
||||
<span>Select records</span><br/>
|
||||
<i class="fa fa-th-list fa-2x" role="img" aria-label="Many2many" title="Many2many"/>
|
||||
</div>
|
||||
</xpath>
|
||||
|
||||
<!-- Add color field configuration -->
|
||||
<xpath expr="//page[@name='custom_configuration']//group[last()]" position="inside">
|
||||
|
||||
<!-- Add Time field configuration -->
|
||||
<group name="time_configuration" string="Time Configuration"
|
||||
invisible="question_type != 'time'">
|
||||
<field name="time_validate"/>
|
||||
<field name="time_min"/>
|
||||
<field name="time_max"/>
|
||||
<field name="time_step"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Range field configuration -->
|
||||
<group name="range_configuration" string="Range Configuration"
|
||||
invisible="question_type != 'range'">
|
||||
<field name="validate_range"/>
|
||||
<field name="range_min"/>
|
||||
<field name="range_max"/>
|
||||
<field name="range_step"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Week field configuration -->
|
||||
<group name="week_configuration" string="Week Configuration"
|
||||
invisible="question_type != 'week'">
|
||||
<field name="validate_week_entry"/>
|
||||
<field name="week_min"/>
|
||||
<field name="week_max"/>
|
||||
<field name="week_step"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Password field configuration -->
|
||||
<group string="Password Settings" invisible="question_type != 'password'">
|
||||
<field name="validate_password"/>
|
||||
<field name="password_min_length" invisible="not validate_password"/>
|
||||
<field name="password_max_length" invisible="not validate_password"/>
|
||||
</group>
|
||||
|
||||
<!-- Add File field configuration -->
|
||||
<group name="file_configuration" string="File Configuration"
|
||||
invisible="question_type != 'file'">
|
||||
<field name="file_max_size"/>
|
||||
<field name="file_allowed_types"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Signature field configuration -->
|
||||
<group name="signature_configuration" string="Signature Configuration"
|
||||
invisible="question_type != 'signature'">
|
||||
<field name="signature_width"/>
|
||||
<field name="signature_height"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Month field configuration -->
|
||||
<group name="month_configuration" string="Month Configuration"
|
||||
invisible="question_type != 'month'">
|
||||
<field name="validate_month_entry"/>
|
||||
<field name="month_min"/>
|
||||
<field name="month_max"/>
|
||||
<field name="month_step"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Address field configuration -->
|
||||
<group name="address_configuration" invisible="question_type != 'address'">
|
||||
<group string="Enable Fields">
|
||||
<field name="address_enable_street"/>
|
||||
<field name="address_enable_street2"/>
|
||||
<field name="address_enable_zip"/>
|
||||
<field name="address_enable_city"/>
|
||||
<field name="address_enable_state"/>
|
||||
<field name="address_enable_country"/>
|
||||
</group>
|
||||
<group string="Field Labels">
|
||||
<field name="address_label_street" invisible="not address_enable_street"/>
|
||||
<field name="address_label_street2" invisible="not address_enable_street2"/>
|
||||
<field name="address_label_zip" invisible="not address_enable_zip"/>
|
||||
<field name="address_label_city" invisible="not address_enable_city"/>
|
||||
<field name="address_label_state" invisible="not address_enable_state"/>
|
||||
<field name="address_label_country" invisible="not address_enable_country"/>
|
||||
</group>
|
||||
</group>
|
||||
|
||||
<!-- Add Name field configuration -->
|
||||
<group name="name_configuration" string="Name Configuration"
|
||||
invisible="question_type != 'name'">
|
||||
<field name="name_middle_optional"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Many2one field configuration -->
|
||||
<group name="many2one_configuration" string="Many2one Configuration"
|
||||
invisible="question_type != 'many2one'">
|
||||
<field name="many2one_model" placeholder="e.g., res.partner or Contact" required="question_type == 'many2one'"/>
|
||||
</group>
|
||||
|
||||
<!-- Add Many2many field configuration -->
|
||||
<group name="many2many_configuration" string="Many2many Configuration"
|
||||
invisible="question_type != 'many2many'">
|
||||
<field name="many2many_model" placeholder="e.g., res.partner or Contact" required="question_type == 'many2many'"/>
|
||||
</group>
|
||||
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,15 @@
|
||||
<odoo>
|
||||
<record id="survey_survey_form_inherit" model="ir.ui.view">
|
||||
<field name="name">survey.survey.form.inherit</field>
|
||||
<field name="model">survey.survey</field>
|
||||
<field name="inherit_id" ref="survey.survey_survey_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='access_mode']" position="after">
|
||||
<field name="enable_cron" invisible="access_mode != 'token'"/>
|
||||
<field name="scheduled_date" invisible="not enable_cron or access_mode != 'token'"/>
|
||||
<field name="cron_status" invisible="not enable_cron or access_mode != 'token'"/>
|
||||
<field name="existing_contact_ids" widget="many2many_tags" invisible="not enable_cron or access_mode != 'token'"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
@@ -0,0 +1,718 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Add color field to question container -->
|
||||
<template id="question_color" name="Question: color picker">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="color"
|
||||
class="form-control o_survey_question_color bg-transparent rounded-0 p-0"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add email field to question container -->
|
||||
<template id="question_email" name="Question: email input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="email"
|
||||
class="form-control o_survey_question_email"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
placeholder="user@domain.com"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add URL field to question container -->
|
||||
<template id="question_url" name="Question: URL input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="url"
|
||||
class="form-control o_survey_question_url"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
placeholder="https://example.com"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add time field to question container -->
|
||||
<template id="question_time" name="Question: time input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="time"
|
||||
class="form-control o_survey_question_time"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-validate-time="question.time_validate"
|
||||
t-att-data-time-min="question.time_min"
|
||||
t-att-data-time-max="question.time_max"
|
||||
t-att-data-time-step="question.time_step"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Range field template -->
|
||||
<template id="question_range" name="Question: Range Slider">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="range"
|
||||
class="form-range o_survey_question_range"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else str(question.range_min or 0)"
|
||||
t-att-min="str(question.range_min or 0)"
|
||||
t-att-max="question.range_max"
|
||||
t-att-step="question.range_step"
|
||||
t-att-data-question-type="question.question_type"/>
|
||||
<div class="d-flex justify-content-between align-items-center mt-2">
|
||||
<span class="text-muted small" t-esc="question.range_min or 0"/>
|
||||
<span class="range-value fw-bold" t-esc="answer_lines[0].value_char_box if answer_lines else (question.range_min or 0)"/>
|
||||
<span class="text-muted small" t-esc="question.range_max"/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Week field template -->
|
||||
<template id="question_week" name="Question: week input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="week"
|
||||
class="form-control o_survey_question_week"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-week-min="question.week_min"
|
||||
t-att-data-week-max="question.week_max"
|
||||
t-att-data-week-step="question.week_step"
|
||||
t-att-data-week-error-msg="question.week_error_msg"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add password field to question container -->
|
||||
<template id="question_password" name="Question: password input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="password"
|
||||
class="form-control o_survey_question_password"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-validate-password="question.validate_password"
|
||||
t-att-data-password-min="question.password_min_length"
|
||||
t-att-data-password-max="question.password_max_length"
|
||||
t-att-data-password-error-msg="question.password_error_msg"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add file field to question container -->
|
||||
<template id="question_file" name="Question: file upload">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="file"
|
||||
class="form-control o_survey_question_file"
|
||||
t-att-data-question-id="question.id"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-max-size="question.file_max_size"
|
||||
t-att-data-allowed-types="question.file_allowed_types"/>
|
||||
<input type="hidden"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
class="file_attachment_id"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Add signature field to question container -->
|
||||
<template id="question_signature" name="Question: signature pad">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<div class="signature-container">
|
||||
<canvas class="signature-pad border"
|
||||
t-att-data-question-id="question.id"
|
||||
t-att-width="question.signature_width or 400"
|
||||
t-att-height="question.signature_height or 200"
|
||||
style="cursor: crosshair; background: white;"></canvas>
|
||||
<div class="signature-controls mt-2">
|
||||
<button type="button" class="btn btn-sm btn-secondary clear-signature">Clear</button>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
class="signature-data"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Month field template -->
|
||||
<template id="question_month" name="Question: month input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<input type="month"
|
||||
class="form-control o_survey_question_month"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-validate-month-entry="question.validate_month_entry"
|
||||
t-att-data-month-min="question.month_min"
|
||||
t-att-data-month-max="question.month_max"
|
||||
t-att-data-month-step="question.month_step"
|
||||
t-att-data-month-error-msg="question.month_error_msg"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Address field template -->
|
||||
<template id="question_address" name="Question: address input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<div class="address-fields" t-att-data-question-id="question.id">
|
||||
<t t-if="question.address_enable_street">
|
||||
<div class="mb-2">
|
||||
<label class="form-label" t-esc="question.address_label_street"/>
|
||||
<input type="text" class="form-control address-street" t-att-placeholder="question.address_label_street"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="question.address_enable_street2">
|
||||
<div class="mb-2">
|
||||
<label class="form-label" t-esc="question.address_label_street2"/>
|
||||
<input type="text" class="form-control address-street2" t-att-placeholder="question.address_label_street2"/>
|
||||
</div>
|
||||
</t>
|
||||
<div class="row">
|
||||
<t t-if="question.address_enable_zip">
|
||||
<div class="col-md-6 mb-2">
|
||||
<label class="form-label" t-esc="question.address_label_zip"/>
|
||||
<input type="text" class="form-control address-zip" t-att-placeholder="question.address_label_zip"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="question.address_enable_city">
|
||||
<div class="col-md-6 mb-2">
|
||||
<label class="form-label" t-esc="question.address_label_city"/>
|
||||
<input type="text" class="form-control address-city" t-att-placeholder="question.address_label_city"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
<div class="row">
|
||||
<t t-if="question.address_enable_state">
|
||||
<div class="col-md-6 mb-2">
|
||||
<label class="form-label" t-esc="question.address_label_state"/>
|
||||
<input type="text" class="form-control address-state" t-att-placeholder="question.address_label_state"/>
|
||||
</div>
|
||||
</t>
|
||||
<t t-if="question.address_enable_country">
|
||||
<div class="col-md-6 mb-2">
|
||||
<label class="form-label" t-esc="question.address_label_country"/>
|
||||
<input type="text" class="form-control address-country" t-att-placeholder="question.address_label_country"/>
|
||||
</div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
class="address-data"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Name field template -->
|
||||
<template id="question_name" name="Question: name input">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<div class="name-fields" t-att-data-question-id="question.id">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label">First Name <span t-if="question.constr_mandatory" class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control name-first" placeholder="First Name"/>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label">Middle Name <span t-if="question.constr_mandatory and not question.name_middle_optional" class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control name-middle" placeholder="Middle Name"/>
|
||||
</div>
|
||||
<div class="col-md-4 mb-2">
|
||||
<label class="form-label">Last Name <span t-if="question.constr_mandatory" class="text-danger">*</span></label>
|
||||
<input type="text" class="form-control name-last" placeholder="Last Name"/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<input type="hidden"
|
||||
t-att-name="question.id"
|
||||
t-att-value="answer_lines[0].value_char_box if answer_lines else ''"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-middle-optional="question.name_middle_optional"
|
||||
class="name-data"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Many2one field template -->
|
||||
<template id="question_many2one" name="Question: many2one selection">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<select class="form-select o_survey_question_many2one many2one-select2"
|
||||
t-att-name="question.id"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-model="question.many2one_model">
|
||||
<option value="">-- Select an option --</option>
|
||||
<t t-if="question.many2one_model">
|
||||
<t t-set="records" t-value="request.env[question.many2one_model].sudo().search([])"/>
|
||||
<t t-set="answer_value" t-value="answer_lines[0].sudo().value_char_box if answer_lines else ''"/>
|
||||
<t t-foreach="records" t-as="record">
|
||||
<option t-att-value="record.id"
|
||||
t-att-selected="str(record.id) == answer_value"
|
||||
t-esc="record.display_name"/>
|
||||
</t>
|
||||
</t>
|
||||
</select>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Many2many field template -->
|
||||
<template id="question_many2many" name="Question: many2many selection">
|
||||
<div class="o_survey_answer_wrapper p-1 rounded">
|
||||
<select class="form-select o_survey_question_many2many many2many-select2" multiple="multiple"
|
||||
t-att-name="question.id"
|
||||
t-att-data-question-type="question.question_type"
|
||||
t-att-data-model="question.many2many_model">
|
||||
<t t-if="question.many2many_model">
|
||||
<t t-set="records" t-value="request.env[question.many2many_model].sudo().search([])"/>
|
||||
<t t-set="answer_value" t-value="answer_lines[0].sudo().value_char_box if answer_lines else ''"/>
|
||||
<t t-set="selected_ids" t-value="answer_value.split(',') if answer_value else []"/>
|
||||
<t t-foreach="records" t-as="record">
|
||||
<option t-att-value="record.id"
|
||||
t-att-selected="str(record.id) in selected_ids"
|
||||
t-esc="record.display_name"/>
|
||||
</t>
|
||||
</t>
|
||||
</select>
|
||||
<input type="hidden" t-att-name="question.id" class="many2many-data"/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Extend question container to include all extra fields -->
|
||||
<template id="question_container_inherit" inherit_id="survey.question_container" name="Question Container Extra Fields">
|
||||
<xpath expr="//t[@t-if="question.question_type == 'matrix'"]" position="after">
|
||||
<t t-if="question.question_type == 'color'" t-call="survey_extra_fields.question_color"/>
|
||||
<t t-if="question.question_type == 'email'" t-call="survey_extra_fields.question_email"/>
|
||||
<t t-if="question.question_type == 'url'" t-call="survey_extra_fields.question_url"/>
|
||||
<t t-if="question.question_type == 'time'" t-call="survey_extra_fields.question_time"/>
|
||||
<t t-if="question.question_type == 'range'" t-call="survey_extra_fields.question_range"/>
|
||||
<t t-if="question.question_type == 'week'" t-call="survey_extra_fields.question_week"/>
|
||||
<t t-if="question.question_type == 'password'" t-call="survey_extra_fields.question_password"/>
|
||||
<t t-if="question.question_type == 'file'" t-call="survey_extra_fields.question_file"/>
|
||||
<t t-if="question.question_type == 'signature'" t-call="survey_extra_fields.question_signature"/>
|
||||
<t t-if="question.question_type == 'month'" t-call="survey_extra_fields.question_month"/>
|
||||
<t t-if="question.question_type == 'address'" t-call="survey_extra_fields.question_address"/>
|
||||
<t t-if="question.question_type == 'name'" t-call="survey_extra_fields.question_name"/>
|
||||
<t t-if="question.question_type == 'many2one'" t-call="survey_extra_fields.question_many2one"/>
|
||||
<t t-if="question.question_type == 'many2many'" t-call="survey_extra_fields.question_many2many"/>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
<!-- Extend print template for all extra fields -->
|
||||
<template id="survey_print_inherit" inherit_id="survey.survey_page_print" name="Survey Print Extra Fields">
|
||||
<xpath expr="//t[@t-if="question.question_type == 'matrix'"]" position="after">
|
||||
|
||||
<!-- Color print -->
|
||||
<t t-if="question.question_type == 'color'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<div class="d-inline-block border"
|
||||
t-att-style="'background-color: %s; width: 30px; height: 30px; border-radius: 4px;' % answer_lines[0].value_char_box"/>
|
||||
<span class="ms-2" t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Email print -->
|
||||
<t t-if="question.question_type == 'email'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- URL print -->
|
||||
<t t-if="question.question_type == 'url'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<a t-att-href="answer_lines[0].value_char_box" t-esc="answer_lines[0].value_char_box" target="_blank"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Time print -->
|
||||
<t t-if="question.question_type == 'time'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Range print -->
|
||||
<t t-if="question.question_type == 'range'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Week print -->
|
||||
<t t-if="question.question_type == 'week'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Password print -->
|
||||
<t t-if="question.question_type == 'password'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- File print -->
|
||||
<t t-if="question.question_type == 'file'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<t t-if="answer_lines[0].value_char_box and answer_lines[0].value_char_box.isdigit()">
|
||||
<t t-set="attachment" t-value="request.env['ir.attachment'].sudo().browse(int(answer_lines[0].value_char_box))"/>
|
||||
<t t-if="attachment.exists()">
|
||||
<a t-att-href="'/web/content/%s?download=true' % attachment.id"
|
||||
t-esc="attachment.name"
|
||||
target="_blank"
|
||||
class="text-primary text-decoration-underline"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">File not found</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="answer_lines[0].value_char_box or 'No file'"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Signature print -->
|
||||
<t t-if="question.question_type == 'signature'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<t t-if="answer_lines[0].value_char_box and answer_lines[0].value_char_box.startswith('data:image/')">
|
||||
<img t-att-src="answer_lines[0].value_char_box"
|
||||
alt="Signature"
|
||||
style="max-width: 300px; border: 1px solid #ccc;"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No signature</span>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Month print -->
|
||||
<t t-if="question.question_type == 'month'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Address print -->
|
||||
<t t-if="question.question_type == 'address'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<t t-set="addr_data" t-value="json.loads(answer_lines[0].value_char_box) if answer_lines[0].value_char_box else {}"/>
|
||||
<div class="address-display">
|
||||
<t t-if="addr_data.get('street')">
|
||||
<div><strong t-esc="question.address_label_street"/>: <span t-esc="addr_data['street']"/></div>
|
||||
</t>
|
||||
<t t-if="addr_data.get('street2')">
|
||||
<div><strong t-esc="question.address_label_street2"/>: <span t-esc="addr_data['street2']"/></div>
|
||||
</t>
|
||||
<t t-if="addr_data.get('zip')">
|
||||
<div><strong t-esc="question.address_label_zip"/>: <span t-esc="addr_data['zip']"/></div>
|
||||
</t>
|
||||
<t t-if="addr_data.get('city')">
|
||||
<div><strong t-esc="question.address_label_city"/>: <span t-esc="addr_data['city']"/></div>
|
||||
</t>
|
||||
<t t-if="addr_data.get('state')">
|
||||
<div><strong t-esc="question.address_label_state"/>: <span t-esc="addr_data['state']"/></div>
|
||||
</t>
|
||||
<t t-if="addr_data.get('country')">
|
||||
<div><strong t-esc="question.address_label_country"/>: <span t-esc="addr_data['country']"/></div>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Name print -->
|
||||
<t t-if="question.question_type == 'name'">
|
||||
<t t-if="answer_lines">
|
||||
<t t-if="answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<t t-set="name_data" t-value="json.loads(answer_lines[0].value_char_box) if answer_lines[0].value_char_box else {}"/>
|
||||
<div class="name-display">
|
||||
<strong>Full Name:</strong>
|
||||
<span t-esc="name_data.get('first_name', '')"/>
|
||||
<span t-if="name_data.get('middle_name')" t-esc="' ' + name_data['middle_name']"/>
|
||||
<span t-esc="' ' + name_data.get('last_name', '')"/>
|
||||
<div class="mt-1">
|
||||
<small class="text-muted">
|
||||
First: <span t-esc="name_data.get('first_name', 'N/A')"/> |
|
||||
Middle: <span t-esc="name_data.get('middle_name', 'N/A')"/> |
|
||||
Last: <span t-esc="name_data.get('last_name', 'N/A')"/>
|
||||
</small>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Many2one print -->
|
||||
<!-- Many2one print -->
|
||||
<t t-if="question.question_type == 'many2one'">
|
||||
<t t-if="answer_lines and answer_lines[0].value_char_box">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<t t-if="',' in answer_lines[0].value_char_box">
|
||||
<t t-set="link_parts" t-value="answer_lines[0].value_char_box.split(',')"/>
|
||||
<t t-set="record" t-value="request.env[link_parts[0]].sudo().browse(int(link_parts[1]))"/>
|
||||
<t t-if="record.exists()">
|
||||
<a t-att-href="'/web#id=%s&model=%s&view_type=form' % (link_parts[1], link_parts[0])"
|
||||
target="_blank"
|
||||
t-esc="record.display_name"/>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">Record not found</span>
|
||||
</t>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="answer_lines and answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
<!-- Many2many print -->
|
||||
<t t-if="question.question_type == 'many2many'">
|
||||
<t t-if="answer_lines and answer_lines[0].value_char_box">
|
||||
<div class="row g-0">
|
||||
<div class="col-12">
|
||||
<t t-set="record_ids" t-value="answer_lines[0].value_char_box.split(',') if answer_lines[0].value_char_box else []"/>
|
||||
<t t-if="record_ids and question.many2many_model">
|
||||
<ul class="list-unstyled mb-0">
|
||||
<t t-foreach="record_ids" t-as="record_id">
|
||||
<t t-set="record" t-value="request.env[question.many2many_model].sudo().browse(int(record_id.strip()))"/>
|
||||
<t t-if="record.exists()">
|
||||
<li><span t-esc="record.display_name"/></li>
|
||||
</t>
|
||||
</t>
|
||||
</ul>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span t-esc="answer_lines[0].value_char_box"/>
|
||||
</t>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-elif="answer_lines and answer_lines[0].skipped">
|
||||
<div class="row g-0">
|
||||
<div class="col-12 col-md-6 col-lg-4 rounded ps-4 o_survey_question_skipped">
|
||||
<span class="fst-italic">Skipped</span>
|
||||
</div>
|
||||
</div>
|
||||
</t>
|
||||
<t t-else="">
|
||||
<span class="text-muted">No answer</span>
|
||||
</t>
|
||||
</t>
|
||||
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
|
||||
<!-- Inherit question container to add custom error message support -->
|
||||
<template id="question_container_error_msg_inherit" inherit_id="survey.question_container" name="Question Container Custom Error Message">
|
||||
<xpath expr="//div[@t-att-id='question.id']" position="attributes">
|
||||
<attribute name="t-att-data-required-error">question.constr_error_msg or default_constr_error_msg</attribute>
|
||||
</xpath>
|
||||
</template>
|
||||
|
||||
</odoo>
|
||||
@@ -0,0 +1,52 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<odoo>
|
||||
<!-- Extend survey user input form view to show correct answer types in list -->
|
||||
<record id="survey_user_input_view_form_inherit_main" model="ir.ui.view">
|
||||
<field name="name">survey.user_input.view.form.inherit.main</field>
|
||||
<field name="model">survey.user_input</field>
|
||||
<field name="inherit_id" ref="survey.survey_user_input_view_form"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='user_input_line_ids']//list//field[@name='answer_type']" position="replace">
|
||||
<field name="answer_type_display" string="Answer Type"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend survey user input line form view to show file links -->
|
||||
<record id="survey_user_input_line_view_form_inherit" model="ir.ui.view">
|
||||
<field name="name">survey.user_input.line.view.form.inherit</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">
|
||||
<!-- Add question_id field for visibility conditions -->
|
||||
<xpath expr="//field[@name='question_id']" position="after">
|
||||
<field name="question_id" invisible="1"/>
|
||||
</xpath>
|
||||
<!-- Replace answer_type field to show correct type -->
|
||||
<xpath expr="//field[@name='answer_type']" position="replace">
|
||||
<field name="answer_type_display" string="Answer Type"/>
|
||||
</xpath>
|
||||
<!-- Replace value_char_box field -->
|
||||
<xpath expr="//field[@name='value_char_box']" position="replace">
|
||||
<field name="value_char_box" colspan='2' invisible="not show_value_char_box"/>
|
||||
<field name="file_display" colspan='2' string="Answer" invisible="not show_file_display"/>
|
||||
<field name="signature_display" colspan='2' string="Answer" invisible="not show_signature_display"/>
|
||||
<field name="many2one_display" colspan='2' string="Answer" invisible="not show_many2one_display"/>
|
||||
<field name="many2many_display" colspan='2' string="Answer" invisible="not show_many2many_display"/>
|
||||
<field name="extra_field_display" colspan='2' string="Answer" invisible="not show_extra_field_display"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
|
||||
<!-- Extend survey user input line list view to show correct answer types -->
|
||||
<record id="survey_user_input_line_view_list_inherit" model="ir.ui.view">
|
||||
<field name="name">survey.user_input.line.view.list.inherit</field>
|
||||
<field name="model">survey.user_input.line</field>
|
||||
<field name="inherit_id" ref="survey.survey_response_line_view_tree"/>
|
||||
<field name="arch" type="xml">
|
||||
<xpath expr="//field[@name='answer_type']" position="replace">
|
||||
<field name="answer_type_display" string="Answer Type"/>
|
||||
</xpath>
|
||||
</field>
|
||||
</record>
|
||||
</odoo>
|
||||
Reference in New Issue
Block a user