survey_custom_certificate_t.../models/survey_survey.py
2025-11-29 08:46:04 +07:00

518 lines
21 KiB
Python

# -*- coding: utf-8 -*-
import json
import logging
import re
import html
from odoo import models, fields, api
from odoo.exceptions import UserError, ValidationError
_logger = logging.getLogger(__name__)
class SurveySurvey(models.Model):
_inherit = 'survey.survey'
# Custom certificate template fields
custom_cert_template = fields.Binary(
string='Custom Certificate Template',
help='The uploaded Microsoft Word (.docx) file containing your custom certificate design. '
'This template includes placeholders (e.g., {key.name}, {key.date}) that are automatically '
'replaced with participant and survey data when certificates are generated. '
'To modify the template, use the "Upload Custom Certificate" button to upload a new version. '
'Maximum file size: 10MB.',
groups='survey.group_survey_manager'
)
custom_cert_template_filename = fields.Char(
string='Template Filename',
help='The original filename of the uploaded certificate template (e.g., "Certificate_Template.docx"). '
'This helps identify which template file is currently configured for this survey.',
groups='survey.group_survey_manager'
)
custom_cert_mappings = fields.Text(
string='Certificate Mappings',
help='JSON data structure storing the configuration of how placeholders in the template '
'are mapped to data sources (survey fields, participant fields, or custom text). '
'This is automatically managed by the certificate configuration wizard. '
'Do not edit this field manually as it may cause certificate generation to fail.',
groups='survey.group_survey_manager'
)
has_custom_certificate = fields.Boolean(
string='Has Custom Certificate',
default=False,
help='Indicates whether this survey has a custom certificate template configured. '
'When True, certificates will be generated using the custom template instead of '
'the default Odoo certificate layouts. This flag is automatically set when you '
'upload and save a custom certificate template.',
groups='survey.group_survey_user'
)
# Extend certification_report_layout selection to add custom option
certification_report_layout = fields.Selection(
selection_add=[('custom', 'Custom Template')],
ondelete={'custom': 'set default'}
)
@api.constrains('custom_cert_mappings')
def _check_custom_cert_mappings(self):
"""
Validate the JSON structure of custom certificate mappings.
This constraint ensures that the mappings field contains valid JSON
with the expected structure, preventing data corruption and
potential security issues.
"""
for record in self:
if not record.custom_cert_mappings:
continue
try:
# Parse JSON
mappings = json.loads(record.custom_cert_mappings)
# Validate structure
if not isinstance(mappings, dict):
raise ValidationError(
'Certificate mappings must be a JSON object/dictionary'
)
if 'placeholders' not in mappings:
raise ValidationError(
'Certificate mappings must contain a "placeholders" key'
)
if not isinstance(mappings['placeholders'], list):
raise ValidationError(
'Certificate mappings "placeholders" must be a list/array'
)
# Validate each placeholder
for idx, placeholder in enumerate(mappings['placeholders']):
if not isinstance(placeholder, dict):
raise ValidationError(
f'Placeholder at index {idx} must be a dictionary'
)
# Check required fields
if 'key' not in placeholder:
raise ValidationError(
f'Placeholder at index {idx} missing required "key" field'
)
if 'value_type' not in placeholder:
raise ValidationError(
f'Placeholder at index {idx} missing required "value_type" field'
)
# Validate key format
key = placeholder['key']
if not re.match(r'^\{key\.[a-zA-Z0-9_]+\}$', key):
raise ValidationError(
f'Invalid placeholder key format at index {idx}: {key}'
)
# Validate value_type
valid_types = ['survey_field', 'user_field', 'custom_text']
if placeholder['value_type'] not in valid_types:
raise ValidationError(
f'Invalid value_type at index {idx}: {placeholder["value_type"]}'
)
# Validate field lengths
if 'value_field' in placeholder:
value_field = str(placeholder['value_field'])
if len(value_field) > 200:
raise ValidationError(
f'value_field too long at index {idx} (max 200 characters)'
)
# Check for suspicious characters
if value_field and not re.match(r'^[a-zA-Z0-9_\.]*$', value_field):
raise ValidationError(
f'Invalid characters in value_field at index {idx}'
)
if 'custom_text' in placeholder:
custom_text = str(placeholder['custom_text'])
if len(custom_text) > 1000:
raise ValidationError(
f'custom_text too long at index {idx} (max 1000 characters)'
)
except json.JSONDecodeError as e:
raise ValidationError(
f'Invalid JSON in certificate mappings: {str(e)}'
)
except ValidationError:
# Re-raise validation errors
raise
except Exception as e:
_logger.error(
'Unexpected error validating certificate mappings: %s',
str(e), exc_info=True
)
raise ValidationError(
f'Error validating certificate mappings: {str(e)}'
)
@staticmethod
def _sanitize_certificate_value(value):
"""
Sanitize a value before using it in certificate generation.
This method removes potentially dangerous characters and HTML tags
to prevent injection attacks in generated certificates.
Args:
value: The value to sanitize (string)
Returns:
str: Sanitized value safe for use in documents
"""
if not value:
return ''
# Convert to string if not already
value = str(value)
# HTML escape to prevent XSS
value = html.escape(value)
# Remove control characters except newlines and tabs
value = re.sub(r'[\x00-\x08\x0B-\x0C\x0E-\x1F\x7F]', '', value)
# Remove any remaining HTML-like tags
value = re.sub(r'<[^>]*>', '', value)
# Limit length to prevent DoS
max_length = 10000
if len(value) > max_length:
value = value[:max_length]
_logger.warning('Truncated certificate value to %d characters', max_length)
return value
def action_open_custom_certificate_wizard(self):
"""
Opens the custom certificate configuration wizard.
Returns:
dict: Action dictionary to open the wizard
"""
self.ensure_one()
return {
'name': 'Configure Custom Certificate',
'type': 'ir.actions.act_window',
'res_model': 'survey.custom.certificate.wizard',
'view_mode': 'form',
'target': 'new',
'context': {
'default_survey_id': self.id,
}
}
def action_delete_custom_certificate(self):
"""
Delete the custom certificate template and revert to default template selection.
This method clears all custom certificate fields and resets the
certification_report_layout to a default value.
Returns:
dict: Action to reload the form view
"""
self.ensure_one()
if not self.has_custom_certificate:
raise UserError('No custom certificate template to delete.')
# Clear all custom certificate fields
self.write({
'custom_cert_template': False,
'custom_cert_template_filename': False,
'custom_cert_mappings': False,
'has_custom_certificate': False,
'certification_report_layout': False, # Revert to default (no selection)
})
_logger.info(
'Deleted custom certificate template for survey %s',
self.id
)
# Return action to reload the form
return {
'type': 'ir.actions.client',
'tag': 'display_notification',
'params': {
'title': 'Template Deleted',
'message': 'Custom certificate template has been deleted successfully.',
'type': 'success',
'sticky': False,
}
}
def _get_certificate_data(self, user_input_id):
"""
Retrieves all data needed for certificate generation for a specific participant.
This method safely extracts data from the survey and user input records,
using empty strings for any missing or unavailable data to prevent
generation failures.
Args:
user_input_id: ID of the survey.user_input record
Returns:
dict: Dictionary containing all available data for placeholder replacement
Missing data is represented as empty strings
"""
self.ensure_one()
# Initialize data dictionary with empty defaults
data = {
'survey_title': '',
'survey_description': '',
'partner_name': '',
'partner_email': '',
'email': '',
'create_date': '',
'completion_date': '',
'scoring_percentage': '',
'scoring_total': '',
}
try:
# Get the user input record
user_input = self.env['survey.user_input'].browse(user_input_id)
if not user_input.exists():
_logger.warning(
'Survey response %s not found, using empty data',
user_input_id
)
return data
# Safely extract survey data with sanitization
try:
data['survey_title'] = self._sanitize_certificate_value(self.title or '')
except Exception as e:
_logger.warning('Failed to get survey title: %s', str(e))
try:
data['survey_description'] = self._sanitize_certificate_value(self.description or '')
except Exception as e:
_logger.warning('Failed to get survey description: %s', str(e))
# Safely extract participant data with sanitization
try:
if user_input.partner_id:
data['partner_name'] = self._sanitize_certificate_value(
user_input.partner_id.name or ''
)
data['partner_email'] = self._sanitize_certificate_value(
user_input.partner_id.email or ''
)
except Exception as e:
_logger.warning('Failed to get partner data: %s', str(e))
try:
data['email'] = self._sanitize_certificate_value(user_input.email or '')
except Exception as e:
_logger.warning('Failed to get email: %s', str(e))
# Safely extract completion data with sanitization
try:
if user_input.create_date:
date_str = user_input.create_date.strftime('%Y-%m-%d')
data['create_date'] = self._sanitize_certificate_value(date_str)
data['completion_date'] = self._sanitize_certificate_value(date_str)
except Exception as e:
_logger.warning('Failed to get completion date: %s', str(e))
# Safely extract score data (if applicable) with sanitization
try:
if hasattr(user_input, 'scoring_percentage') and user_input.scoring_percentage:
data['scoring_percentage'] = self._sanitize_certificate_value(
str(user_input.scoring_percentage)
)
except Exception as e:
_logger.warning('Failed to get scoring percentage: %s', str(e))
try:
if hasattr(user_input, 'scoring_total') and user_input.scoring_total:
data['scoring_total'] = self._sanitize_certificate_value(
str(user_input.scoring_total)
)
except Exception as e:
_logger.warning('Failed to get scoring total: %s', str(e))
# Log data retrieval
fields_populated = sum(1 for v in data.values() if v)
total_fields = len(data)
try:
from ..services.certificate_logger import CertificateLogger
CertificateLogger.log_data_retrieval(
user_input_id,
fields_populated,
total_fields
)
except ImportError:
_logger.debug(
'Retrieved certificate data for user_input %s: %d fields populated',
user_input_id, fields_populated
)
except Exception as e:
_logger.error(
'Unexpected error retrieving certificate data for user_input %s: %s',
user_input_id, str(e), exc_info=True
)
# Return data with empty strings rather than failing
return data
def _generate_custom_certificate(self, user_input_id):
"""
Generates a custom certificate for a participant using the configured template.
This method includes comprehensive error handling to prevent system crashes
and ensure the survey completion workflow continues even if certificate
generation fails.
Args:
user_input_id: ID of the survey.user_input record
Returns:
bytes: PDF certificate content, or None if generation fails
"""
self.ensure_one()
try:
# Check if custom certificate is configured
if not self.has_custom_certificate or not self.custom_cert_template:
_logger.warning(
'Custom certificate generation requested for survey %s but no template configured',
self.id
)
return None
# Check if mappings are configured
if not self.custom_cert_mappings:
_logger.warning(
'Custom certificate template exists for survey %s but no mappings configured',
self.id
)
return None
# Parse mappings from JSON
try:
mappings = json.loads(self.custom_cert_mappings)
# Validate mappings structure
if not isinstance(mappings, dict) or 'placeholders' not in mappings:
_logger.error(
'Invalid mappings structure for survey %s: missing "placeholders" key',
self.id
)
return None
if not isinstance(mappings['placeholders'], list):
_logger.error(
'Invalid mappings structure for survey %s: "placeholders" must be a list',
self.id
)
return None
except (json.JSONDecodeError, TypeError) as e:
_logger.error(
'Failed to parse certificate mappings for survey %s: %s',
self.id, str(e), exc_info=True
)
return None
# Get certificate data (this method handles missing data gracefully)
try:
data = self._get_certificate_data(user_input_id)
if not data:
_logger.warning(
'No certificate data retrieved for user_input %s, using empty data',
user_input_id
)
data = {}
except Exception as e:
_logger.error(
'Failed to retrieve certificate data for user_input %s: %s',
user_input_id, str(e), exc_info=True
)
# Use empty data rather than failing
data = {}
# Generate certificate using the certificate generator service
try:
from ..services.certificate_generator import CertificateGenerator
generator = CertificateGenerator()
# Attempt certificate generation
pdf_content = generator.generate_certificate(
template_binary=self.custom_cert_template,
mappings=mappings,
data=data
)
if not pdf_content:
_logger.error(
'Certificate generation returned no content for user_input %s',
user_input_id
)
return None
_logger.info(
'Successfully generated certificate for user_input %s (size: %d bytes)',
user_input_id, len(pdf_content)
)
return pdf_content
except ImportError as e:
_logger.error(
'CertificateGenerator service not available: %s',
str(e), exc_info=True
)
return None
except ValueError as e:
# ValueError is raised for validation errors (invalid template, missing data, etc.)
_logger.error(
'Certificate generation validation error for user_input %s: %s',
user_input_id, str(e)
)
return None
except RuntimeError as e:
# RuntimeError is raised for LibreOffice and conversion errors
_logger.error(
'Certificate generation runtime error for user_input %s: %s',
user_input_id, str(e)
)
return None
except Exception as e:
# Catch all other exceptions to prevent system crashes
_logger.error(
'Unexpected error generating certificate for user_input %s: %s',
user_input_id, str(e), exc_info=True
)
return None
except Exception as e:
# Outer exception handler to catch any errors in the entire method
_logger.error(
'Critical error in _generate_custom_certificate for user_input %s: %s',
user_input_id, str(e), exc_info=True
)
return None