# -*- coding: utf-8 -*- import json import base64 import logging import re import html from odoo import models, fields, api from odoo.exceptions import UserError, ValidationError _logger = logging.getLogger(__name__) class SurveyCustomCertificateWizard(models.TransientModel): _name = 'survey.custom.certificate.wizard' _description = 'Survey Custom Certificate Configuration Wizard' # Maximum allowed sizes for security MAX_CUSTOM_TEXT_LENGTH = 1000 MAX_PLACEHOLDER_KEY_LENGTH = 200 MAX_VALUE_FIELD_LENGTH = 200 # Reference to the survey being configured survey_id = fields.Many2one( 'survey.survey', string='Survey', required=True, ondelete='cascade', help='The survey for which the certificate template is being configured. ' 'Each survey can have its own custom certificate template with unique placeholder mappings.' ) # Template file fields template_file = fields.Binary( string='Certificate Template', help='Upload a Microsoft Word (.docx) file containing your certificate design. ' 'Use placeholders in the format {key.field_name} where you want dynamic data to appear. ' 'Example placeholders: {key.name}, {key.course_name}, {key.date}, {key.score}. ' 'Maximum file size: 10MB. The template should be created in Microsoft Word or LibreOffice.' ) template_filename = fields.Char( string='Filename', help='The original filename of the uploaded certificate template. ' 'This helps identify which template file is currently configured.' ) # Preview field preview_pdf = fields.Binary( string='Certificate Preview', help='A preview of your certificate with sample data filled in. ' 'This shows how the final certificate will look when generated for participants. ' 'Use this to verify that all placeholders are correctly mapped and the formatting is preserved.', readonly=True ) # One2many relation to placeholder mappings placeholder_ids = fields.One2many( 'survey.certificate.placeholder', 'wizard_id', string='Placeholder Mappings', help='Configure how each placeholder in your template should be filled with data. ' 'For each placeholder found in the template, you can choose to map it to: ' '(1) Survey fields (like survey title or description), ' '(2) Participant fields (like name, email, or completion date), or ' '(3) Custom text that will be the same for all certificates. ' 'The system automatically suggests mappings for common placeholder names.' ) # Flag to indicate if this is an update operation is_update = fields.Boolean( string='Is Update', default=False, help='Indicates whether this wizard is updating an existing template. ' 'When updating, existing placeholder mappings will be preserved for placeholders ' 'that exist in both the old and new templates.' ) @api.model def default_get(self, fields_list): """ Load existing template and mappings when opening wizard for a survey that already has a custom certificate configured. """ res = super(SurveyCustomCertificateWizard, self).default_get(fields_list) # Get survey_id from context survey_id = self.env.context.get('default_survey_id') if survey_id: survey = self.env['survey.survey'].browse(survey_id) # Check if survey has existing custom certificate if survey.has_custom_certificate and survey.custom_cert_template: # Load existing template res['template_file'] = survey.custom_cert_template res['template_filename'] = survey.custom_cert_template_filename res['is_update'] = True # Load existing mappings if survey.custom_cert_mappings: try: mappings = json.loads(survey.custom_cert_mappings) # We'll load the placeholder records after the wizard is created # Store mappings in context for later use self = self.with_context(existing_mappings=mappings) except (json.JSONDecodeError, TypeError) as e: _logger.warning( 'Failed to load existing mappings for survey %s: %s', survey_id, str(e) ) return res @api.model def create(self, vals): """ Override create to load existing placeholder mappings when updating. """ wizard = super(SurveyCustomCertificateWizard, self).create(vals) # If this is an update and we have existing mappings in context, load them existing_mappings = self.env.context.get('existing_mappings') if wizard.is_update and existing_mappings and wizard.template_file: try: # Parse the existing template to get placeholders wizard._load_existing_mappings(existing_mappings) except Exception as e: _logger.warning( 'Failed to load existing mappings during wizard creation: %s', str(e) ) return wizard def _load_existing_mappings(self, existing_mappings): """ Load existing placeholder mappings into the wizard. Args: existing_mappings: Dictionary containing existing placeholder mappings """ self.ensure_one() if not existing_mappings or 'placeholders' not in existing_mappings: return # Create placeholder records from existing mappings placeholder_records = [] for sequence, mapping in enumerate(existing_mappings['placeholders'], start=1): placeholder_records.append((0, 0, { 'source_key': mapping.get('key', ''), 'value_type': mapping.get('value_type', 'custom_text'), 'value_field': mapping.get('value_field', ''), 'custom_text': mapping.get('custom_text', ''), 'sequence': sequence, })) # Assign placeholder records to the wizard self.placeholder_ids = placeholder_records _logger.info( 'Loaded %d existing placeholder mappings for survey %s', len(placeholder_records), self.survey_id.id ) def action_upload_template(self): """ Handle template file upload and trigger placeholder parsing. This method is called when a user uploads a template file. It validates the file and extracts placeholders. Returns: dict: Action to reload the wizard form with parsed placeholders """ self.ensure_one() if not self.template_file: raise UserError('Please upload a template file before proceeding.') if not self.template_filename: raise UserError('Template filename is missing.') # Perform comprehensive file validation try: self._validate_template_file() except ValidationError as e: # Re-raise validation errors with user-friendly messages raise except Exception as e: _logger.error( 'Unexpected error during file validation for survey %s: %s', self.survey_id.id, str(e), exc_info=True ) raise UserError( f'An unexpected error occurred during file validation: {str(e)}' ) try: # Parse template and extract placeholders self._parse_template_placeholders() # Return action to reload the form return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', 'context': self.env.context, } except Exception as e: _logger.error( 'Failed to parse template for survey %s: %s', self.survey_id.id, str(e) ) raise UserError( f'Failed to parse template: {str(e)}\n\n' 'Please ensure the file is a valid DOCX document.' ) def _validate_template_file(self): """ Perform comprehensive validation on the uploaded template file. This method checks: - File extension and MIME type - File size limits - DOCX structure validity using python-docx Raises: ValidationError: If validation fails with user-friendly error message """ self.ensure_one() # Check file extension if not self.template_filename: raise ValidationError('Template filename is missing.') filename_lower = self.template_filename.lower() if not filename_lower.endswith('.docx'): raise ValidationError( 'Invalid file format. Only DOCX files are supported.\n\n' f'Uploaded file: {self.template_filename}\n' 'Please upload a file with .docx extension.' ) # Decode the binary file try: template_binary = base64.b64decode(self.template_file) except Exception as e: _logger.error('Failed to decode template file: %s', str(e)) raise ValidationError( 'Failed to read the uploaded file. The file may be corrupted.\n\n' 'Please try uploading the file again.' ) # Check file size (limit: 10MB) file_size_mb = len(template_binary) / (1024 * 1024) max_size_mb = 10 if file_size_mb > max_size_mb: raise ValidationError( f'File size exceeds the maximum allowed limit.\n\n' f'Uploaded file size: {file_size_mb:.2f} MB\n' f'Maximum allowed: {max_size_mb} MB\n\n' 'Please reduce the file size by:\n' '- Compressing images in the document\n' '- Removing unnecessary content\n' '- Simplifying formatting' ) _logger.info( 'Template file size: %.2f MB for survey %s', file_size_mb, self.survey_id.id ) # Validate DOCX structure using python-docx try: from ..services.certificate_template_parser import CertificateTemplateParser parser = CertificateTemplateParser() is_valid, error_message = parser.validate_template(template_binary) if not is_valid: raise ValidationError( f'The uploaded file is not a valid DOCX document.\n\n' f'Error: {error_message}\n\n' 'Please ensure you are uploading a valid Microsoft Word (.docx) file.\n' 'If the file was created in an older version of Word, try:\n' '1. Opening it in Microsoft Word or LibreOffice\n' '2. Saving it as a new .docx file\n' '3. Uploading the newly saved file' ) _logger.info( 'Template file validation successful for survey %s', self.survey_id.id ) except ImportError as e: error_msg = ( 'Template validation service is not available. ' 'Please ensure python-docx is installed.' ) _logger.error(error_msg) raise ValidationError(error_msg) except ValidationError: # Re-raise validation errors raise except Exception as e: _logger.error( 'Unexpected error during DOCX validation: %s', str(e), exc_info=True ) raise ValidationError( f'Failed to validate the DOCX file structure.\n\n' f'Error: {str(e)}\n\n' 'The file may be corrupted or in an unsupported format.' ) def _parse_template_placeholders(self): """ Parse the uploaded template and extract placeholders. This method uses the CertificateTemplateParser service to identify all placeholders in the template and creates placeholder records for mapping configuration. When updating an existing template, this method preserves mappings for placeholders that exist in both the old and new templates. Raises: UserError: If template parsing fails """ self.ensure_one() if not self.template_file: raise UserError('No template file to parse.') try: # Import the parser service from ..services.certificate_template_parser import CertificateTemplateParser # Decode the binary file template_binary = base64.b64decode(self.template_file) # Initialize parser and extract placeholders parser = CertificateTemplateParser() placeholders = parser.parse_template(template_binary) _logger.info( 'Extracted %d placeholders from template for survey %s', len(placeholders), self.survey_id.id ) # Build a mapping of existing placeholder configurations existing_mappings = {} if self.is_update and self.placeholder_ids: for placeholder in self.placeholder_ids: existing_mappings[placeholder.source_key] = { 'value_type': placeholder.value_type, 'value_field': placeholder.value_field, 'custom_text': placeholder.custom_text, } _logger.info( 'Preserving %d existing placeholder mappings for survey %s', len(existing_mappings), self.survey_id.id ) # Clear existing placeholder records self.placeholder_ids.unlink() # Create placeholder records with automatic field mapping or preserved mappings placeholder_records = [] preserved_count = 0 for sequence, placeholder_key in enumerate(placeholders, start=1): # Check if we have an existing mapping for this placeholder if placeholder_key in existing_mappings: # Preserve existing mapping existing = existing_mappings[placeholder_key] placeholder_records.append((0, 0, { 'source_key': placeholder_key, 'value_type': existing['value_type'], 'value_field': existing['value_field'] or '', 'custom_text': existing['custom_text'] or '', 'sequence': sequence, })) preserved_count += 1 else: # Attempt automatic field mapping for new placeholders value_type, value_field = self._auto_map_placeholder(placeholder_key) placeholder_records.append((0, 0, { 'source_key': placeholder_key, 'value_type': value_type, 'value_field': value_field, 'sequence': sequence, })) # Assign placeholder records to the wizard self.placeholder_ids = placeholder_records if self.is_update and preserved_count > 0: _logger.info( 'Preserved %d existing mappings out of %d total placeholders for survey %s', preserved_count, len(placeholders), self.survey_id.id ) except ImportError as e: error_msg = ( 'Template parser service is not available. ' 'Please ensure python-docx is installed.' ) _logger.error(error_msg) raise UserError(error_msg) except ValueError as e: # Parser raises ValueError for invalid templates raise UserError(str(e)) except Exception as e: _logger.error( 'Unexpected error parsing template: %s', str(e), exc_info=True ) raise UserError( f'An unexpected error occurred while parsing the template: {str(e)}' ) def _auto_map_placeholder(self, placeholder_key): """ Attempt to automatically map a placeholder to a data source. This method recognizes common placeholder patterns and maps them to appropriate survey or participant fields. It supports a wide range of standard field patterns for both survey data and participant data. Standard field patterns recognized: - Survey fields: title, course_name, course, description - Participant fields: name, email, date, score, etc. Args: placeholder_key: The placeholder string (e.g., "{key.name}") Returns: tuple: (value_type, value_field) where value_type is one of 'survey_field', 'user_field', or 'custom_text' """ # Extract the field name from the placeholder (e.g., "name" from "{key.name}") field_name = placeholder_key.replace('{key.', '').replace('}', '').lower() # Define mapping patterns for survey fields # These map to fields on the survey.survey model survey_field_patterns = { # Title variations 'title': 'survey_title', 'survey_title': 'survey_title', 'course_name': 'survey_title', 'course': 'survey_title', 'coursename': 'survey_title', 'survey_name': 'survey_title', 'surveyname': 'survey_title', # Description variations 'description': 'survey_description', 'survey_description': 'survey_description', 'course_description': 'survey_description', 'coursedescription': 'survey_description', } # Define mapping patterns for participant/user input fields # These map to fields on the survey.user_input model or related partner user_field_patterns = { # Name variations 'name': 'partner_name', 'participant_name': 'partner_name', 'participantname': 'partner_name', 'partner_name': 'partner_name', 'partnername': 'partner_name', 'student_name': 'partner_name', 'studentname': 'partner_name', 'user_name': 'partner_name', 'username': 'partner_name', 'fullname': 'partner_name', 'full_name': 'partner_name', # Email variations 'email': 'partner_email', 'participant_email': 'partner_email', 'participantemail': 'partner_email', 'partner_email': 'partner_email', 'partneremail': 'partner_email', 'student_email': 'partner_email', 'studentemail': 'partner_email', 'user_email': 'partner_email', 'useremail': 'partner_email', # Date variations 'date': 'completion_date', 'completion_date': 'completion_date', 'completiondate': 'completion_date', 'finish_date': 'completion_date', 'finishdate': 'completion_date', 'completed_date': 'completion_date', 'completeddate': 'completion_date', 'create_date': 'create_date', 'createdate': 'create_date', 'submission_date': 'create_date', 'submissiondate': 'create_date', # Score variations 'score': 'scoring_percentage', 'scoring_percentage': 'scoring_percentage', 'scoringpercentage': 'scoring_percentage', 'percentage': 'scoring_percentage', 'percent': 'scoring_percentage', 'grade': 'scoring_percentage', 'result': 'scoring_percentage', 'scoring_total': 'scoring_total', 'scoringtotal': 'scoring_total', 'total_score': 'scoring_total', 'totalscore': 'scoring_total', 'points': 'scoring_total', } # Check if it matches a survey field pattern if field_name in survey_field_patterns: return 'survey_field', survey_field_patterns[field_name] # Check if it matches a user field pattern if field_name in user_field_patterns: return 'user_field', user_field_patterns[field_name] # Default to custom text if no automatic mapping found # This allows users to manually configure unmapped placeholders return 'custom_text', '' def action_generate_preview(self): """ Generate a preview certificate with sample data. This method creates a sample certificate using the uploaded template and configured mappings, replacing placeholders with sample data. The preview is stored as a PDF in the wizard for display. Returns: dict: Action to reload the wizard form with the preview """ self.ensure_one() if not self.template_file: raise UserError('Please upload a template file before generating a preview.') if not self.placeholder_ids: raise UserError( 'No placeholders found in the template. ' 'Please ensure your template contains placeholders in the format {key.field_name}.' ) try: # Generate sample data for placeholders sample_data = self._generate_sample_data() # Build mappings dictionary mappings = { 'placeholders': [] } for placeholder in self.placeholder_ids: mapping = { 'key': placeholder.source_key, 'value_type': placeholder.value_type, 'value_field': placeholder.value_field or '', 'custom_text': placeholder.custom_text or '', } mappings['placeholders'].append(mapping) # Import the certificate generator from ..services.certificate_generator import CertificateGenerator # Decode the template binary template_binary = base64.b64decode(self.template_file) # Generate the preview certificate generator = CertificateGenerator() pdf_content = generator.generate_certificate( template_binary=template_binary, mappings=mappings, data=sample_data ) if pdf_content: # Store the preview PDF self.preview_pdf = base64.b64encode(pdf_content) _logger.info( 'Generated preview certificate for survey %s', self.survey_id.id ) else: raise UserError('Failed to generate preview certificate.') # Return action to reload the form with the preview return { 'type': 'ir.actions.act_window', 'res_model': self._name, 'res_id': self.id, 'view_mode': 'form', 'target': 'new', 'context': self.env.context, } except ImportError as e: error_msg = ( 'Certificate generator service is not available. ' 'Please ensure python-docx is installed.' ) _logger.error(error_msg) raise UserError(error_msg) except RuntimeError as e: # Handle LibreOffice-specific errors error_str = str(e) if 'LibreOffice' in error_str or 'PDF conversion' in error_str: # Notify administrators about LibreOffice issues using the new service try: from ..services.admin_notifier import AdminNotifier AdminNotifier.notify_libreoffice_unavailable( self.env, error_str, { 'survey_id': self.survey_id.id, 'survey_title': self.survey_id.title, 'operation': 'preview_generation' } ) except ImportError: # Fallback to old notification method self._notify_admins_libreoffice_error(error_str) except Exception as notify_error: _logger.warning( 'Failed to send admin notification: %s', str(notify_error) ) # Fallback to old notification method self._notify_admins_libreoffice_error(error_str) raise UserError( f'PDF conversion failed: {error_str}\n\n' 'System administrators have been notified. ' 'Please contact your administrator for assistance.' ) else: # Re-raise other runtime errors raise except Exception as e: _logger.error( 'Failed to generate preview for survey %s: %s', self.survey_id.id, str(e), exc_info=True ) raise UserError( f'Failed to generate preview: {str(e)}' ) def _notify_admins_libreoffice_error(self, error_message): """ Notify system administrators about LibreOffice errors. This method sends a notification to users in the 'Administration / Settings' group when LibreOffice-related errors occur. Args: error_message: The error message to include in the notification """ try: # Get admin users (users with Settings access) admin_group = self.env.ref('base.group_system', raise_if_not_found=False) if not admin_group: _logger.warning('Could not find admin group to notify about LibreOffice error') return admin_users = admin_group.users if not admin_users: _logger.warning('No admin users found to notify about LibreOffice error') return # Create notification message subject = 'Survey Certificate: LibreOffice Error' body = f"""

