1210 lines
48 KiB
Python
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 = ''
|