first push message
@@ -0,0 +1,61 @@
|
||||
=====================
|
||||
Survey Extra Fields
|
||||
=====================
|
||||
|
||||
Enhance Odoo Survey App with 14 new question field types like Color Pickers, Digital Signatures, File Uploads, and Email validation. This module also includes automated survey scheduling via cron jobs, allowing hands-free distribution to specific contacts. Improve data quality, accuracy, and efficiency for all your Odoo survey needs and data collection processes.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
**Key Features**
|
||||
================================================================
|
||||
|
||||
- **14 Advanced Question Types** : Unlock richer forms with fields like **Color Picker**, **Email**, **URL**, **Time**, **Range**, **Password**, **File Upload**, and **Digital Signature** for versatile data capture.
|
||||
- **Mandatory & Validation Rules** : Set required questions and enforce strict validations (email, URL, numeric limits) to ensure precise and reliable responses.
|
||||
- **Automated Survey Scheduling**:Use cron-based automation to send surveys at scheduled times without manual action, ensuring timely and consistent feedback collection.
|
||||
- **Targeted Survey Distribution**: Distribute surveys to selected contacts only — enabling focused and personalized data gathering.
|
||||
- **Seamless Odoo Integration** : Fully compatible with standard Odoo Surveys for smooth operation, no extra setup, and direct backend usability.
|
||||
|
||||
|
||||
|
||||
**Installation**
|
||||
================================================================
|
||||
* Go to Apps menu
|
||||
* Click "Update Apps List"
|
||||
* Search for "Survey Extra Fields"
|
||||
* Click on the module
|
||||
* Click "Install" button
|
||||
|
||||
**How to use this module:**
|
||||
================================================================
|
||||
|
||||
1. **Go to Apps and Install the Module** : In your Odoo dashboard, navigate to **Apps**, search for **Survey Extra Fields**, and install it to enhance your survey functionality.
|
||||
2. **Open the Survey Application** : After installation, go to the **Survey** module from the main Odoo menu.
|
||||
3. **Create or Edit a Survey** : Click **Create** to make a new survey or open an existing one to add advanced question types.
|
||||
4. **Add Advanced Question Types** : In the question form, select from 14 new field types such as **Color Picker**, **Email**, **URL**, **Time**, **Range**, **Password**, **File Upload**, or **Digital Signature** for more flexible data collection.
|
||||
5. **Set Mandatory Questions** : Enable the **Required** checkbox on specific questions to make them compulsory for participants to answer.
|
||||
6. **Apply Validation Rules** : Define strict validation settings (e.g., correct email format, valid URL, numeric limits) to ensure accurate and structured responses.
|
||||
7. **Enable Automated Survey Scheduling** : In the survey form, activate the **Enable Cron** option and set the **Scheduled Date** to automatically send surveys at specific times.
|
||||
8. **Distribute Surveys to Targeted Contacts** : Choose specific contacts, customers, or segments to ensure surveys are sent only to relevant participants.
|
||||
9. **Send and Monitor Surveys** : Once scheduled or sent, track participant responses and completion status in real time from the Odoo backend.
|
||||
10. **Analyze and Export Survey Results** : Review the collected responses using Odoo’s built-in analytics tools. Export results in various formats for further reporting and insights.
|
||||
|
||||
|
||||
**User Manual:**
|
||||
=======================
|
||||
User Manual - https://web.kopyst.com/sharedoc/9de6e2cc8b
|
||||
|
||||
|
||||
**Change logs**
|
||||
================================================================
|
||||
[1.0.0]
|
||||
|
||||
* ``Added`` [12-11-2025]- Initial release of the Survey Extra Fields module.
|
||||
|
||||
**Support**
|
||||
================================================================
|
||||
|
||||
`Zehntech Technologies <https://www.zehntech.com/erp-crm/odoo-services/>`_
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
from . import models
|
||||
from . import controllers
|
||||
@@ -0,0 +1,42 @@
|
||||
{
|
||||
"name": "Survey Extra Fields",
|
||||
"summary": "Enhance Odoo Survey App with 14 new question field types like Color Pickers, Digital Signatures, File Uploads, and Email validation. This module also includes automated survey scheduling via cron jobs, allowing hands-free distribution to specific contacts. Improve data quality, accuracy, and efficiency for all your Odoo survey needs and data collection processes. Odoo Survey Fields | Survey Extra Fields | Custom Survey Fields | Digital Signature Survey | Color Picker Survey | File Upload Survey | Survey Automation Odoo | Automated Survey Scheduling | Survey Field Validation | Advanced Survey Questions | Survey Cron Jobs | Odoo Survey Enhancement | Survey Data Collection | Custom Question Types | Survey Module Addon",
|
||||
"description": """
|
||||
Survey Extra Fields module expands the standard Odoo Survey app, adding 14 specialized field types (e.g., Time, Range, Password, URL) for richer data. Features include mandatory field settings, strict input validation, and a powerful automated scheduling option. Use Odoo's cron jobs to automatically send surveys on set dates to targeted contacts, ensuring timely distribution and efficient status tracking. This is essential for advanced data capture and streamlined workflow.
|
||||
""",
|
||||
"author": "Zehntech Technologies Inc.",
|
||||
"company": "Zehntech Technologies Inc.",
|
||||
"maintainer": "Zehntech Technologies Inc.",
|
||||
"contributor": "Zehntech Technologies Inc.",
|
||||
"website": "https://www.zehntech.com/",
|
||||
"support": "odoo-support@zehntech.com",
|
||||
"live_test_url": "https://zehntechodoo.com/app_name=zehntech_survey_extra_fields/app_version=19.0",
|
||||
"category": "Marketing/Surveys",
|
||||
"version": "19.0.1.0.0",
|
||||
"depends": ["survey"],
|
||||
"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": [
|
||||
"zehntech_survey_extra_fields/static/src/js/survey_many2many_select2.js",
|
||||
],
|
||||
"survey.survey_assets": [
|
||||
"zehntech_survey_extra_fields/static/src/js/survey_color_field.js",
|
||||
"zehntech_survey_extra_fields/static/src/js/survey_signature_field.js",
|
||||
"zehntech_survey_extra_fields/static/src/js/survey_range_field.js",
|
||||
],
|
||||
},
|
||||
"images": ["static/description/banner.gif"],
|
||||
"license": "LGPL-3",
|
||||
"price": 15.00,
|
||||
"currency": "USD",
|
||||
"installable": True,
|
||||
"application": True,
|
||||
"auto_install": False,
|
||||
}
|
||||
|
||||
@@ -0,0 +1 @@
|
||||
from . import main
|
||||
@@ -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,13 @@
|
||||
<odoo>
|
||||
<data noupdate="1">
|
||||
<record id="ir_cron_send_scheduled_surveys" model="ir.cron">
|
||||
<field name="name">Send Scheduled Surveys</field>
|
||||
<field name="model_id" ref="survey.model_survey_survey"/>
|
||||
<field name="state">code</field>
|
||||
<field name="code">model._cron_send_scheduled_surveys()</field>
|
||||
<field name="interval_number">2</field>
|
||||
<field name="interval_type">minutes</field>
|
||||
<field name="active" eval="True"/>
|
||||
</record>
|
||||
</data>
|
||||
</odoo>
|
||||
@@ -0,0 +1,61 @@
|
||||
=====================
|
||||
Survey Extra Fields
|
||||
=====================
|
||||
|
||||
Enhance Odoo Survey App with 14 new question field types like Color Pickers, Digital Signatures, File Uploads, and Email validation. This module also includes automated survey scheduling via cron jobs, allowing hands-free distribution to specific contacts. Improve data quality, accuracy, and efficiency for all your Odoo survey needs and data collection processes.
|
||||
|
||||
**Table of contents**
|
||||
|
||||
.. contents::
|
||||
:local:
|
||||
|
||||
**Key Features**
|
||||
================================================================
|
||||
|
||||
- **14 Advanced Question Types** : Unlock richer forms with fields like **Color Picker**, **Email**, **URL**, **Time**, **Range**, **Password**, **File Upload**, and **Digital Signature** for versatile data capture.
|
||||
- **Mandatory & Validation Rules** : Set required questions and enforce strict validations (email, URL, numeric limits) to ensure precise and reliable responses.
|
||||
- **Automated Survey Scheduling**:Use cron-based automation to send surveys at scheduled times without manual action, ensuring timely and consistent feedback collection.
|
||||
- **Targeted Survey Distribution**: Distribute surveys to selected contacts only — enabling focused and personalized data gathering.
|
||||
- **Seamless Odoo Integration** : Fully compatible with standard Odoo Surveys for smooth operation, no extra setup, and direct backend usability.
|
||||
|
||||
|
||||
|
||||
**Installation**
|
||||
================================================================
|
||||
* Go to Apps menu
|
||||
* Click "Update Apps List"
|
||||
* Search for "Survey Extra Fields"
|
||||
* Click on the module
|
||||
* Click "Install" button
|
||||
|
||||
**How to use this module:**
|
||||
================================================================
|
||||
|
||||
1. **Go to Apps and Install the Module** : In your Odoo dashboard, navigate to **Apps**, search for **Survey Extra Fields**, and install it to enhance your survey functionality.
|
||||
2. **Open the Survey Application** : After installation, go to the **Survey** module from the main Odoo menu.
|
||||
3. **Create or Edit a Survey** : Click **Create** to make a new survey or open an existing one to add advanced question types.
|
||||
4. **Add Advanced Question Types** : In the question form, select from 14 new field types such as **Color Picker**, **Email**, **URL**, **Time**, **Range**, **Password**, **File Upload**, or **Digital Signature** for more flexible data collection.
|
||||
5. **Set Mandatory Questions** : Enable the **Required** checkbox on specific questions to make them compulsory for participants to answer.
|
||||
6. **Apply Validation Rules** : Define strict validation settings (e.g., correct email format, valid URL, numeric limits) to ensure accurate and structured responses.
|
||||
7. **Enable Automated Survey Scheduling** : In the survey form, activate the **Enable Cron** option and set the **Scheduled Date** to automatically send surveys at specific times.
|
||||
8. **Distribute Surveys to Targeted Contacts** : Choose specific contacts, customers, or segments to ensure surveys are sent only to relevant participants.
|
||||
9. **Send and Monitor Surveys** : Once scheduled or sent, track participant responses and completion status in real time from the Odoo backend.
|
||||
10. **Analyze and Export Survey Results** : Review the collected responses using Odoo’s built-in analytics tools. Export results in various formats for further reporting and insights.
|
||||
|
||||
|
||||
**User Manual:**
|
||||
=======================
|
||||
User Manual - https://web.kopyst.com/sharedoc/9de6e2cc8b
|
||||
|
||||
|
||||
**Change logs**
|
||||
================================================================
|
||||
[1.0.0]
|
||||
|
||||
* ``Added`` [12-11-2025]- Initial release of the Survey Extra Fields module.
|
||||
|
||||
**Support**
|
||||
================================================================
|
||||
|
||||
`Zehntech Technologies <https://www.zehntech.com/erp-crm/odoo-services/>`_
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
from . import survey_question
|
||||
from . import survey_user_input
|
||||
from . import survey_user_input_line
|
||||
from . import survey_survey
|
||||
@@ -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()
|
||||
|
After Width: | Height: | Size: 6.6 MiB |
|
After Width: | Height: | Size: 25 KiB |
|
After Width: | Height: | Size: 89 KiB |
|
After Width: | Height: | Size: 1.8 KiB |
|
After Width: | Height: | Size: 4.7 KiB |
|
After Width: | Height: | Size: 5.6 KiB |
|
After Width: | Height: | Size: 355 B |
|
After Width: | Height: | Size: 24 KiB |
|
After Width: | Height: | Size: 31 KiB |
|
After Width: | Height: | Size: 366 B |
|
After Width: | Height: | Size: 2.1 KiB |
|
After Width: | Height: | Size: 21 KiB |
|
After Width: | Height: | Size: 304 B |
|
After Width: | Height: | Size: 635 KiB |
|
After Width: | Height: | Size: 222 B |
|
After Width: | Height: | Size: 315 B |
|
After Width: | Height: | Size: 576 KiB |
|
After Width: | Height: | Size: 7.3 KiB |
|
After Width: | Height: | Size: 8.4 KiB |
|
After Width: | Height: | Size: 7.7 KiB |
|
After Width: | Height: | Size: 187 KiB |
|
After Width: | Height: | Size: 42 KiB |
|
After Width: | Height: | Size: 295 KiB |
|
After Width: | Height: | Size: 203 KiB |
|
After Width: | Height: | Size: 274 KiB |
|
After Width: | Height: | Size: 193 KiB |
|
After Width: | Height: | Size: 190 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
After Width: | Height: | Size: 116 KiB |
|
After Width: | Height: | Size: 339 KiB |
|
After Width: | Height: | Size: 126 KiB |
|
After Width: | Height: | Size: 160 KiB |
@@ -0,0 +1,304 @@
|
||||
<div class="ng-star-inserted">
|
||||
<div id="banner" class="ng-star-inserted">
|
||||
<div class="position-relative" style="background-color: #03031F; height: auto;">
|
||||
<div class="position-absolute d-none d-md-block" style="margin-top: 15%; margin-left: 80%;"><img src="images/1741839926658-star.png"></div>
|
||||
<div class="position-absolute d-none d-md-block" style="margin-top: 25%; margin-left: 15%;"><img src="images/1741839869490-thunder.png"></div>
|
||||
<div class="p-4 position-relative ng-star-inserted">
|
||||
<nav class="navbar navbar-expand-lg navbar-dark p-3 position-absolute start-0 top-0 z-2" style="z-index: 20; background-color: #03031F; width: 100%; top: 0; left: 0;">
|
||||
<div class="container-fluid" style="background-color: #03031F;">
|
||||
<a href="#" class="navbar-brand fw-bold fs-5 ng-star-inserted" style="margin-right: 0 !important;"><img alt="logo" style="height: 30px;" src="images/1741097650426-zehntech-logo.png"></a>
|
||||
<span style="padding: 0 10px; color: #fff; font-size: 22px;" class="ng-star-inserted">|</span>
|
||||
<a href="#" class="navbar-brand fw-bold fs-5 ng-star-inserted" style="margin-right: 0 !important;"><img alt="logo" style="height: 33px; width: 67px; border-radius: 3px;" src="https://s3.eu-central-1.amazonaws.com/springboard-template/uploads/1760610306302-odoo_ready_partner_logo.png"></a>
|
||||
<div type="button" data-bs-toggle="collapse" data-bs-target="#navbarCollapse" aria-controls="navbarCollapse" aria-expanded="false" aria-label="Toggle navigation" class="navbar-toggler collapsed ng-star-inserted"><span class="navbar-toggler-icon"></span></div>
|
||||
<div id="navbarCollapse" class="collapse navbar-collapse justify-content-center">
|
||||
<ul class="navbar-nav">
|
||||
<li class="nav-item ng-star-inserted"><a class="nav-link text-white fw-normal" style="padding: 10px 20px; font-size: 14px;" href="#Overview">Overview</a></li>
|
||||
<li class="nav-item ng-star-inserted"><a class="nav-link text-white fw-normal" style="padding: 10px 20px; font-size: 14px;" href="#Feature">Feature</a></li>
|
||||
<li class="nav-item ng-star-inserted"><a class="nav-link text-white fw-normal" style="padding: 10px 20px; font-size: 14px;" href="#AboutZehntech">About Zehntech</a></li>
|
||||
<li class="nav-item ng-star-inserted"><a class="nav-link text-white fw-normal" style="padding: 10px 20px; font-size: 14px;" href="#FAQs">FAQs</a></li>
|
||||
</ul>
|
||||
</div>
|
||||
<a class="rounded-2 d-none d-lg-inline border p-2 ng-star-inserted" style="font-size: 14px; border: 1px solid white; color: white;" href="mailto:odoo-support@zehntech.com"><span><img src="images/1744786379207-mail-outline.png"></span> odoo-support@zehntech.com </a>
|
||||
</div>
|
||||
</nav>
|
||||
</div>
|
||||
<div class="text-center px-3 pt-5 col-12 col-sm-8 col-md-6 m-auto">
|
||||
<h1 class="fw-bold text-white ng-star-inserted" style="font-size: 36px;">Enhance Odoo Surveys with 14 Dynamic Questions & Auto Scheduling</h1>
|
||||
<p class="text-white p-2 ng-star-inserted" style="font-size: 18px;">Enhance Odoo Survey App with 14 new question field types like Color Pickers, Digital Signatures, File Uploads, and Email validation. This module also includes automated survey scheduling via cron jobs, allowing hands-free distribution to specific contacts. Improve data quality, accuracy, and efficiency for all your Odoo survey needs and data collection processes.</p>
|
||||
<div class="mt-4 d-flex flex-column flex-md-row justify-content-center gap-4 position-relative z-1" style="gap: 1.5rem; z-index: 1;">
|
||||
<a target="_blank" href=""></a>
|
||||
<a target="_blank" href="https://apps.odoo.com/apps/modules/19.0/zehntech_survey_extra_fields">
|
||||
<div class="btn px-4 py-2 btn-lg ng-star-inserted" style="color: white; background-color: #FF8900; min-width: 200px; font-size: 18px;">Download</div>
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
<div class="position-relative text-center" style="max-width: 1160px; margin: 0 auto; max-height: 400px;">
|
||||
<div style="padding-top: 37.2%;">
|
||||
<img alt="Odoo PowerBI Connector UI" class="img-fluid position-absolute start-50 translate-middle-x z-1 top-0 ng-star-inserted" style="top: 0; padding-top: 15px; z-index: 10; left: 50%; max-width: 80%; transform: translateX(-50%); max-height: 485px; border-radius: 12px; margin-top: 4%;" src="images/all_features.gif">
|
||||
<div class="position-absolute d-none d-md-block top-0 z-2" style="top: 0; margin-left: 84%; padding-top: 40px; z-index: 11;"><img src="images/1741840514869-badge.png" class="d-none"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="z-0" style="background-color: #EEF3FF;">
|
||||
<div style="width: 100%; padding-top: 15%; background-color: #03031F; border-radius: 50%; margin-top: -7.5%;"></div>
|
||||
</div>
|
||||
<div class="position-relative" style="width: 100%;">
|
||||
<div class="position-absolute" style="margin-top: -50%; width: 100%;"><img src="images/1744786449506-blur-box-shadow.png" style="width: 100%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div style="background-color: #EEF3FF; padding: 40px 5%;" id="section" class="ng-star-inserted">
|
||||
<div class="d-flex justify-content-center position-relative z-1 ng-star-inserted" style="margin-top: 30px; z-index: 10;"><img src="images/1749727890058-odoo-1.png" style="margin-right: 8px; width: 60px; height: 36px;"><img src="images/1749727908079-odoo-2.png" style="margin-right: 8px; width: 60px; height: 36px;"><img src="images/1749727935087-odoo-3.png" style="width: 60px; height: 36px;"></div>
|
||||
<div class="col-12 col-md-8" style="margin: auto; text-align: center;">
|
||||
<h1 style="margin-top: 40px; color: white;" class="ng-star-inserted"><span style="color: #080425; font-weight: bold; font-size: 40px;">Survey Extra Fields</span></h1>
|
||||
<p style="font-size: 16px; color: #555; line-height: 1.6; margin: 20px auto;" class="ng-star-inserted"> Survey Extra Fields module expands the standard Odoo Survey app, adding 14 specialized field types (e.g., Time, Range, Password, URL) for richer data. Features include mandatory field settings, strict input validation, and a powerful automated scheduling option. Use Odoo's cron jobs to automatically send surveys on set dates to targeted contacts, ensuring timely distribution and efficient status tracking. This is essential for advanced data capture and streamlined workflow. </p>
|
||||
<div class="z-1 position-relative ng-star-inserted" style="z-index: 10;"><a target="_blank" style="word-break: break-word; font-size: 16px; display: inline-block; font-size: 1rem; font-weight: bold; color: #0066ff; margin-top: 15px; text-decoration: none;" href="https://web.kopyst.com/sharedoc/9de6e2cc8b" class="ng-star-inserted">Admin Guide - https://web.kopyst.com/sharedoc/9de6e2cc8b <span><img src="images/1744786486541-arrow.png"></span></a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ng-star-inserted">
|
||||
<div id="key_feature" class="ng-star-inserted">
|
||||
<div class="d-flex flex-wrap align-items-center justify-content-between gap-5" style="padding: 20px 5%; background-color: #fff; margin: auto; gap: 30px;">
|
||||
<div class="flex-1" style="flex: 1; min-width: 280px;">
|
||||
<h2 style="font-size: 34px; margin-bottom: 20px; font-weight: bold;" class="ng-star-inserted">Key Features</h2>
|
||||
<ul style="list-style: none; padding: 0;" class="ng-star-inserted">
|
||||
<li class="d-flex align-items-center ng-star-inserted" style="font-size: 16px; margin-bottom: 10px;"><span style="margin-right: 10px;"><img src="images/1741839956911-charm_tick.png"></span> 14 Advanced Question Types</li>
|
||||
</ul>
|
||||
<ul style="list-style: none; padding: 0;" class="ng-star-inserted">
|
||||
<li class="d-flex align-items-center ng-star-inserted" style="font-size: 16px; margin-bottom: 10px;"><span style="margin-right: 10px;"><img src="images/1741839956911-charm_tick.png"></span> Mandatory & Validation Rules </li>
|
||||
</ul>
|
||||
<ul style="list-style: none; padding: 0;" class="ng-star-inserted">
|
||||
<li class="d-flex align-items-center ng-star-inserted" style="font-size: 16px; margin-bottom: 10px;"><span style="margin-right: 10px;"><img src="images/1741839956911-charm_tick.png"></span> Automated Survey Scheduling </li>
|
||||
</ul>
|
||||
<ul style="list-style: none; padding: 0;" class="ng-star-inserted">
|
||||
<li class="d-flex align-items-center ng-star-inserted" style="font-size: 16px; margin-bottom: 10px;"><span style="margin-right: 10px;"><img src="images/1741839956911-charm_tick.png"></span> Targeted Survey Distribution </li>
|
||||
</ul>
|
||||
<ul style="list-style: none; padding: 0;" class="ng-star-inserted">
|
||||
<li class="d-flex align-items-center ng-star-inserted" style="font-size: 16px; margin-bottom: 10px;"><span style="margin-right: 10px;"><img src="images/1741839956911-charm_tick.png"></span> Seamless Odoo Integration</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="d-flex justify-content-lg-end justify-content-center flex-1 ng-star-inserted" style="flex: 1;"><img alt="Feature Image" style="max-width: 350px; width: 100%; border-radius: 10px; padding: 10px;" src="images/1758023375847-1741097931207-key-features_img.png"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div style="padding: 20px 0px;" id="Feature" class="ng-star-inserted">
|
||||
<div class="position-relative" style="background-color: #f0f1fd; border: 1px solid #0249FF33; border-radius: 16px; padding: 40px;">
|
||||
<div class="position-absolute start-100 top-0 translate-middle-x d-none d-md-block" style="transform: translateX(-100%); top: 0; left: 100%;"><img src="images/1741840252979-grain.png" alt="grain"></div>
|
||||
<div class="position-absolute top-0 start-0 z-0 d-none d-md-block" style="top: 0; left: 0;"><img src="images/1741840265299-grain-two.png" alt="grain-two"></div>
|
||||
<h1 class="text-center font-weight-bold mb-4 ng-star-inserted" style="font-size: 36px;">Key Feature</h1>
|
||||
<div class="pt-3">
|
||||
<nav class="border-bottom">
|
||||
<div id="nav-tab" role="tablist" class="nav nav-tabs justify-content-center z-1 position-relative" style="background-color: transparent; z-index: 10;">
|
||||
<a data-bs-toggle="tab" type="button" role="tab" class="nav-link active ng-star-inserted" style="color: black;" id="nav-tab0" href="#nav-0" aria-controls="nav0" aria-selected="true"> 14 Advanced Question Types </a>
|
||||
<a data-bs-toggle="tab" type="button" role="tab" class="nav-link ng-star-inserted" style="color: black;" id="nav-tab1" href="#nav-1" aria-controls="nav1" aria-selected="false"> Mandatory & Validation Rules </a>
|
||||
<a data-bs-toggle="tab" type="button" role="tab" class="nav-link ng-star-inserted" style="color: black;" id="nav-tab2" href="#nav-2" aria-controls="nav2" aria-selected="false"> Automated Survey Scheduling </a>
|
||||
<a data-bs-toggle="tab" type="button" role="tab" class="nav-link ng-star-inserted" style="color: black;" id="nav-tab3" href="#nav-3" aria-controls="nav3" aria-selected="false"> Targeted Survey Distribution </a>
|
||||
<a data-bs-toggle="tab" type="button" role="tab" class="nav-link ng-star-inserted" style="color: black;" id="nav-tab4" href="#nav-4" aria-controls="nav4" aria-selected="false"> Seamless Odoo Integration </a></div>
|
||||
</nav>
|
||||
<div id="nav-tabContent" class="tab-content pt-3">
|
||||
<div role="tabpanel" class="tab-pane fade active show ng-star-inserted" id="nav-0" aria-labelledby="nav-tab0">
|
||||
<img alt="image" class="w-100 rounded" id="0" src="images/14 Advanced Question Types.png">
|
||||
<h6 class="font-weight-bold mt-3" style="font-size: 20px; color: #333;">14 Advanced Question Types</h6>
|
||||
<p style="font-size: 14px; color: #555;">Unlock richer forms with fields like color picker, email, URL, time, range, password, file upload, and digital signature for versatile data capture.</p>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade ng-star-inserted" id="nav-1" aria-labelledby="nav-tab1">
|
||||
<img alt="image" class="w-100 rounded" id="1" src="images/feature2.png">
|
||||
<h6 class="font-weight-bold mt-3" style="font-size: 20px; color: #333;">Mandatory & Validation Rules</h6>
|
||||
<p style="font-size: 14px; color: #555;">Set required questions and enforce strict validations (email, URL, numeric limits) to ensure precise responses.</p>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade ng-star-inserted" id="nav-2" aria-labelledby="nav-tab2">
|
||||
<img alt="image" class="w-100 rounded" id="2" src="images/feature3.png">
|
||||
<h6 class="font-weight-bold mt-3" style="font-size: 20px; color: #333;">Automated Survey Scheduling</h6>
|
||||
<p style="font-size: 14px; color: #555;">Use cron-based automation to send surveys at scheduled times without manual action, ensuring prompt feedback.</p>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade ng-star-inserted" id="nav-3" aria-labelledby="nav-tab3">
|
||||
<img alt="image" class="w-100 rounded" id="3" src="images/Targeted Survey Distribution.png">
|
||||
<h6 class="font-weight-bold mt-3" style="font-size: 20px; color: #333;">Targeted Survey Distribution</h6>
|
||||
<p style="font-size: 14px; color: #555;">Distribute surveys to selected or filtered contacts only enabling focused, personalized data gathering.</p>
|
||||
</div>
|
||||
<div role="tabpanel" class="tab-pane fade ng-star-inserted" id="nav-4" aria-labelledby="nav-tab4">
|
||||
<img alt="image" class="w-100 rounded" id="4" src="images/Seamless Odoo Integration.png">
|
||||
<h6 class="font-weight-bold mt-3" style="font-size: 20px; color: #333;">Seamless Odoo Integration</h6>
|
||||
<p style="font-size: 14px; color: #555;">Fully compatible with standard Odoo Surveys for smooth operation, no extra setup, and direct backend usability.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div id="AboutZehntech" class="ng-star-inserted">
|
||||
<div style="margin: auto; padding: 50px 30px;">
|
||||
<div style="margin: auto; max-width: 900px; text-align: center;">
|
||||
<h2 style="font-size: 36px; font-weight: bold;" class="ng-star-inserted">About Zehntech</h2>
|
||||
<p style="color: #666; font-size: 16px;" class="ng-star-inserted"> Zehntech is a leading Odoo custom development company, empowering businesses across industries with tailored ERP solutions. With a strong team of 50+ skilled Odoo professionals, ranging from 2 to over 10+ years of experience, we bring deep expertise and innovation to every project. Over the years, we have successfully developed 50+ Odoo apps and themes, helping clients streamline operations, enhance productivity, and achieve digital transformation. Our Odoo services include Custom Odoo Development, Implementation, Customization, Support and maintenance. </p>
|
||||
</div>
|
||||
<div class="d-flex flex-column flex-lg-row align-items-center gap-4" style="gap: 2rem;">
|
||||
<div id="accordionExample" class="accordion" style="padding-top: 10px; width: 70%;"></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div style="padding-bottom: 40px;" id="achievement" class="ng-star-inserted">
|
||||
<div class="d-flex justify-content-around align-items-center gap-3 flex-wrap" style="background-color: #000025; color: white; flex-wrap: wrap; gap: 16px; padding: 50px 5%; text-align: center;">
|
||||
<div class="flex-1 ng-star-inserted" style="flex: 1; min-width: 150px; max-width: 200px;">
|
||||
<h2 style="font-size: 43px; margin: 0; color: white;">115+</h2>
|
||||
<p style="font-size: 14px; margin-top: 10px; margin-bottom: 0px;">Happy Customer</p>
|
||||
</div>
|
||||
<div class="d-none d-md-block ng-star-inserted" style="width: 1px; height: 70px; background-color: #FFFFFF66; display: inline-block;"></div>
|
||||
<div class="flex-1 ng-star-inserted" style="flex: 1; min-width: 150px; max-width: 200px;">
|
||||
<h2 style="font-size: 43px; margin: 0; color: white;">120+</h2>
|
||||
<p style="font-size: 14px; margin-top: 10px; margin-bottom: 0px;">Expert Professionals</p>
|
||||
</div>
|
||||
<div class="d-none d-md-block ng-star-inserted" style="width: 1px; height: 70px; background-color: #FFFFFF66; display: inline-block;"></div>
|
||||
<div class="flex-1 ng-star-inserted" style="flex: 1; min-width: 150px; max-width: 200px;">
|
||||
<h2 style="font-size: 43px; margin: 0; color: white;">30%</h2>
|
||||
<p style="font-size: 14px; margin-top: 10px; margin-bottom: 0px;">Time Saved</p>
|
||||
</div>
|
||||
<div class="d-none d-md-block ng-star-inserted" style="width: 1px; height: 70px; background-color: #FFFFFF66; display: inline-block;"></div>
|
||||
<div class="flex-1 ng-star-inserted" style="flex: 1; min-width: 150px; max-width: 200px;">
|
||||
<h2 style="font-size: 43px; margin: 0; color: white;">12+</h2>
|
||||
<p style="font-size: 14px; margin-top: 10px; margin-bottom: 0px;">Countries We Serve</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div style="max-width: 800px; margin: auto;" id="FAQs" class="ng-star-inserted">
|
||||
<div style="max-width: 600px; margin: auto; text-align: center;">
|
||||
<h1 style="font-weight: bold; font-size: 36px;" class="ng-star-inserted">FAQs</h1>
|
||||
<p style="font-size: 16px;" class="ng-star-inserted">Everything you need to know about the Survey Extra Fields Module</p>
|
||||
</div>
|
||||
<div id="accordionExample" class="accordion ng-star-inserted" style="padding-top: 10px;">
|
||||
<div class="accordion-item" style="margin-bottom: 20px; border: 1px solid #ddd; border-radius: 6px; padding: 10px 0; background-color: transparent;">
|
||||
<h2 class="accordion-header ng-star-inserted" style="margin: 0px 20px;">
|
||||
<div type="button" data-bs-toggle="collapse" aria-expanded="true" class="accordion-button" style="background-color: transparent; border: none; box-shadow: none; font-weight: bold; display: flex; justify-content: space-between; width: 100%; font-size: 16px; padding: 10px 0; text-align: start;" data-bs-target="#collapse0" aria-controls="collapse0"> 1. Why Choose Survey Extra Fields Module? </div>
|
||||
</h2>
|
||||
<div data-parent="#accordionExample" class="accordion-collapse collapse show" id="collapse0" aria-labelledby="heading0">
|
||||
<div class="accordion-body" style="margin: 10px 20px; color: #666; font-size: 16px;"> The Survey Extra Fields App enhances Odoo Surveys with 14 dynamic question types, smart validations, and automated scheduling. It ensures accurate data collection, targeted survey distribution, and seamless integration with standard Odoo functionality for a smoother, more powerful survey experience. </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accordionExample" class="accordion ng-star-inserted" style="padding-top: 10px;">
|
||||
<div class="accordion-item" style="margin-bottom: 20px; border: 1px solid #ddd; border-radius: 6px; padding: 10px 0; background-color: transparent;">
|
||||
<h2 class="accordion-header ng-star-inserted" style="margin: 0px 20px;">
|
||||
<div type="button" data-bs-toggle="collapse" aria-expanded="true" class="accordion-button collapsed" style="background-color: transparent; border: none; box-shadow: none; font-weight: bold; display: flex; justify-content: space-between; width: 100%; font-size: 16px; padding: 10px 0; text-align: start;" data-bs-target="#collapse1" aria-controls="collapse1"> 2. Is the Survey Extra Fields Module scalable for growing businesses? </div>
|
||||
</h2>
|
||||
<div data-parent="#accordionExample" class="accordion-collapse collapse" id="collapse1" aria-labelledby="heading1">
|
||||
<div class="accordion-body" style="margin: 10px 20px; color: #666; font-size: 16px;"> Yes, the **Survey Extra Fields module** is highly scalable for growing businesses. It is built on Odoo's standard survey framework, ensuring smooth performance even with large datasets, multiple users, and complex surveys allowing your survey capabilities to expand seamlessly as your business grows. </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accordionExample" class="accordion ng-star-inserted" style="padding-top: 10px;">
|
||||
<div class="accordion-item" style="margin-bottom: 20px; border: 1px solid #ddd; border-radius: 6px; padding: 10px 0; background-color: transparent;">
|
||||
<h2 class="accordion-header ng-star-inserted" style="margin: 0px 20px;">
|
||||
<div type="button" data-bs-toggle="collapse" aria-expanded="true" class="accordion-button collapsed" style="background-color: transparent; border: none; box-shadow: none; font-weight: bold; display: flex; justify-content: space-between; width: 100%; font-size: 16px; padding: 10px 0; text-align: start;" data-bs-target="#collapse2" aria-controls="collapse2"> 3. I need some customization in this app. Is it possible? </div>
|
||||
</h2>
|
||||
<div data-parent="#accordionExample" class="accordion-collapse collapse" id="collapse2" aria-labelledby="heading2">
|
||||
<div class="accordion-body" style="margin: 10px 20px; color: #666; font-size: 16px;"> Yes, you can customize the module if you need additional features. We can also assist with the customization. Just email us at odoo-support@zehntech.com with your requirements. </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accordionExample" class="accordion ng-star-inserted" style="padding-top: 10px;">
|
||||
<div class="accordion-item" style="margin-bottom: 20px; border: 1px solid #ddd; border-radius: 6px; padding: 10px 0; background-color: transparent;">
|
||||
<h2 class="accordion-header ng-star-inserted" style="margin: 0px 20px;">
|
||||
<div type="button" data-bs-toggle="collapse" aria-expanded="true" class="accordion-button collapsed" style="background-color: transparent; border: none; box-shadow: none; font-weight: bold; display: flex; justify-content: space-between; width: 100%; font-size: 16px; padding: 10px 0; text-align: start;" data-bs-target="#collapse3" aria-controls="collapse3"> 4. Will I get lifetime updates for this app? </div>
|
||||
</h2>
|
||||
<div data-parent="#accordionExample" class="accordion-collapse collapse" id="collapse3" aria-labelledby="heading3">
|
||||
<div class="accordion-body" style="margin: 10px 20px; color: #666; font-size: 16px;"> Yes, after purchasing the app, you are eligible for lifetime updates. Check the app page for the changelog, and you can download the updated module from the same link as your original download. If you'd like us to send you update notifications, contact us at odoo-support@zehntech.com. </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div id="accordionExample" class="accordion ng-star-inserted" style="padding-top: 10px;">
|
||||
<div class="accordion-item" style="margin-bottom: 20px; border: 1px solid #ddd; border-radius: 6px; padding: 10px 0; background-color: transparent;">
|
||||
<h2 class="accordion-header ng-star-inserted" style="margin: 0px 20px;">
|
||||
<div type="button" data-bs-toggle="collapse" aria-expanded="true" class="accordion-button collapsed" style="background-color: transparent; border: none; box-shadow: none; font-weight: bold; display: flex; justify-content: space-between; width: 100%; font-size: 16px; padding: 10px 0; text-align: start;" data-bs-target="#collapse4" aria-controls="collapse4"> 5. I have more questions regarding this app. How do I contact you? </div>
|
||||
</h2>
|
||||
<div data-parent="#accordionExample" class="accordion-collapse collapse" id="collapse4" aria-labelledby="heading4">
|
||||
<div class="accordion-body" style="margin: 10px 20px; color: #666; font-size: 16px;"> Feel free to send us an email at odoo-support@zehntech.com with your questions, and we will get back to you as soon as possible. </div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="margin: auto; padding: 80px 20px; background-color: #000025;" id="services">
|
||||
<div class="position-absolute top-0 left-0" style="top: 0; left: 0;"><img src="images/1744786550977-services-blur-bg-img.png" style="width: 100%; height: 100%; object-fit: cover;"></div>
|
||||
<h2 style="color: white; font-size: 36px; font-weight: bold; margin-bottom: 30px; text-align: center;" class="ng-star-inserted">Our Services</h2>
|
||||
<div class="row" style="max-width: 1000px; margin: auto;">
|
||||
<div style="padding: 5px;" class="col-md-4 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: orange; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_customization.png" class="ng-star-inserted">
|
||||
<p>Odoo Customization</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 5px;" class="col-md-4 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: blue; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_migration.png" class="ng-star-inserted">
|
||||
<p>Odoo Migration Services</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 5px;" class="col-md-4 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: orange; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_implementation.png" class="ng-star-inserted">
|
||||
<p>Odoo Implementation</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 5px;" class="col-md-3 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: blue; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_support.png" class="ng-star-inserted">
|
||||
<p>Odoo Support & Maintenance</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 5px;" class="col-md-3 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: orange; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_web_development.png" class="ng-star-inserted">
|
||||
<p>Odoo Website Development</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 5px;" class="col-md-3 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: blue; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_theme_development.png" class="ng-star-inserted">
|
||||
<p>Odoo Theme Development</p>
|
||||
</div>
|
||||
</div>
|
||||
<div style="padding: 5px;" class="col-md-3 ng-star-inserted">
|
||||
<div class="position-relative ng-star-inserted" style="background-color: rgba(255, 255, 255, 0.1); padding: 10px; border-radius: 16px; border: 1px solid rgba(255, 255, 255, 0.3); color: white; height: 100%;">
|
||||
<div class="position-absolute top-0 end-0 translate-middle-x" style="width: 59px; height: 4px; background-color: orange; top: 0px; right: 0px; transform: translateX(-50%);"></div>
|
||||
<img style="width: 50px; height: 50px;" src="https://springboard.zehntech.com/assets/images/emerald-img/odoo_mobile_development.png" class="ng-star-inserted">
|
||||
<p>Odoo Mobile App Development</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="ng-star-inserted">
|
||||
<div style="background-color: #EBF1FF;" id="footer" class="ng-star-inserted">
|
||||
<div style="padding: 40px 60px;">
|
||||
<div class="position-relative flex-wrap d-flex justify-content-around gap-2" style="background-color: #0249FF; border-radius: 16px; color: white; padding: 40px; gap: 10px;">
|
||||
<div class="position-absolute top-0 end-0" style="top: 0; right: 0;"><img src="images/1741840319067-frame.png"></div>
|
||||
<div class="flex-1 text-break" style="flex: 1; padding: 0px 20px; word-break: break-word;">
|
||||
<h4 style="font-size: 24px; color: white;">Contact Us</h4>
|
||||
<p style="font-size: 14px;">Zehntech Technologies</p>
|
||||
<div class="d-flex align-items-center ng-star-inserted" style="display: flex; align-items: center;"><span style="margin-right: 8px;"><img src="images/1741840276122-ri_whatsapp-fill.png"></span><a target="_blank" style="color: white; text-decoration: none; font-size: 14px;" href="unsafe:(https://www.zehntech.com/contact-us/)">(https://www.zehntech.com/contact-us/)</a></div>
|
||||
</div>
|
||||
<div class="d-none d-md-block" style="width: 1px; height: 100px; background-color: #FFFFFF66; display: inline-block;"></div>
|
||||
<div class="flex-1 text-break" style="flex: 1; padding: 0px 20px; word-break: break-word;">
|
||||
<h4 style="font-size: 24px; color: white;">Support</h4>
|
||||
<p style="font-size: 14px;">Zehntech Odoo Support Email</p>
|
||||
<div class="d-flex align-items-center ng-star-inserted" style="display: flex; align-items: center;"><span style="margin-right: 8px;"><img src="images/1744786525119-mail-filled.png"></span><a style="color: white; text-decoration: none; font-size: 14px;" href="mailto:odoo-support@zehntech.com">odoo-support@zehntech.com</a></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -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('zehntech_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="zehntech_survey_extra_fields.question_color"/>
|
||||
<t t-if="question.question_type == 'email'" t-call="zehntech_survey_extra_fields.question_email"/>
|
||||
<t t-if="question.question_type == 'url'" t-call="zehntech_survey_extra_fields.question_url"/>
|
||||
<t t-if="question.question_type == 'time'" t-call="zehntech_survey_extra_fields.question_time"/>
|
||||
<t t-if="question.question_type == 'range'" t-call="zehntech_survey_extra_fields.question_range"/>
|
||||
<t t-if="question.question_type == 'week'" t-call="zehntech_survey_extra_fields.question_week"/>
|
||||
<t t-if="question.question_type == 'password'" t-call="zehntech_survey_extra_fields.question_password"/>
|
||||
<t t-if="question.question_type == 'file'" t-call="zehntech_survey_extra_fields.question_file"/>
|
||||
<t t-if="question.question_type == 'signature'" t-call="zehntech_survey_extra_fields.question_signature"/>
|
||||
<t t-if="question.question_type == 'month'" t-call="zehntech_survey_extra_fields.question_month"/>
|
||||
<t t-if="question.question_type == 'address'" t-call="zehntech_survey_extra_fields.question_address"/>
|
||||
<t t-if="question.question_type == 'name'" t-call="zehntech_survey_extra_fields.question_name"/>
|
||||
<t t-if="question.question_type == 'many2one'" t-call="zehntech_survey_extra_fields.question_many2one"/>
|
||||
<t t-if="question.question_type == 'many2many'" t-call="zehntech_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>
|
||||