# -*- coding: utf-8 -*- # Part of Odoo. See LICENSE file for full copyright and licensing details. import logging from odoo import api, fields, models, _ from odoo.exceptions import UserError _logger = logging.getLogger(__name__) class HelpdeskTemplate(models.Model): _name = 'helpdesk.template' _description = 'Helpdesk Template' _order = 'name' name = fields.Char( string='Name', required=True, translate=True, help="Name of the template" ) description = fields.Text( string='Description', translate=True, help="Description of the template" ) active = fields.Boolean( string='Active', default=True, help="If unchecked, this template will be hidden and won't be available" ) field_ids = fields.One2many( 'helpdesk.template.field', 'template_id', string='Fields', copy=True, help="Fields included in this template" ) @api.model def default_get(self, fields_list): """Set default required fields when creating a new template""" res = super().default_get(fields_list) # Only set defaults if creating a new record (not editing existing) if 'field_ids' in fields_list and not self.env.context.get('default_field_ids'): # Get the required fields from form builder (same as website_helpdesk_form_editor.js) required_field_names = ['partner_name', 'partner_email', 'name', 'description'] # Get field records ticket_model = self.env['ir.model'].search([('model', '=', 'helpdesk.ticket')], limit=1) if ticket_model: # Find the field records required_fields = self.env['ir.model.fields'].search([ ('model_id', '=', ticket_model.id), ('name', 'in', required_field_names), ('website_form_blacklisted', '=', False) ]) # Create field mapping field_map = {f.name: f for f in required_fields} # Prepare default field_ids field_ids_commands = [] sequence = 10 # Add partner_name (required: true, sequence 10) if 'partner_name' in field_map: field_ids_commands.append((0, 0, { 'field_id': field_map['partner_name'].id, 'required': True, 'sequence': sequence })) sequence += 10 # Add partner_email (required: true, sequence 20) if 'partner_email' in field_map: field_ids_commands.append((0, 0, { 'field_id': field_map['partner_email'].id, 'required': True, 'sequence': sequence })) sequence += 10 # Add name (modelRequired: true, sequence 30) - required by model # Note: model_required will be set automatically in create() based on field.required if 'name' in field_map: name_field = field_map['name'] field_ids_commands.append((0, 0, { 'field_id': name_field.id, 'required': True, # Mark as required since it's modelRequired 'model_required': name_field.required, # Auto-detect from field definition 'sequence': sequence })) sequence += 10 # Add description (required: true, sequence 40) if 'description' in field_map: field_ids_commands.append((0, 0, { 'field_id': field_map['description'].id, 'required': True, 'sequence': sequence })) sequence += 10 if field_ids_commands: res['field_ids'] = field_ids_commands _logger.info(f"Setting default required fields for new template: {[cmd[2]['field_id'] for cmd in field_ids_commands]}") return res @api.model_create_multi def create(self, vals_list): """Override create to automatically add required fields from form builder""" # Get the required fields from form builder (same as website_helpdesk_form_editor.js) # These are the fields that are always required in the form builder: # - partner_name (required: true) # - partner_email (required: true) # - name (modelRequired: true) - required by the model # - description (required: true) required_field_names = ['partner_name', 'partner_email', 'name', 'description'] # Get field records ticket_model = self.env['ir.model'].search([('model', '=', 'helpdesk.ticket')], limit=1) if not ticket_model: return super().create(vals_list) # Find the field records required_fields = self.env['ir.model.fields'].search([ ('model_id', '=', ticket_model.id), ('name', 'in', required_field_names), ('website_form_blacklisted', '=', False) ]) # Create field mapping field_map = {f.name: f for f in required_fields} # Prepare default field_ids for each template for vals in vals_list: if 'field_ids' not in vals or not vals.get('field_ids'): # Only add default fields if no fields are provided field_ids_commands = [] sequence = 10 # Add partner_name (required: true, sequence 10) if 'partner_name' in field_map: field_ids_commands.append((0, 0, { 'field_id': field_map['partner_name'].id, 'required': True, 'sequence': sequence })) sequence += 10 # Add partner_email (required: true, sequence 20) if 'partner_email' in field_map: field_ids_commands.append((0, 0, { 'field_id': field_map['partner_email'].id, 'required': True, 'sequence': sequence })) sequence += 10 # Add name (modelRequired: true, sequence 30) - required by model # Note: model_required will be set automatically in create() based on field.required if 'name' in field_map: name_field = field_map['name'] field_ids_commands.append((0, 0, { 'field_id': name_field.id, 'required': True, # Mark as required since it's modelRequired 'model_required': name_field.required, # Auto-detect from field definition 'sequence': sequence })) sequence += 10 # Add description (required: true, sequence 40) if 'description' in field_map: field_ids_commands.append((0, 0, { 'field_id': field_map['description'].id, 'required': True, 'sequence': sequence })) sequence += 10 if field_ids_commands: vals['field_ids'] = field_ids_commands _logger.info(f"Adding default required fields to new template: {[cmd[2]['field_id'] for cmd in field_ids_commands]}") return super().create(vals_list) def write(self, vals): """Override write to regenerate forms in all teams using this template""" result = super().write(vals) # If template fields or active status changed, regenerate forms in all teams using this template # Note: field_ids changes are handled by helpdesk.template.field create/write/unlink methods # but we also check here in case field_ids is directly modified if 'field_ids' in vals or 'active' in vals: # Find all teams using this template teams = self.env['helpdesk.team'].search([ ('template_id', 'in', self.ids), ('use_website_helpdesk_form', '=', True) ]) # Regenerate form XML for each team for team in teams: # Ensure view exists before regenerating if not team.website_form_view_id: team._ensure_submit_form_view() # Regenerate or restore form based on template status if team.website_form_view_id: try: if team.template_id.active: team._regenerate_form_from_template() _logger.info(f"Regenerated form for team {team.id} after template {team.template_id.id} change") else: # If template is deactivated, restore default form team._restore_default_form() _logger.info(f"Restored default form for team {team.id} after template {team.template_id.id} deactivation") except Exception as e: _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True) return result class HelpdeskTemplateField(models.Model): _name = 'helpdesk.template.field' _description = 'Helpdesk Template Field' _order = 'sequence, id' template_id = fields.Many2one( 'helpdesk.template', string='Template', required=True, ondelete='cascade', index=True ) field_id = fields.Many2one( 'ir.model.fields', string='Field', required=True, domain="[('model', '=', 'helpdesk.ticket'), ('website_form_blacklisted', '=', False)]", ondelete='cascade', help="Field from helpdesk.ticket model" ) field_name = fields.Char( related='field_id.name', string='Field Name', store=True, readonly=True ) field_type = fields.Selection( related='field_id.ttype', string='Field Type', readonly=True ) label_custom = fields.Char( string='Custom Label', help="Custom label for the field in the form. If empty, uses the field's default label." ) placeholder = fields.Text( string='Placeholder', help="Placeholder text shown when field is empty" ) default_value = fields.Char( string='Default Value', help="Default value for the field" ) help_text = fields.Html( string='Help Text', help="Help text/description shown below the field (supports HTML formatting)" ) widget = fields.Selection( [ ('default', 'Default'), ('radio', 'Radio Buttons'), ('checkbox', 'Checkboxes'), ], string='Widget', default='default', help="Widget to use for selection/many2one fields. Default uses dropdown select." ) selection_type = fields.Selection( [ ('dropdown', 'Dropdown List'), ('radio', 'Radio'), ], string='Selection Type', default='dropdown', help="Display type for selection and many2one fields. Dropdown List shows a select dropdown, Radio shows radio buttons. Same as Odoo formbuilder." ) selection_options = fields.Text( string='Selection Options', help="For selection fields (not relations): JSON array of [value, label] pairs. Example: [['option1', 'Option 1'], ['option2', 'Option 2']]" ) rows = fields.Integer( string='Height (Rows)', default=3, help="Number of rows for textarea fields. Default is 3." ) input_type = fields.Selection( [ ('text', 'Text'), ('email', 'Email'), ('tel', 'Telephone'), ('url', 'Url'), ], string='Input Type', default='text', help="Input type for text fields. Determines the HTML input type attribute." ) sequence = fields.Integer( string='Sequence', default=10, help="Order in which fields are displayed" ) required = fields.Boolean( string='Required', default=False, help="Make this field required in addition to its base configuration" ) model_required = fields.Boolean( string='Model Required', default=False, readonly=True, help="This field is mandatory for the model and cannot be removed" ) # Visibility conditions visibility_dependency = fields.Many2one( 'ir.model.fields', string='Visibility Dependency', domain="[('model', '=', 'helpdesk.ticket'), ('website_form_blacklisted', '=', False)]", help="Field on which visibility depends" ) visibility_condition = fields.Char( string='Visibility Condition Value', help="Value to compare against the dependency field (for text, number, date, etc.)" ) visibility_comparator = fields.Selection( [ # Basic comparators ('equal', 'Is equal to'), ('!equal', 'Is not equal to'), ('contains', 'Contains'), ('!contains', "Doesn't contain"), ('set', 'Is set'), ('!set', 'Is not set'), # Numeric comparators ('greater', 'Is greater than'), ('less', 'Is less than'), ('greater or equal', 'Is greater than or equal to'), ('less or equal', 'Is less than or equal to'), # Date/Datetime comparators ('dateEqual', 'Is equal to (date)'), ('date!equal', 'Is not equal to (date)'), ('after', 'Is after'), ('before', 'Is before'), ('equal or after', 'Is after or equal to'), ('equal or before', 'Is before or equal to'), ('between', 'Is between (included)'), ('!between', 'Is not between (excluded)'), # Selection/Many2one comparators ('selected', 'Is equal to (selected)'), ('!selected', 'Is not equal to (not selected)'), # File comparators ('fileSet', 'Is set (file)'), ('!fileSet', 'Is not set (file)'), ], string='Visibility Comparator', default='equal', help="Comparison operator for visibility condition" ) # Computed field to determine dependency field type visibility_dependency_type = fields.Char( string='Dependency Field Type', compute='_compute_visibility_dependency_type', store=False, help="Type of the visibility dependency field" ) # Field for many2one dependency - store ID as Integer (not Many2one to avoid model validation) # The widget will handle the dynamic model change and display visibility_condition_m2o_id = fields.Integer( string='Visibility Condition (Many2one ID)', help="ID of the selected record when dependency is a many2one field (model stored separately)" ) visibility_condition_m2o_model = fields.Char( string='M2O Model', related='visibility_dependency.relation', store=False, readonly=True, help="Model name for the many2one condition" ) # Field for selection dependency - computed selection options visibility_condition_selection = fields.Selection( selection='_get_visibility_condition_selection_options', string='Visibility Condition (Selection)', help="Selected value when dependency is a selection field" ) # Field for range conditions (between/!between) - second value for date/datetime ranges visibility_between = fields.Char( string='Visibility Between (End Value)', help="Second value for 'between' and '!between' comparators (for date/datetime ranges)" ) def _get_visibility_condition_selection_options(self): """Return selection options based on visibility_dependency field""" # Handle empty recordset (when called from fields_get) if not self: return [] # Handle multiple records (shouldn't happen, but be safe) if len(self) > 1: return [] record = self[0] if self else None if not record or not record.visibility_dependency or record.visibility_dependency.ttype != 'selection': return [] # Get selection options from ir.model.fields.selection selection_records = self.env['ir.model.fields.selection'].search([ ('field_id', '=', record.visibility_dependency.id) ], order='sequence, id') if selection_records: return [(sel.value, sel.name) for sel in selection_records] # Fallback: try to get from field definition (for old-style selection) try: model = self.env[record.visibility_dependency.model] field = model._fields.get(record.visibility_dependency.name) if field and hasattr(field, 'selection') and field.selection: if callable(field.selection): return field.selection(model) return field.selection except: pass return [] @api.depends('visibility_dependency') def _compute_visibility_dependency_type(self): """Compute the type of the visibility dependency field""" for record in self: if record.visibility_dependency: record.visibility_dependency_type = record.visibility_dependency.ttype else: record.visibility_dependency_type = False @api.onchange('visibility_condition_m2o_id', 'visibility_dependency') def _onchange_visibility_condition_m2o_id(self): """Sync many2one ID to visibility_condition""" if self.visibility_dependency and self.visibility_dependency.ttype == 'many2one': if self.visibility_condition_m2o_id: self.visibility_condition = str(self.visibility_condition_m2o_id) else: self.visibility_condition = False @api.onchange('visibility_condition_selection') def _onchange_visibility_condition_selection(self): """Sync selection value to visibility_condition""" if self.visibility_condition_selection: self.visibility_condition = self.visibility_condition_selection @api.onchange('visibility_dependency') def _onchange_visibility_dependency(self): """Clear condition values when dependency changes""" if not self.visibility_dependency: self.visibility_condition = False self.visibility_condition_m2o_id = False self.visibility_condition_selection = False elif self.visibility_dependency.ttype not in ['many2one', 'selection']: self.visibility_condition_m2o_id = False self.visibility_condition_selection = False elif self.visibility_dependency.ttype == 'many2one': # Load current value into m2o_id if exists if self.visibility_condition and self.visibility_condition.isdigit(): try: model_name = self.visibility_dependency.relation if model_name: model = self.env[model_name] record = model.browse(int(self.visibility_condition)) if record.exists(): # Store the ID - the widget will handle the model change self.visibility_condition_m2o_id = int(self.visibility_condition) else: self.visibility_condition_m2o_id = False else: self.visibility_condition_m2o_id = False except: self.visibility_condition_m2o_id = False elif self.visibility_dependency.ttype == 'selection': # Load current value into selection if exists if self.visibility_condition: self.visibility_condition_selection = self.visibility_condition @api.onchange('visibility_comparator') def _onchange_visibility_comparator(self): """Clear visibility_between when comparator changes away from between/!between""" if self.visibility_comparator not in ['between', '!between']: self.visibility_between = False _sql_constraints = [ ('unique_template_field', 'unique(template_id, field_id)', 'A field can only be added once to a template') ] @api.model def _register_hook(self): """Register label_custom field in ir.model.fields if it doesn't exist""" super()._register_hook() try: model = self.env['ir.model'].search([('model', '=', 'helpdesk.template.field')], limit=1) if model: field_model = self.env['ir.model.fields'] existing_field = field_model.search([ ('model_id', '=', model.id), ('name', '=', 'label_custom') ], limit=1) if not existing_field: field_model.create({ 'model_id': model.id, 'name': 'label_custom', 'field_description': 'Custom Label', 'ttype': 'char', 'state': 'manual', 'required': False, 'readonly': False, 'store': True, }) _logger.info("Campo label_custom registrado en _register_hook") except Exception as e: _logger.error(f"Error registrando label_custom en _register_hook: {str(e)}", exc_info=True) @api.model def _migrate_label_custom_field(self): """ Migration method to ensure label_custom field exists in database. This method should be called after module update to fix any missing field issues. """ try: # Check if column exists in database self.env.cr.execute(""" SELECT column_name FROM information_schema.columns WHERE table_name = 'helpdesk_template_field' AND column_name = 'label_custom' """) column_exists = self.env.cr.fetchone() if not column_exists: _logger.warning("Column 'label_custom' does not exist. Adding it...") # Add column manually if it doesn't exist self.env.cr.execute(""" ALTER TABLE helpdesk_template_field ADD COLUMN label_custom VARCHAR """) self.env.cr.commit() _logger.info("Column 'label_custom' added successfully") else: _logger.info("Column 'label_custom' already exists") # Update ir.model.fields to ensure field is registered field_model = self.env['ir.model.fields'] model_id = self.env['ir.model'].search([('model', '=', 'helpdesk.template.field')], limit=1) if model_id: existing_field = field_model.search([ ('model_id', '=', model_id.id), ('name', '=', 'label_custom') ], limit=1) if not existing_field: _logger.warning("Field 'label_custom' not found in ir.model.fields. Creating it...") field_model.create({ 'model_id': model_id.id, 'name': 'label_custom', 'field_description': 'Custom Label', 'ttype': 'char', 'state': 'manual', }) _logger.info("Field 'label_custom' registered in ir.model.fields") else: _logger.info("Field 'label_custom' already registered in ir.model.fields") # Clear cache to ensure changes are reflected self.env.registry.clear_cache() except Exception as e: _logger.error(f"Error in _migrate_label_custom_field: {str(e)}", exc_info=True) # Don't raise to avoid breaking module update @api.model_create_multi def create(self, vals_list): """Override create to mark model required fields and regenerate forms when template field is added""" # Mark model required fields automatically based on field definition for vals in vals_list: if 'field_id' in vals and vals['field_id']: field = self.env['ir.model.fields'].browse(vals['field_id']) # Check if field is required at model level (not just in form) # A field is model required if: # 1. It's in the helpdesk.ticket model # 2. It has required=True in ir.model.fields (mandatory at model level) # 3. It's not blacklisted for website forms if (field.model == 'helpdesk.ticket' and field.required and not field.website_form_blacklisted): vals['model_required'] = True _logger.info(f"Auto-marked field {field.name} as model_required (required at model level)") fields_created = super().create(vals_list) # Get unique templates that were modified templates = fields_created.mapped('template_id') # Regenerate forms in all teams using these templates for template in templates: if not template: continue teams = self.env['helpdesk.team'].search([ ('template_id', '=', template.id), ('use_website_helpdesk_form', '=', True) ]) for team in teams: # Ensure view exists before regenerating if not team.website_form_view_id: team._ensure_submit_form_view() # Regenerate form if view exists if team.website_form_view_id: try: team._regenerate_form_from_template() _logger.info(f"Regenerated form for team {team.id} after adding field to template {template.id}") except Exception as e: _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True) return fields_created def write(self, vals): """Override write to mark model required fields and regenerate forms when template field is modified""" # Mark/unmark model_required automatically based on field definition if 'field_id' in vals and vals['field_id']: field = self.env['ir.model.fields'].browse(vals['field_id']) # Check if field is required at model level if (field.model == 'helpdesk.ticket' and field.required and not field.website_form_blacklisted): vals['model_required'] = True _logger.info(f"Auto-marked field {field.name} as model_required (required at model level)") else: # Field is not model required, unmark it vals['model_required'] = False elif 'field_id' in vals and not vals['field_id']: # Field_id is being cleared, unmark model_required vals['model_required'] = False result = super().write(vals) # If any field configuration changed, regenerate forms if any(key in vals for key in ['field_id', 'sequence', 'required', 'visibility_dependency', 'visibility_condition', 'visibility_comparator', 'label_custom', 'model_required', 'placeholder', 'default_value', 'help_text', 'widget', 'selection_options', 'rows', 'input_type', 'selection_type']): # Get unique templates that were modified templates = self.mapped('template_id') # Regenerate forms in all teams using these templates for template in templates: if not template: continue teams = self.env['helpdesk.team'].search([ ('template_id', '=', template.id), ('use_website_helpdesk_form', '=', True) ]) for team in teams: # Ensure view exists before regenerating if not team.website_form_view_id: team._ensure_submit_form_view() # Regenerate form if view exists if team.website_form_view_id: try: team._regenerate_form_from_template() _logger.info(f"Regenerated form for team {team.id} after modifying field in template {template.id}") except Exception as e: _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True) return result def unlink(self): """Override unlink to prevent deletion of model required fields and regenerate forms""" # Prevent deletion of model required fields model_required_fields = self.filtered('model_required') if model_required_fields: field_names = [f.field_id.name if f.field_id else 'Unknown' for f in model_required_fields] raise UserError( _("Cannot delete model required field(s): %s. This field is mandatory for the model and cannot be removed. " "Try hiding it with the 'Visibility' option instead and add it a default value.") % ', '.join(field_names) ) # Get templates before deletion templates = self.mapped('template_id') result = super().unlink() # Regenerate forms in all teams using these templates for template in templates: if not template: continue teams = self.env['helpdesk.team'].search([ ('template_id', '=', template.id), ('use_website_helpdesk_form', '=', True) ]) for team in teams: # Ensure view exists before regenerating if not team.website_form_view_id: team._ensure_submit_form_view() # Regenerate form if view exists if team.website_form_view_id: try: team._regenerate_form_from_template() _logger.info(f"Regenerated form for team {team.id} after removing field from template {template.id}") except Exception as e: _logger.error(f"Error regenerating form for team {team.id}: {e}", exc_info=True) return result