518 lines
21 KiB
Python
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
|