first push message

This commit is contained in:
2026-07-01 14:41:49 +07:00
parent 6667dec2bf
commit 58b5f46cc4
2951 changed files with 316619 additions and 0 deletions
+4
View File
@@ -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()