survey_custom_certificate_t.../wizards/survey_custom_certificate_wizard.py
2025-11-29 08:46:04 +07:00

1210 lines
48 KiB
Python

# -*- 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"""
<p>A LibreOffice error occurred while generating a certificate preview.</p>
<p><strong>Survey:</strong> {self.survey_id.title}</p>
<p><strong>Error:</strong> {error_message}</p>
<p>Please ensure LibreOffice is properly installed and accessible on the server.</p>
<p>To install LibreOffice:</p>
<ul>
<li>Ubuntu/Debian: <code>sudo apt-get install libreoffice</code></li>
<li>CentOS/RHEL: <code>sudo yum install libreoffice</code></li>
<li>macOS: <code>brew install --cask libreoffice</code></li>
</ul>
<p>After installation, restart the Odoo service.</p>
"""
# 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 = ''