from odoo import fields, models, api,_ from odoo.exceptions import ValidationError class BaseDashboard(models.Model): _name = 'base.dashbord' _description = 'Dashboard Builder' _order = 'sequence' sequence = fields.Integer() rec_name ="model_id " name = fields.Char( string='Card name', translate=True ) model_name = fields.Char( string='model name', ) model_id = fields.Many2one(string='Model', comodel_name='ir.model',) line_ids = fields.One2many( string='Line', comodel_name='base.dashbord.line', inverse_name='board_id' ) icon_type = fields.Selection( [('image', 'Image'), ('icon', 'Icon Library')], string='Icon Type', default='image', required=True ) icon_name = fields.Char( string='Icon Class', help="FontAwesome class (e.g., 'fa-plane', 'fa-users'). See https://fontawesome.com/v4/icons/" ) icon_preview_html = fields.Html(compute='_compute_icon_preview', string="Icon/Image Preview") @api.depends('icon_type', 'icon_name', 'card_image') def _compute_icon_preview(self): for record in self: if record.icon_type == 'icon' and record.icon_name: # Dynamic Icon Preview - Class based sizing record.icon_preview_html = f'
' elif record.icon_type == 'image' and record.card_image: # Actual Image Preview # CRITICAL: Use bin_size=False to ensure we get DATA not SIZE TEXT (e.g. "25 KB") try: img_rec = record.with_context(bin_size=False) image_data = img_rec.card_image.decode('utf-8') if isinstance(img_rec.card_image, bytes) else img_rec.card_image record.icon_preview_html = f'
' except Exception: record.icon_preview_html = '
' else: # Default Placeholder record.icon_preview_html = '
' card_image = fields.Binary( string='Card Image', ) is_self_service = fields.Boolean( string='Self Service?', ) is_financial_impact = fields.Boolean( string='Without Financial Impact?', ) form_view_id = fields.Many2one('ir.ui.view', string='Form View', ) list_view_id = fields.Many2one('ir.ui.view', string='List View',) action_id = fields.Many2one('ir.actions.act_window', string='Action',) field_id = fields.Many2one('ir.model.fields', string='Fields',) is_button = fields.Boolean( string='is_button', ) is_stage = fields.Boolean( string='is_stage',compute='_compute_field',store = True ) is_double = fields.Boolean( string='is_double',compute='_compute_field' ,store = True ) is_state = fields.Boolean( string='is_state',compute='_compute_field' ,store = True ) action_domain = fields.Char( string='action_domain', compute='_compute_action_domain', store=True, ) action_context = fields.Char( string='action_domain', compute='_compute_action_domain', store=True ) relation = fields.Char( string='action_domain', ) search_field = fields.Char(string='Search field', required=True, default='employee_id.user_id') def unlink_nodes(self): for rec in self: rec.is_button = False nodes = self.env['node.state'].sudo().search([('model_id', '=', rec.model_id.id),('is_workflow', '=', False)]) nodes.sudo().unlink() def unlink(self): for rec in self: rec.unlink_nodes() return super(BaseDashboard, self).unlink() @api.constrains('action_id') def _check_action_id(self): for record in self: is_record = self.sudo().search_count([('action_id','=',record.action_id.id)]) if is_record > 1: raise ValidationError(_('there is a record for this action.')) @api.depends('action_id') def _compute_action_domain(self): for record in self: record.action_domain = record.action_id.domain record.action_context = record.action_id.context @api.onchange('model_id') def _get_stage_value(self): for rec in self: if rec.model_id: rec.model_name = rec.model_id.model @api.depends('model_name') def _compute_field(self): for rec in self: if rec.model_id: #use those fileds to attr invisiable model = self.env[rec.model_name] #model holidays has special case, #becuase when holiday work flow installed records of leave can hold state or stage depends on holiday type. if rec.model_name == 'hr.holidays': rec.is_double = True elif 'state' in model._fields: rec.is_state = True elif 'stage_id' in model._fields: rec.is_stage = True @api.depends('name', 'model_id') def name_get(self): result = [] for record in self: if record.name: name = record.name else: name = record.model_id.name result.append((record.id, name)) return result def _get_stage(self,rel): #this method recive the relation model and return the stage on this obj #we get the stage ids for rel and create them in intermediate table. for rec in self: current_model = self.env['stage.stage'].sudo().search([('model_id','=',rec.model_id.id)]) stage_ids = self.env[rel].sudo().search([]) if not current_model: for stage in stage_ids: # TODO: find a way to fix translation without searching in translation model & #we got translaton issue stage name is translatable ,so we get the stage translation value = stage.with_context(lang=self.env.user.lang).name #value = self.env['ir.translation'].sudo().search([('source','=',stage.name)],limit=1).value stage = self.env['stage.stage'].sudo().create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id ,'stage_id':stage.id ,'name':stage.name,'value':value}) else: self.update_selection() def update_selection(self): ''' in this method we need to check if new states or stages were added we have a module in which new state will be added "odoo_dynamic_workflow", so we need to check if its installed , and get only the new state and update the intermediate object with it. same goes when deleting state , we search for the deleted ones and remove them from our intermediate object. we have three types of lines choose either stage or state or both depends on the model, both is only when the modeli is "hr.holidays",because there is a workflow mdouel that replace the state with stage depends on holiday type ''' odoo_dynamic_workflow = self.env['ir.module.module'].sudo().search([('name', '=', 'odoo_dynamic_workflow')]) for rec in self: if odoo_dynamic_workflow and odoo_dynamic_workflow.state == 'installed': model = self.env[rec.model_name] work_folow_active = self.env['odoo.workflow'].sudo().search([('model_id','=',rec.model_id.id),('active','=',True)]) work_folow_inactive = self.env['odoo.workflow'].sudo().search([('model_id','=',rec.model_id.id),('active','=',False)]) state = self.env['node.state'].sudo().search([('is_workflow','=',True)]) work_folow_name = work_folow_active.node_ids.filtered( lambda r: r.code_node == False and r.active ==True ).mapped( "node_name" ) state_name = state.mapped('state') if not rec.is_stage and rec.model_name != 'hr.holidays': for line in work_folow_active.node_ids: if not line.code_node and line.active: if not self.env['node.state'].sudo().search([('state','=',line.node_name)]): state = self.env['node.state'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id ,'action_id':rec.action_id.id,'state':line.node_name ,'name':line.name,'is_workflow':True}) diffs = (list(set(state_name) - set(work_folow_name))) state = self.env['node.state'].sudo().search([('state','in',diffs)]).unlink() for line in work_folow_inactive.node_ids: to_remove = [] if not line.code_node: to_remove.append(line.node_name) for stat in rec.line_ids: if stat.state_id.state == line.node_name or stat.state_id.name == line.name or stat.state_id.state == line.node_name or stat.state_id.name == line.name: stat.unlink() self.env['node.state'].sudo().search([('state','in',to_remove)]).unlink() if rec.is_stage: rel = self.env['ir.model.fields'].sudo().search([('model_id','=',rec.model_id.id),('name','=','stage_id')]) current_model = self.env['stage.stage'].sudo().search([('model_id','=',rec.model_id.id)]).ids rel_ids = self.env[rel.relation].sudo().search([]) if current_model: for rel in rel_ids: if rel.id not in current_model: stage = self.env['stage.stage'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id ,'stage_id':rel.id ,'name':rel.name,}) if rec.model_name == 'hr.holidays': rel = self.env['ir.model.fields'].sudo().search([('model_id','=',rec.model_id.id),('name','=','stage_id')]) current_model = self.env['node.state'].sudo().search([('model_id','=',rec.model_id.id)]).mapped('name') for line in work_folow_active.node_ids: if not line.code_node and line.active: if not self.env['node.state'].sudo().search([('state','=',line.node_name)]): state = self.env['node.state'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id ,'action_id':rec.action_id.id,'state':line.node_name ,'name':line.name,'is_workflow':True}) if rel: rel_ids = self.env[rel.relation].sudo().search([('state','=','approved')]).mapped('name') diffs = (list(set(rel_ids) - set(current_model))) for diff in diffs: stage = self.env['node.state'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id , 'action_id':rec.action_id.id,'stage_id':rel.id ,'state':diff,'name':diff,'is_holiday_workflow':True}) holiday_workflow_leave = self.env['node.state'].sudo().search([('is_holiday_workflow','=',True)]).mapped( "name" ) diffs = (list(set(holiday_workflow_leave) - set(rel_ids))) state = self.env['node.state'].sudo().search([('name','in',diffs)]).unlink() for line in work_folow_inactive.node_ids: diff = [] if not line.code_node: diff.append(line.node_name) for stat in rec.line_ids: if stat.state_id.state == line.node_name or stat.state_id.name == line.name or stat.state_id.state == line.node_name or stat.state_id.name == line.name: stat.unlink() self.env['node.state'].sudo().search([('state','in',diffs)]).unlink() def compute_selection(self): ''' method used to compute the states or stages depends on chosen model it looks in model fields and see if it contains state or stage fields then dive in those fields and get their values and create them in a middle obj yet "hr.holidays could have both states and stages if holiday workflow module is installed" ''' for rec in self: rec.is_button = True model = self.env[rec.model_name] current_model = self.env['node.state'].sudo().search([('model_id','=',rec.model_id.id)]) if rec.model_name == 'hr.holidays': rec.is_double = True if not current_model: nodes = model._fields.get('state')._description_selection(self.env) rel = self.env['ir.model.fields'].sudo().search([('model_id','=',rec.model_id.id),('name','=','stage_id')]) for node in nodes: state = self.env['node.state'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id ,'action_id':rec.action_id.id,'state':node[0] ,'name':node[1]}) if rel: rel_ids = self.env[rel.relation].sudo().search([]) for rel in rel_ids: if rel.state == 'approved': stage = self.env['node.state'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id , 'action_id':rec.action_id.id,'stage_id':rel.id ,'name':rel.name,'is_holiday_workflow':True}) else: self.update_selection() if 'state' in model._fields and rec.model_name != 'hr.holidays' : if not current_model: nodes = model._fields.get('state')._description_selection(self.env) for node in nodes: state = self.env['node.state'].create({'model_id':rec.model_id.id , 'form_view_id':rec.form_view_id.id , 'list_view_id':rec.list_view_id.id ,'action_id':rec.action_id.id,'state':node[0] ,'name':node[1]}) else: self.update_selection() elif 'stage_id' in model._fields and rec.model_name != 'hr.holidays': rel = self.env['ir.model.fields'].sudo().search([('model_id','=',rec.model_id.id),('name','=','stage_id')]) #call method to get the stage of the model self._get_stage(rel.relation) if 'state' not in model._fields and not 'stage_id' in model._fields : #raise error if no state nor state raise ValidationError(_('This model has no states nor stages.')) class BaseDashboardUser(models.Model): _name = 'base.dashbord.line' name = fields.Char( string='name', ) group_ids = fields.Many2many( string='Group', comodel_name='res.groups', ) board_id = fields.Many2one( string='Bord', comodel_name='base.dashbord', ondelete = 'cascade' ) state_id = fields.Many2one( string='State', comodel_name='node.state' ) stage_id = fields.Many2one( string='Stage', comodel_name='stage.stage' ) model_id = fields.Many2one(string='Model', comodel_name='ir.model') model_name = fields.Char( string='Model Name', ) sequence = fields.Integer( string='sequence', ) @api.onchange('state_id') def onchange_state_id(self): state_ids = [] if self.state_id: for stat in self.board_id.line_ids: state_ids.append(stat.state_id.id) if state_ids.count(self.state_id.id) > 2: raise ValidationError(_('already choose this state')) @api.onchange('stage_id') def onchange_stage_id(self): stage_ids = [] if self.stage_id: for stat in self.board_id.line_ids: stage_ids.append(stat.stage_id.id) if stage_ids.count(self.stage_id.id) > 2: raise ValidationError(_('already choose this stage')) class NodeState(models.Model): _name = 'node.state' @api.depends('name', 'state') def name_get(self): result = [] for record in self: if self.env.user.lang == 'en_US': if record.state: name = record.state if self.env.user.lang == 'ar_001' or self.env.user.lang == 'ar_EG': if record.name: name = record.name result.append((record.id, name)) return result name = fields.Char( string='name', translate=True ) state = fields.Char( string='state', translate=True ) stage_id = fields.Char( string='Stage',readonly=True ) is_workflow = fields.Boolean( string='is_workflow', readonly=True ) is_holiday_workflow = fields.Boolean( string='is_holiday_workflow', readonly=True ) model_id = fields.Many2one(string='Model', comodel_name='ir.model', readonly=True ) form_view_id = fields.Many2one('ir.ui.view', string='Form View', readonly=True ) list_view_id = fields.Many2one('ir.ui.view', string='List View',readonly=True) action_id = fields.Many2one('ir.actions.act_window', string='Action',readonly=True) class StageStage(models.Model): _name = 'stage.stage' @api.depends('name', 'value') def name_get(self): result = [] for record in self: if self.env.user.lang == 'en_US': if record.name: name = record.name if self.env.user.lang == 'ar_001' or self.env.user.lang == 'ar_EG': if record.value: name = record.value result.append((record.id, name)) return result name = fields.Char( string='name', translate=True,readonly=True ) stage_id = fields.Char( string='Stage',readonly=True ) value = fields.Char( string='value',readonly=True ) model_id = fields.Many2one(string='Model', comodel_name='ir.model',readonly=True) form_view_id = fields.Many2one('ir.ui.view', string='Form View',readonly=True) list_view_id = fields.Many2one('ir.ui.view', string='List View',readonly=True) action_id = fields.Many2one('ir.actions.act_window', string='Action',readonly=True) class DashboardConfigSettings(models.TransientModel): """Dashboard Theme Settings - Configurable colors from UI""" _inherit = 'res.config.settings' _description = 'Dashboard Configuration Settings' # Dashboard Theme Colors dashboard_primary_color = fields.Char( string='Primary Color', config_parameter='system_dashboard_classic.primary_color', default='#0891b2', help='Main accent color for the dashboard (e.g., #0891b2)' ) dashboard_secondary_color = fields.Char( string='Secondary Color', config_parameter='system_dashboard_classic.secondary_color', default='#1e293b', help='Secondary color for headers and dark elements (e.g., #1e293b)' ) dashboard_success_color = fields.Char( string='Success Color', config_parameter='system_dashboard_classic.success_color', default='#10b981', help='Success/Online status color (e.g., #10b981)' ) dashboard_warning_color = fields.Char( string='Warning Color', config_parameter='system_dashboard_classic.warning_color', default='#f59e0b', help='Warning/Remaining balance color (e.g., #f59e0b)' ) # Dashboard Statistics Visibility Settings # NOTE: We DON'T use config_parameter here because Odoo's default behavior # converts any non-empty string to True via bool(). Instead, we manually # handle storage in get_values/set_values. dashboard_show_annual_leave = fields.Boolean( string='Show Annual Leave', default=True, help='Show/Hide Annual Leave statistics card in dashboard header' ) dashboard_show_salary_slips = fields.Boolean( string='Show Salary Slips', default=True, help='Show/Hide Salary Slips statistics card in dashboard header' ) dashboard_show_timesheet = fields.Boolean( string='Show Weekly Timesheet', default=True, help='Show/Hide Weekly Timesheet statistics card in dashboard header' ) dashboard_show_attendance_hours = fields.Boolean( string='Show Attendance Hours', default=True, help='Show/Hide Attendance Hours statistics card in dashboard header' ) # Attendance Check-in/out Section dashboard_show_attendance_section = fields.Boolean( string='Show Attendance Section', default=True, help='Show/Hide the Attendance Check-in/out section in dashboard header. When hidden, statistics cards will expand to fill the space.' ) # Enable/Disable Check-in/out Button dashboard_enable_attendance_button = fields.Boolean( string='Enable Check-in/out Button', default=False, help='Enable/Disable the Check-in/Check-out button functionality. When disabled, the button will appear but will not function.' ) # Chart Type Selection Fields dashboard_annual_leave_chart_type = fields.Selection([ ('donut', 'Donut'), ('pie', 'Pie'), ], default='donut', string='Annual Leave Chart', config_parameter='system_dashboard_classic.annual_leave_chart_type') dashboard_salary_slips_chart_type = fields.Selection([ ('donut', 'Donut'), ('pie', 'Pie'), ], default='donut', string='Salary Slips Chart', config_parameter='system_dashboard_classic.salary_slips_chart_type') dashboard_timesheet_chart_type = fields.Selection([ ('donut', 'Donut'), ('pie', 'Pie'), ], default='donut', string='Weekly Timesheet Chart', config_parameter='system_dashboard_classic.timesheet_chart_type') dashboard_attendance_hours_chart_type = fields.Selection([ ('donut', 'Donut'), ('pie', 'Pie'), ], default='donut', string='Attendance Hours Chart', config_parameter='system_dashboard_classic.attendance_hours_chart_type') # Periodic Refresh Settings dashboard_refresh_enabled = fields.Boolean( string='Enable Auto Refresh', default=False, config_parameter='system_dashboard_classic.refresh_enabled', help='Automatically refresh attendance status and approval count at regular intervals' ) dashboard_refresh_interval = fields.Integer( string='Refresh Interval (seconds)', default=60, config_parameter='system_dashboard_classic.refresh_interval', help='How often to refresh data (minimum: 30 seconds, maximum: 3600 seconds / 1 hour)' ) # Work Timer Settings dashboard_show_work_timer = fields.Boolean( string='Show Work Timer', default=False, help='Display live work hour countdown showing remaining time until end of planned shift. Updates every second and shows progress bar.' ) @api.model def get_values(self): """Override to properly load Boolean visibility settings from ir.config_parameter We store these as explicit strings ('True'/'False') and convert them back to Python booleans here. """ res = super(DashboardConfigSettings, self).get_values() ICPSudo = self.env['ir.config_parameter'].sudo() # Helper to safely get boolean value from string storage def get_bool_param(key, default='True'): value = ICPSudo.get_param(key, default) return value == 'True' res.update( dashboard_show_annual_leave=get_bool_param('system_dashboard_classic.show_annual_leave'), dashboard_show_salary_slips=get_bool_param('system_dashboard_classic.show_salary_slips'), dashboard_show_timesheet=get_bool_param('system_dashboard_classic.show_timesheet'), dashboard_show_attendance_hours=get_bool_param('system_dashboard_classic.show_attendance_hours'), dashboard_show_attendance_section=get_bool_param('system_dashboard_classic.show_attendance_section'), dashboard_enable_attendance_button=get_bool_param('system_dashboard_classic.enable_attendance_button', 'False'), dashboard_show_work_timer=get_bool_param('system_dashboard_classic.show_work_timer'), ) return res def set_values(self): """Override to explicitly store Boolean visibility settings as strings We store as 'True' or 'False' strings to prevent Odoo from deleting the parameter when value is False. """ res = super(DashboardConfigSettings, self).set_values() # Explicitly store visibility settings as string values ICPSudo = self.env['ir.config_parameter'].sudo() ICPSudo.set_param('system_dashboard_classic.show_annual_leave', 'True' if self.dashboard_show_annual_leave else 'False') ICPSudo.set_param('system_dashboard_classic.show_salary_slips', 'True' if self.dashboard_show_salary_slips else 'False') ICPSudo.set_param('system_dashboard_classic.show_timesheet', 'True' if self.dashboard_show_timesheet else 'False') ICPSudo.set_param('system_dashboard_classic.show_attendance_hours', 'True' if self.dashboard_show_attendance_hours else 'False') ICPSudo.set_param('system_dashboard_classic.show_attendance_section', 'True' if self.dashboard_show_attendance_section else 'False') ICPSudo.set_param('system_dashboard_classic.enable_attendance_button', 'True' if self.dashboard_enable_attendance_button else 'False') ICPSudo.set_param('system_dashboard_classic.show_work_timer', 'True' if self.dashboard_show_work_timer else 'False') return res @api.model def get_stats_visibility(self): """API method to get statistics visibility settings for JavaScript Returns dict with boolean values for each visibility setting. Default is True (show) if parameter doesn't exist. """ ICPSudo = self.env['ir.config_parameter'].sudo() # Helper to safely get boolean value def get_bool_param(key, default='True'): value = ICPSudo.get_param(key, default) # Handle various possible values if value in ('True', 'true', True, '1', 1): return True return False return { 'show_annual_leave': get_bool_param('system_dashboard_classic.show_annual_leave'), 'show_salary_slips': get_bool_param('system_dashboard_classic.show_salary_slips'), 'show_timesheet': get_bool_param('system_dashboard_classic.show_timesheet'), 'show_attendance_hours': get_bool_param('system_dashboard_classic.show_attendance_hours'), 'show_attendance_section': get_bool_param('system_dashboard_classic.show_attendance_section'), } @api.model def get_chart_types(self): """API method to get chart type settings for JavaScript Returns dict with chart type for each card. Default is 'donut' if parameter doesn't exist. """ ICPSudo = self.env['ir.config_parameter'].sudo() return { 'annual_leave_chart': ICPSudo.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'), 'salary_slips_chart': ICPSudo.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'), 'timesheet_chart': ICPSudo.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'), 'attendance_hours_chart': ICPSudo.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'), }