A LibreOffice error occurred while generating a certificate preview.

Survey: {self.survey_id.title}

Error: {error_message}

Please ensure LibreOffice is properly installed and accessible on the server.

To install LibreOffice:

After installation, restart the Odoo service.

""" # Send notification to each admin for admin in admin_users: try: self.env['mail.message'].create({ 'subject': subject, 'body': body, 'message_type': 'notification', 'subtype_id': self.env.ref('mail.mt_note').id, 'partner_ids': [(4, admin.partner_id.id)], 'model': self._name, 'res_id': self.id, }) except Exception as e: _logger.warning( 'Failed to send notification to admin %s: %s', admin.name, str(e) ) _logger.info( 'Notified %d administrators about LibreOffice error', len(admin_users) ) except Exception as e: _logger.error( 'Failed to notify administrators about LibreOffice error: %s', str(e), exc_info=True ) def _generate_sample_data(self): """ Generate sample data for certificate preview. This method creates realistic sample data for all possible placeholder fields, which will be used to generate the preview certificate. Returns: dict: Dictionary containing sample data for all fields """ from datetime import datetime # Generate sample data for all possible fields sample_data = { # Survey data 'survey_title': self.survey_id.title or 'Sample Course Title', 'survey_description': self.survey_id.description or 'Sample course description', # Participant data 'partner_name': 'John Doe', 'partner_email': 'john.doe@example.com', 'email': 'john.doe@example.com', # Completion data 'create_date': datetime.now().strftime('%Y-%m-%d'), 'completion_date': datetime.now().strftime('%Y-%m-%d'), # Score data 'scoring_percentage': '95.5', 'scoring_total': '100', } return sample_data @staticmethod def _sanitize_placeholder_value(value): """ Sanitize a placeholder value to prevent injection attacks. This method removes potentially dangerous characters and HTML tags from placeholder values to prevent XSS and injection attacks. 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 placeholder value to %d characters', max_length) return value @staticmethod def _validate_placeholder_key(key): """ Validate a placeholder key format. Placeholder keys must follow the pattern {key.field_name} where field_name contains only alphanumeric characters and underscores. Args: key: The placeholder key to validate Returns: bool: True if valid, False otherwise """ if not key: return False # Check length if len(key) > 200: return False # Check format: must match {key.field_name} pattern pattern = r'^\{key\.[a-zA-Z0-9_]+\}$' return bool(re.match(pattern, key)) @staticmethod def _validate_json_structure(json_string): """ Validate JSON structure for mappings. This method ensures the JSON string is valid and contains the expected structure for placeholder mappings. Args: json_string: JSON string to validate Returns: tuple: (is_valid, error_message) """ if not json_string: return False, 'JSON string is empty' try: # Parse JSON data = json.loads(json_string) # Check if it's a dictionary if not isinstance(data, dict): return False, 'JSON must be a dictionary/object' # Check for required 'placeholders' key if 'placeholders' not in data: return False, 'Missing required "placeholders" key' # Check if placeholders is a list if not isinstance(data['placeholders'], list): return False, '"placeholders" must be a list/array' # Validate each placeholder entry for idx, placeholder in enumerate(data['placeholders']): if not isinstance(placeholder, dict): return False, f'Placeholder at index {idx} must be a dictionary' # Check required fields required_fields = ['key', 'value_type'] for field in required_fields: if field not in placeholder: return False, f'Placeholder at index {idx} missing required field "{field}"' # Validate key format if not SurveyCustomCertificateWizard._validate_placeholder_key(placeholder['key']): return False, f'Invalid placeholder key format at index {idx}: {placeholder["key"]}' # Validate value_type valid_types = ['survey_field', 'user_field', 'custom_text'] if placeholder['value_type'] not in valid_types: return False, f'Invalid value_type at index {idx}: {placeholder["value_type"]}' # Validate field lengths if 'value_field' in placeholder and len(str(placeholder['value_field'])) > 200: return False, f'value_field too long at index {idx}' if 'custom_text' in placeholder and len(str(placeholder['custom_text'])) > 1000: return False, f'custom_text too long at index {idx}' return True, '' except json.JSONDecodeError as e: return False, f'Invalid JSON syntax: {str(e)}' except Exception as e: return False, f'Validation error: {str(e)}' def _validate_and_sanitize_placeholders(self): """ Validate and sanitize all placeholder records before saving. This method checks all placeholder records for: - Valid placeholder key format - Appropriate value types - Safe custom text values - Reasonable field lengths Raises: ValidationError: If validation fails """ self.ensure_one() if not self.placeholder_ids: return for placeholder in self.placeholder_ids: # Validate placeholder key format if not self._validate_placeholder_key(placeholder.source_key): raise ValidationError( f'Invalid placeholder key format: {placeholder.source_key}\n\n' 'Placeholder keys must follow the pattern {{key.field_name}} where ' 'field_name contains only letters, numbers, and underscores.' ) # Check key length if len(placeholder.source_key) > self.MAX_PLACEHOLDER_KEY_LENGTH: raise ValidationError( f'Placeholder key too long: {placeholder.source_key}\n\n' f'Maximum length: {self.MAX_PLACEHOLDER_KEY_LENGTH} characters' ) # 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: {placeholder.value_type}\n\n' f'Valid types: {", ".join(valid_types)}' ) # Validate and sanitize value_field if placeholder.value_field: if len(placeholder.value_field) > self.MAX_VALUE_FIELD_LENGTH: raise ValidationError( f'Value field name too long: {placeholder.value_field}\n\n' f'Maximum length: {self.MAX_VALUE_FIELD_LENGTH} characters' ) # Check for suspicious characters in field names if not re.match(r'^[a-zA-Z0-9_\.]+$', placeholder.value_field): raise ValidationError( f'Invalid characters in field name: {placeholder.value_field}\n\n' 'Field names can only contain letters, numbers, underscores, and dots.' ) # Validate and sanitize custom_text if placeholder.custom_text: if len(placeholder.custom_text) > self.MAX_CUSTOM_TEXT_LENGTH: raise ValidationError( f'Custom text too long for placeholder {placeholder.source_key}\n\n' f'Maximum length: {self.MAX_CUSTOM_TEXT_LENGTH} characters\n' f'Current length: {len(placeholder.custom_text)} characters' ) # Sanitize the custom text sanitized = self._sanitize_placeholder_value(placeholder.custom_text) if sanitized != placeholder.custom_text: _logger.info( 'Sanitized custom text for placeholder %s', placeholder.source_key ) placeholder.custom_text = sanitized def action_save_template(self): """ Save the template and mappings to the survey record. This method persists the uploaded template and configured placeholder mappings to the survey.survey record, making them available for certificate generation. Returns: dict: Action to close the wizard """ self.ensure_one() if not self.template_file: raise UserError('Please upload a template file before saving.') if not self.placeholder_ids: raise UserError( 'No placeholders found in the template. ' 'Please ensure your template contains placeholders in the format {key.field_name}.' ) # Validate and sanitize all placeholders before saving try: self._validate_and_sanitize_placeholders() except ValidationError as e: # Re-raise validation errors with context raise ValidationError( f'Validation failed:\n\n{str(e)}\n\n' 'Please correct the issues and try again.' ) try: # Serialize placeholder mappings to JSON mappings = { 'placeholders': [] } for placeholder in self.placeholder_ids: # Sanitize values before adding to mappings sanitized_custom_text = self._sanitize_placeholder_value( placeholder.custom_text or '' ) mapping = { 'key': placeholder.source_key, 'value_type': placeholder.value_type, 'value_field': placeholder.value_field or '', 'custom_text': sanitized_custom_text, } mappings['placeholders'].append(mapping) # Convert to JSON string mappings_json = json.dumps(mappings, indent=2) # Validate the JSON structure before saving is_valid, error_msg = self._validate_json_structure(mappings_json) if not is_valid: raise ValidationError( f'Invalid mappings structure: {error_msg}\n\n' 'This is an internal error. Please contact support.' ) # Update the survey record self.survey_id.write({ 'custom_cert_template': self.template_file, 'custom_cert_template_filename': self.template_filename, 'custom_cert_mappings': mappings_json, 'has_custom_certificate': True, }) _logger.info( 'Saved custom certificate template for survey %s with %d placeholder mappings', self.survey_id.id, len(self.placeholder_ids) ) # Return action to close the wizard return {'type': 'ir.actions.act_window_close'} except ValidationError: # Re-raise validation errors raise except Exception as e: _logger.error( 'Failed to save template for survey %s: %s', self.survey_id.id, str(e), exc_info=True ) raise UserError( f'Failed to save template configuration: {str(e)}' ) class SurveyCertificatePlaceholder(models.TransientModel): _name = 'survey.certificate.placeholder' _description = 'Certificate Template Placeholder' _order = 'sequence, id' # Reference to the wizard wizard_id = fields.Many2one( 'survey.custom.certificate.wizard', string='Wizard', required=True, ondelete='cascade', help='The configuration wizard this placeholder mapping belongs to. ' 'Placeholder mappings are temporary and only saved when you click the Save button.' ) # Placeholder source key from the template source_key = fields.Char( string='Source', required=True, help='The placeholder text found in your template (e.g., {key.name}, {key.course_name}). ' 'This is automatically detected from your DOCX file and cannot be edited. ' 'To change placeholders, you must edit the DOCX template file itself.' ) # Value type selection value_type = fields.Selection([ ('survey_field', 'Survey Field'), ('user_field', 'Participant Field'), ('custom_text', 'Custom Text'), ], string='Value Type', default='custom_text', required=True, help='Choose how this placeholder should be filled:\n' '• Survey Field: Use data from the survey itself (title, description)\n' '• Participant Field: Use data from the participant (name, email, completion date, score)\n' '• Custom Text: Enter static text that will be the same for all certificates\n\n' 'The system automatically suggests the appropriate type for common placeholder names.') # Field name for dynamic data value_field = fields.Char( string='Field', help='The specific field to use for dynamic data. Common options:\n' '• Survey fields: survey_title, survey_description\n' '• Participant fields: partner_name, partner_email, completion_date, scoring_percentage\n\n' 'This field is automatically populated for recognized placeholder names. ' 'Only visible when Value Type is "Survey Field" or "Participant Field".' ) # Custom text value custom_text = fields.Char( string='Custom Text', help='Enter the text that should appear in place of this placeholder. ' 'This text will be the same on all certificates generated from this template. ' 'Maximum length: 1000 characters. ' 'Only visible when Value Type is "Custom Text".' ) # Sequence for ordering sequence = fields.Integer( string='Sequence', default=10, help='Controls the display order of placeholders in the list. ' 'Lower numbers appear first. You can drag and drop rows to reorder them.' ) @api.constrains('source_key') def _check_source_key(self): """ Validate placeholder key format. Ensures the placeholder key follows the expected pattern and doesn't contain malicious content. """ for record in self: if not record.source_key: continue # Check length if len(record.source_key) > 200: raise ValidationError( f'Placeholder key too long: {record.source_key}\n' 'Maximum length: 200 characters' ) # Check format pattern = r'^\{key\.[a-zA-Z0-9_]+\}$' if not re.match(pattern, record.source_key): raise ValidationError( f'Invalid placeholder key format: {record.source_key}\n\n' 'Placeholder keys must follow the pattern {{key.field_name}} where ' 'field_name contains only letters, numbers, and underscores.' ) @api.constrains('value_field') def _check_value_field(self): """ Validate value_field format to prevent injection attacks. """ for record in self: if not record.value_field: continue # Check length if len(record.value_field) > 200: raise ValidationError( f'Value field name too long: {record.value_field}\n' 'Maximum length: 200 characters' ) # Check for suspicious characters if not re.match(r'^[a-zA-Z0-9_\.]+$', record.value_field): raise ValidationError( f'Invalid characters in field name: {record.value_field}\n\n' 'Field names can only contain letters, numbers, underscores, and dots.' ) @api.constrains('custom_text') def _check_custom_text(self): """ Validate custom_text length and content. """ for record in self: if not record.custom_text: continue # Check length if len(record.custom_text) > 1000: raise ValidationError( f'Custom text too long for placeholder {record.source_key}\n' 'Maximum length: 1000 characters\n' f'Current length: {len(record.custom_text)} characters' ) @api.onchange('value_type') def _onchange_value_type(self): """Clear inappropriate fields when value_type changes.""" if self.value_type == 'custom_text': self.value_field = '' else: self.custom_text = ''