# -*- 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