from odoo import models, fields, api, _ from odoo.exceptions import AccessError, UserError import ast from datetime import datetime, date from dateutil.relativedelta import relativedelta, SA, SU, MO import pytz from math import radians, sin, cos, sqrt, asin class SystemDashboard(models.Model): _name = 'system_dashboard_classic.dashboard' _description = 'System Dashboard' name = fields.Char("") def is_user(self, groups, user): # this method is used to check whether current user in a certin group for group in groups: if user.id in group.users.ids: return True return False @api.model def get_data(self): ''' we call the mothod from js do_action function, when a user access the system we first check if user belongs to one of the groups on the configuration model lines, then draw cards depends on states or stage they should see ''' # employee and user date and vars defined to be used inside this method values = {'user': [], 'timesheet': [], 'leaves': [], 'payroll': [], 'attendance': [], 'employee': [], 'cards': [], 'attendance_hours': [], 'chart_types': {}, 'card_orders': {}} base = self.env['base.dashbord'].search([]) # Load chart type settings ICPSudo = self.env['ir.config_parameter'].sudo() values['chart_types'] = { 'annual_leave': ICPSudo.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'), 'salary_slips': ICPSudo.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'), 'timesheet': ICPSudo.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'), 'attendance_hours': ICPSudo.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'), } # Load chart colors from settings (match the totals styling) # primary = Total/Remaining color (teal), warning = Left amount color (amber) values['chart_colors'] = { 'primary': ICPSudo.get_param('system_dashboard_classic.primary_color', '#0891b2'), 'warning': ICPSudo.get_param('system_dashboard_classic.warning_color', '#f59e0b'), 'secondary': ICPSudo.get_param('system_dashboard_classic.secondary_color', '#1e293b'), } # Load attendance button setting (disabled by default) values['enable_attendance_button'] = ICPSudo.get_param('system_dashboard_classic.enable_attendance_button', 'False') == 'True' # Load stats visibility settings (to prevent fadeIn from overriding hidden state) def get_bool_param(key, default='True'): value = ICPSudo.get_param(key, default) return value in ('True', 'true', True, '1', 1) values['stats_visibility'] = { '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'), } # Initialize celebration data (birthday and work anniversary) values['celebration'] = { 'is_birthday': False, 'is_anniversary': False, 'anniversary_years': 0, } # Initialize gender-aware data for personalized experience values['gender_info'] = { 'gender': 'male', # default 'honorific': 'أستاذ', # default male 'pronoun_you': 'ك', # default male (لديك) 'verb_suffix': 'تَ', # default male (قمتَ) } user = self.env.user user_id = self.env['res.users'].sudo().search_read( [('id', '=', user.id)], limit=1) employee_id = self.env['hr.employee'].sudo().search_read( [('user_id', '=', user.id)], limit=1) employee_object = self.env['hr.employee'].sudo().search( [('user_id', '=', user.id)], limit=1) if hasattr(employee_object.job_id, 'english_name') and employee_object.job_id.english_name: job_english = employee_object.job_id.english_name else: job_english = employee_object.job_id.name or '' # ========================================== # CELEBRATION DETECTION: Birthday & Anniversary # ========================================== today = date.today() if employee_object: # ========================================== # GENDER-AWARE PERSONALIZATION # ========================================== # hr.employee has 'gender' field with values: 'male', 'female', 'other' employee_gender = getattr(employee_object, 'gender', 'male') or 'male' if employee_gender == 'female': values['gender_info'] = { 'gender': 'female', 'honorific': 'أستاذة', # Ms./Mrs. 'pronoun_you': 'كِ', # لديكِ (feminine you) 'verb_suffix': 'تِ', # قمتِ (feminine past tense) } else: values['gender_info'] = { 'gender': 'male', 'honorific': 'أستاذ', # Mr. 'pronoun_you': 'ك', # لديك (masculine you) 'verb_suffix': 'تَ', # قمتَ (masculine past tense) } # Check for BIRTHDAY (compare month and day only) birthday = getattr(employee_object, 'birthday', None) if birthday: if birthday.month == today.month and birthday.day == today.day: values['celebration']['is_birthday'] = True # Check for WORK ANNIVERSARY (first contract start date) # Using contract_id.date_start from hr.contract joining_date = None if employee_object.contract_id and employee_object.contract_id.date_start: joining_date = employee_object.contract_id.date_start if joining_date: # Compare month and day if joining_date.month == today.month and joining_date.day == today.day: # Calculate years of service years = today.year - joining_date.year if years > 0: # Only celebrate if at least 1 year values['celebration']['is_anniversary'] = True values['celebration']['anniversary_years'] = years t_date = today # Use same date object attendance_date = {} leaves_data = {} payroll_data = {} timesheet_data = {} attendance_hours_data = {} ################################################### # check whether last action sign in or out and its date is_hr_attendance_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_attendance')]) if is_hr_attendance_module and is_hr_attendance_module.state == 'installed': # ATTENDANCE LOGIC IMPROVEMENT: # Fetch the absolute latest record regardless of date (e.g. yesterday, mobile app entry) # This ensures we show the REAL status. last_attendance = self.env['attendance.attendance'].sudo().search( [('employee_id', '=', employee_object.id)], limit=1, order="name desc") is_attendance = False if last_attendance and last_attendance.action == 'sign_in': is_attendance = True user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz) time_in_timezone = False if last_attendance: # Use 'name' (datetime) for the time display time_object = fields.Datetime.from_string(last_attendance.name) time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz) attendance_date.update({'is_attendance': is_attendance, 'time': time_in_timezone }) # if noc is found case shoud be handeld ############################################### # compute leaves taken and remaing leaves is_leave_module = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_holidays_community')]) is_leave_installed = bool(is_leave_module and is_leave_module.state == 'installed') # Always set default values and module status leaves_data.update({ 'taken': 0, 'remaining_leaves': 0, 'is_module_installed': is_leave_installed }) if is_leave_installed: # FIX: Removed limit=1 to consider ALL valid allocations (e.g. current year + carried over) leaves = self.env['hr.holidays'].sudo().search( [('employee_id', '=', employee_object.id), ('holiday_status_id.leave_type', '=', 'annual'), ('type', '=', 'add'), ('check_allocation_view', '=', 'balance')]) # Sum up valid allocations taken = sum(l.leaves_taken for l in leaves) remaining_leaves = sum(l.remaining_leaves for l in leaves) leaves_data.update({'taken': taken, 'remaining_leaves': remaining_leaves }) ################################################### # compute payroll taken and remaing payslips first_day = date(date.today().year, 1, 1) last_day = date(date.today().year, 12, 31) is_payslip_module = self.env['ir.module.module'].sudo().search([('name', '=', 'exp_hr_payroll')]) is_payslip_installed = bool(is_payslip_module and is_payslip_module.state == 'installed') # Always set default values and module status payroll_data.update({ 'taken': 0, 'payslip_remaining': 0, 'is_module_installed': is_payslip_installed }) if is_payslip_installed: payslip_count = self.env['hr.payslip'].sudo().search_count( [('employee_id', '=', employee_object.id), ('date_from', '>=', first_day), ('date_to', '<=', last_day)]) # FIX: Calculate expected slips based on contract start date # If joined mid-year, they shouldn't expect 12 slips contract = self.env['hr.contract'].sudo().search([('employee_id', '=', employee_object.id), ('state', '=', 'open')], limit=1) expected_slips = 12 if contract and contract.date_start and contract.date_start.year == date.today().year: # If joined this year, expected slips = months from start date to Dec expected_slips = 12 - contract.date_start.month + 1 # Ensure we don't show negative remaining remaining_slips = max(0, expected_slips - payslip_count) payroll_data.update({'taken': payslip_count, 'payslip_remaining': remaining_slips }) ############################################## # compute timesheet taken and remaing timesheet is_analytic_module = self.env['ir.module.module'].sudo().search([('name', '=', 'analytic')]) is_timesheet_installed = bool(is_analytic_module and is_analytic_module.state == 'installed') # Always set default values and module status timesheet_data.update({ 'taken': 0, 'timesheet_remaining': 0, 'is_module_installed': is_timesheet_installed }) if is_timesheet_installed: calender = employee_object.resource_calendar_id days_off_name = [] days_special_name = [] days_of_week = 7 working_hours = 0.0 sepcial_working_hours = 0 # get working hours and days_off and special days if emp is a full day working if calender.is_full_day: working_hours = calender.working_hours for off in calender.shift_day_off: days_off_name.append(off.name) for special in calender.special_days: if not special.date_from and not special.date_to: sepcial_working_hours += special.working_hours days_special_name.append(special.name) elif not special.date_from and t_date <= special.date_to: sepcial_working_hours += special.working_hours days_special_name.append(special.name) elif not special.date_to and t_date >= special.date_from: sepcial_working_hours += special.working_hours days_special_name.append(special.name) elif special.date_from and special.date_to and special.date_from <= t_date <= special.date_to: sepcial_working_hours += special.working_hours days_special_name.append(special.name) # get working hours and days_off and special days if emp is shit hours working # else: # working_hours = calender.shift_one_working_hours + calender.shift_two_working_hours # for off in calender.full_day_off: # days_off_name.append(off.name) # if calender.special_days_partcial: # for special in calender.special_days_partcial: # if not special.date_from and not special.date_to: # sepcial_working_hours += special.working_hours # days_special_name.append(special.name) # elif not special.date_from and str(t_date) < special.date_to: # sepcial_working_hours += special.working_hours # days_special_name.append(special.name) # elif not special.date_to and str(t_date) > special.date_from: # sepcial_working_hours += special.working_hours # days_special_name.append(special.name) # elif special.date_from and special.date_to and str(t_date) >= special.date_from and str( # t_date) <= special.date_to: # sepcial_working_hours += special.working_hours # days_special_name.append(special.name) # get start of the week according to days off star_of_week = None if 'saturday' in days_off_name: star_of_week = SU elif 'friday' in days_off_name: star_of_week = SA elif 'sunday' in days_off_name: star_of_week = MO else: star_of_week = SA # calcultion of all working hours and return done working hours and remaining # ATTENDANCE/TIMESHEET LOGIC IMPROVEMENT: # Replaced manual calculation with standard Odoo calendar method # This ensures Public Holidays (Global Leaves) are correctly handled # Determine start/end of week logic (kept from original code to maintain week start preference) # star_of_week is determined above (SU, SA, MO) start_date = date.today() + relativedelta(weeks=-1, days=1, weekday=star_of_week) end_date = start_date + relativedelta(days=6) # Ensure strictly 7 days week range # Note: start_date is calculated based on star_of_week logic (e.g. last Saturday) total_wroking_hours = 0 if employee_object.resource_calendar_id: # Use standard Odoo method total_wroking_hours = employee_object.resource_calendar_id.get_work_hours_count( datetime.combine(start_date, datetime.min.time()), datetime.combine(end_date, datetime.max.time()), compute_leaves=True ) else: # Fallback manual calculation (if calendar missing) # Restore variables needed for calculation lenght_days_off = len(days_off_name) lenght_special_days_off = len(days_special_name) lenght_work_days = (days_of_week - lenght_days_off) - lenght_special_days_off total_wroking_hours = (working_hours * lenght_work_days) + sepcial_working_hours domain = [('employee_id', '=', employee_object.id), ('date', '>=', start_date), ('date', '<=', end_date)] timesheet = self.env['account.analytic.line'].sudo().search(domain) # Filter days off manually? Odoo search already filters by date. # Original code filtered 'day_name not in days_off_name'. # If standard timesheet is used, entries on off-days are usually overtime or valid. # We will sum ALL valid timesheet entries in the period. done_hours = sum(sheet.unit_amount for sheet in timesheet) timesheet_data.update({'taken': done_hours, 'timesheet_remaining': max(0, total_wroking_hours - done_hours) }) ############################################## if base: for models in base: for model in models: #we use try and except access error if user has no read access on a model try: # print(model) # print(model.with_user(user).check_access_rights('read')) # print("user", user) model.with_user(user).check_access_rights('read') mod = {'name': '', 'model': '', 'icon': '', 'lines': [], 'type': 'approve', 'domain_to_follow': []} for line in model.line_ids: if self.is_user(line.group_ids, user): # call method to return if user is in one of the groups in current line # static vars for the card # FIX: Fetch explicit translations for bilingual support # model.name returns current user lang, so we force context card_name_ar = model.with_context(lang='ar_001').name or model.with_context(lang='ar_SY').name or model.name card_name_en = model.with_context(lang='en_US').name or model.name # Fallback if model name is empty (use model description) if not card_name_ar: card_name_ar = model.model_id.with_context(lang='ar_001').name or model.model_id.name if not card_name_en: card_name_en = model.model_id.with_context(lang='en_US').name or model.model_id.name mod['name'] = card_name_ar if self.env.user.lang in ['ar_001', 'ar_SY'] else card_name_en mod['name_arabic'] = card_name_ar mod['name_english'] = card_name_en mod['model'] = model.model_name mod['image'] = model.card_image mod['icon_type'] = model.icon_type mod['icon_name'] = model.icon_name # Default Icon Logic if not mod['image'] and not mod['icon_name']: mod['icon_type'] = 'icon' mod['icon_name'] = 'fa-th-large' # var used in domain to serach with either state or state state_or_stage = 'state' if line.state_id else 'stage_id' state_click = line.state_id.state if line.state_id else int(line.stage_id.stage_id) state_follow = line.state_id.state if line.state_id else int(line.stage_id.stage_id) ''' below lists to passed for search count it contains the domain from action_id if it contains domain and the domain in the action, dosnt contains state field,if it does remove the state. and the actual domain of state ''' action_domain_click = [] action_domain_follow = [] if model.action_domain: try: # because we can use literal eval only with string if the action domain contains active word not str it throw an error dom_act = ast.literal_eval(model.action_domain) for i in dom_act: if i[0] != 'state': action_domain_click.append(i) action_domain_follow.append(i) except ValueError: pass ''' if model is hr.holdiays and hr_holidays_workflow moduel is installed , we need to search with state or stage, because the moduel adds stages for some recods ''' if model.model_name == 'hr.holidays': hr_holidays_workflow = self.env['ir.module.module'].sudo().search( [('name', '=', 'hr_holidays_workflow')]) if hr_holidays_workflow and hr_holidays_workflow.state == 'installed': action_domain_click.append('|') action_domain_click.append(('stage_id.name', '=', line.state_id.state)) action_domain_click.append(('state', '=', line.state_id.state) if line.state_id else ( 'stage_id', '=', int(line.stage_id.stage_id))) # we use str to be able to use replace method, and make domain valid for search orm method then converted back to list domain_click = str(action_domain_click).replace('(', '[').replace(')', ']') domain_click = ast.literal_eval(domain_click) domain_follow = str(action_domain_follow).replace('(', '[').replace(')', ']') domain_follow = ast.literal_eval(domain_follow) state_to_click = self.env[model.model_name].search_count(action_domain_click) mod['domain_to_follow'].append(state_follow) action_domain_follow.append((state_or_stage, 'not in', mod['domain_to_follow'])) state_to_follow = self.env[model.model_name].search_count(action_domain_follow) # below domains to be passed to do action in js domain_follow_js = str(action_domain_follow).replace('(', '[').replace(')', ']') domain_follow_js = ast.literal_eval(domain_follow_js) # for translation # TODO: find a way to fix translations,we load states in ar lang it create states in ar lang in the table and vise versa state = "" if line.state_id: if self.env.user.lang == 'en_US': state = line.state_id.state else: state = line.state_id.name if line.stage_id: if self.env.user.lang == 'en_US': state = state = line.stage_id.name else: state = line.stage_id.value '''for every line in states form th config model ,we add a card with its data so we could have a card with "record to confirm count is 5" ''' mod['lines'].append({ 'id': line.state_id.id if line.state_id else line.stage_id.id, 'count_state_click': state_to_click, # count of state to click on card 'count_state_follow': state_to_follow, # count of state to follow on card 'state_approval': _('') + '' + _(line.state_id.name), # title of card ex:records to confirm in approve tab 'state_folow': _('All Records'), # title of card in track tap 'domain_to_follow': state_follow, # domain 'form_view': model.form_view_id.id, # to open specific list 'list_view': model.list_view_id.id, # to open specific from 'domain_click': domain_click, # to open specific records in approval tab 'domain_follow': domain_follow_js, # to open specific records in track tab 'context': model.action_context, }) # sort the cards according to states or stages (draft,confirm,etc..) mod['lines'] = sorted(mod['lines'], key=lambda i: i['id']) '''if user in tow or more cards we wont create another card, but append the new data in the old card so one card could have two ex:records to confirm and new line records to approve''' if mod['name'] != '': values['cards'].append(mod) ''' if we check the is_self_service field new card will be added to sef service screen and it depnds on current user , because search method apply security and access right,records rules''' if model.is_self_service: card_name = model.name if model.name else model.model_id.name mod = self.env[model.model_name] js_domain = [] service_action_domain = [] ''' check if user_id = user.id to search for user own records if there is domain in the action which open the view get the domain and append the old one to new one ''' if model.action_domain: js_domain = model.action_domain try: # because we can use literal eval only with string if the action domain contains active word not str it throw an error service_action = ast.literal_eval(model.action_domain) for i in service_action: if i[0] != 'state': service_action_domain.append(i) except ValueError: pass # service_action_domain.append('|') if model.search_field: service_action_domain.append((model.search_field, '=', user.id)) #if 'employee_id' in mod._fields and 'user_id' in mod._fields: # service_action_domain.append('|') # service_action_domain.append(('user_id', '=', user.id)) # service_action_domain.append(('employee_id.user_id', '=', user.id)) #if 'employee_id' in mod._fields: # service_action_domain.append(('employee_id.user_id', '=', user.id)) #if 'user_id' in mod._fields: # service_action_domain.append(('user_id', '=', user.id)) # service_action_domain.append(('employee_id.user_id','=',user.id)) else: if model.search_field: service_action_domain.append((model.search_field, '=', user.id)) #if 'employee_id' in mod._fields and 'user_id' in mod._fields: # service_action_domain.append('|') # service_action_domain.append(('user_id', '=', user.id)) # service_action_domain.append(('employee_id.user_id', '=', user.id)) # service_action_domain.append('|') #if 'employee_id' in mod._fields: # service_action_domain.append(('employee_id.user_id', '=', user.id)) #if 'user_id' in mod._fields: # service_action_domain.append(('user_id', '=', user.id)) # service_action_domain.append(('employee_id.user_id','=',user.id)) # Default Icon Logic if not model.card_image and not model.icon_name: model_icon_type = 'icon' model_icon_name = 'fa-th-large' else: model_icon_type = model.icon_type model_icon_name = model.icon_name values['cards'].append({ 'type': 'selfs', 'name': card_name, 'name_english': card_name, 'name_arabic': card_name, 'model': model.model_name, 'state_count': self.env[model.model_name].search_count(service_action_domain), 'image': model.card_image, 'icon_type': model_icon_type, 'icon_name': model_icon_name, 'form_view': model.form_view_id.id, 'list_view': model.list_view_id.id, 'js_domain': service_action_domain, 'context': model.action_context if model.action_context else {}, }) except AccessError: continue # append user record values['user'].append(user_id) # append employee data record values['employee'].append(employee_id) values['leaves'].append(leaves_data) values['payroll'].append(payroll_data) values['attendance'].append(attendance_date) values['timesheet'].append(timesheet_data) # Compute attendance hours from hr.attendance.transaction try: AttendanceTransaction = self.env['hr.attendance.transaction'].sudo() # Get current month's attendance transactions for the employee first_day_of_month = date(t_date.year, t_date.month, 1) attendance_txns = AttendanceTransaction.search([ ('employee_id', '=', employee_object.id), ('date', '>=', first_day_of_month), ('date', '<=', t_date) ]) # ATTENDANCE LOGIC IMPROVEMENT: # Calculate planned hours from the Resource Calendar (Work Schedule) # This ensures we count days where the employee was ABSENT (no transaction) # as missed hours, providing a true compliance percentage. if employee_object.resource_calendar_id: # Use standard Odoo method to get expected working hours for the period # Use standard Odoo method to get expected working hours for the period # from start of month to today (Month-to-Date) # compute_related ensures we consider resource specificities if needed # Important: Convert dates to datetime as required by get_work_hours_count plan_hours_total = employee_object.resource_calendar_id.get_work_hours_count( datetime.combine(first_day_of_month, datetime.min.time()), datetime.combine(t_date, datetime.max.time()), compute_leaves=True ) else: # Fallback to sum of transactions if no calendar (though rare for active employees) plan_hours_total = sum(txn.plan_hours for txn in attendance_txns) official_hours_total = sum(txn.official_hours for txn in attendance_txns) attendance_hours_data.update({ 'plan_hours': round(plan_hours_total, 2), 'official_hours': round(official_hours_total, 2), 'is_module_installed': True # Module is installed if we got here }) except Exception: attendance_hours_data.update({ 'plan_hours': 0, 'official_hours': 0, 'is_module_installed': False # Module not installed or error }) values['attendance_hours'].append(attendance_hours_data) values['job_english'] = job_english # Load user's saved card order preferences import json try: card_orders_json = self.env.user.dashboard_card_orders or '{}' values['card_orders'] = json.loads(card_orders_json) except (json.JSONDecodeError, TypeError, AttributeError): values['card_orders'] = {} # Load periodic refresh settings ICP = self.env['ir.config_parameter'].sudo() values['refresh_settings'] = { 'enabled': ICP.get_param('system_dashboard_classic.refresh_enabled', 'False') == 'True', 'interval': int(ICP.get_param('system_dashboard_classic.refresh_interval', '60')) } return values @api.model def get_public_dashboard_colors(self): """ Secure API method to get dashboard colors for Genius Enhancements JS. Uses sudo() to bypass access rules for ir.config_parameter. """ ICPSudo = self.env['ir.config_parameter'].sudo() return { 'primary': ICPSudo.get_param('system_dashboard_classic.primary_color', '#0891b2'), 'secondary': ICPSudo.get_param('system_dashboard_classic.secondary_color', '#1e293b'), 'success': ICPSudo.get_param('system_dashboard_classic.success_color', '#10b981'), 'warning': ICPSudo.get_param('system_dashboard_classic.warning_color', '#f59e0b'), } def _haversine_distance(self, lat1, lon1, lat2, lon2): """ Calculate the great-circle distance between two GPS points using Haversine formula. @param lat1, lon1: First point coordinates in degrees @param lat2, lon2: Second point coordinates in degrees @return: Distance in meters """ R = 6371000 # Earth's radius in meters # Convert to radians lat1_rad, lon1_rad = radians(lat1), radians(lon1) lat2_rad, lon2_rad = radians(lat2), radians(lon2) # Differences dlat = lat2_rad - lat1_rad dlon = lon2_rad - lon1_rad # Haversine formula a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2 c = 2 * asin(sqrt(a)) return R * c @api.model def checkin_checkout(self, latitude=None, longitude=None): """ Check-in or Check-out with zone-based geolocation validation. @param latitude: User's current latitude from browser geolocation @param longitude: User's current longitude from browser geolocation @return: Dict with is_attendance, time, and any error info """ ctx = self._context t_date = date.today() user = self.env.user employee_object = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1) if not employee_object: is_arabic = 'ar' in self._context.get('lang', 'en_US') msg = "عذراً، لم يتم العثور على ملف وظيفي مرتبط بحسابك.\nيرجى مراجعة إدارة الموارد البشرية." if is_arabic else "Sorry, no employee profile found linked to your account.\nPlease contact HR department." return {'error': True, 'message': msg} # ============================================================ # ZONE VALIDATION - Check if employee is within allowed zone # ============================================================ AttendanceZone = self.env['attendance.zone'].sudo() # Find employee's assigned zones employee_zones = AttendanceZone.search([('employee_ids', 'in', employee_object.id)]) # Check for general zone (applies to all employees) general_zone = employee_zones.filtered(lambda z: z.general) # If employee has a general zone, allow from anywhere (no location check needed) zone_validation_required = not bool(general_zone) if zone_validation_required: is_arabic = 'ar' in self._context.get('lang', 'en_US') # Location is required for non-general zones if not latitude or not longitude: msg = "يتطلب النظام الوصول إلى موقعك الجغرافي لتسجيل الحضور.\nيرجى تفعيل صلاحية الموقع في المتصفح والمحاولة مرة أخرى." if is_arabic else "System requires access to your location for attendance.\nPlease enable location permission in your browser and try again." return {'error': True, 'message': msg} # If no zones assigned at all if not employee_zones: msg = "لم يتم تعيين منطقة حضور خاصة بك.\nيرجى التواصل مع إدارة النظام." if is_arabic else "No specific attendance zone assigned to you.\nPlease contact system administration." return {'error': True, 'message': msg} # Check if user is within any of their assigned zones is_in_zone = False closest_zone_distance = float('inf') allowed_range = 0 valid_zones_count = 0 # Track zones with valid coordinates for zone in employee_zones: if not zone.latitude or not zone.longitude or not zone.allowed_range: continue try: zone_lat = float(zone.latitude) zone_lon = float(zone.longitude) zone_range = float(zone.allowed_range) except (ValueError, TypeError): continue valid_zones_count += 1 # Calculate distance from user to zone center distance = self._haversine_distance(latitude, longitude, zone_lat, zone_lon) if distance <= zone_range: is_in_zone = True break # Track closest zone for error message if distance < closest_zone_distance: closest_zone_distance = distance allowed_range = zone_range if not is_in_zone: # Edge case: all zones have invalid coordinates if valid_zones_count == 0: msg = "بيانات منطقة الحضور غير مكتملة.\nيرجى التواصل مع إدارة النظام لتحديث الإحداثيات." if is_arabic else "Attendance zone data is incomplete.\nPlease contact system administration to update coordinates." return {'error': True, 'message': msg} # Calculate how far outside the zone user is distance_outside = int(closest_zone_distance - allowed_range) # Smart distance formatting: use km for large distances, meters for small if distance_outside >= 1000: # Convert to km with 1 decimal place distance_km = round(distance_outside / 1000, 1) distance_str_ar = f"{distance_km} كيلو متر" distance_str_en = f"{distance_km} kilometers" else: distance_str_ar = f"{distance_outside} متر" distance_str_en = f"{distance_outside} meters" # Gender-aware Arabic message # تتواجد (male) / تتواجدين (female) # لتتمكن (male) / لتتمكني (female) employee_gender = getattr(employee_object, 'gender', 'male') or 'male' if employee_gender == 'female': ar_msg = f"عذراً، أنتِ تتواجدين خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة {distance_str_ar} تقريباً أو أكثر لتتمكني من التسجيل." else: ar_msg = f"عذراً، أنت تتواجد خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة {distance_str_ar} تقريباً أو أكثر لتتمكن من التسجيل." en_msg = f"Sorry, you are outside the allowed attendance zone.\nPlease move approximately {distance_str_en} or more closer to be able to check in." msg = ar_msg if is_arabic else en_msg return { 'error': True, 'message': msg } # ============================================================ # ATTENDANCE CREATION (Zone validation passed) # ============================================================ vals_in = { 'employee_id': employee_object.id, 'action': 'sign_in', 'action_type': 'manual', 'latitude': str(latitude) if latitude else False, 'longitude': str(longitude) if longitude else False, } vals_out = { 'employee_id': employee_object.id, 'action': 'sign_out', 'action_type': 'manual', 'latitude': str(latitude) if latitude else False, 'longitude': str(longitude) if longitude else False, } Attendance = self.env['attendance.attendance'] # Robust Logic: Always fetch the ABSOLUTE last record regardless of date last_attendance = self.env['attendance.attendance'].sudo().search( [('employee_id', '=', employee_object.id)], limit=1, order="name desc") user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz) # Determine next action based on DB state if last_attendance and last_attendance.action == 'sign_in': # User is currently Checked In -> Action: Sign Out result = Attendance.create(vals_out) is_attendance = False else: # User is Checked Out (or no record) -> Action: Sign In result = Attendance.create(vals_in) is_attendance = True # Fetch the NEW last record to return accurate time last_attendance = self.env['attendance.attendance'].sudo().search( [('employee_id', '=', employee_object.id)], limit=1, order="name desc") if last_attendance: time_object = fields.Datetime.from_string(last_attendance.name) time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz) else: time_in_timezone = False attendance_response = { 'is_attendance': is_attendance, 'time': time_in_timezone } return attendance_response @api.model def save_card_order(self, order_type, card_ids): """ Save card order preference for current user. Stores in res.users.dashboard_card_orders as JSON. @param order_type: String key identifying which cards (e.g., 'service', 'approve', 'track', 'stats') @param card_ids: List of card identifiers in desired order @return: Boolean success status """ import json user = self.env.user # Get current orders or initialize empty dict try: current_orders = json.loads(user.dashboard_card_orders or '{}') except (json.JSONDecodeError, TypeError): current_orders = {} # Update the specific order type current_orders[order_type] = card_ids # Save back to user user.sudo().write({ 'dashboard_card_orders': json.dumps(current_orders) }) return True @api.model def get_card_order(self, order_type=None): """ Get card order preferences for current user. @param order_type: Optional string key for specific order type. If None, returns all orders. @return: List of card IDs for specific type, or dict of all orders """ import json user = self.env.user try: all_orders = json.loads(user.dashboard_card_orders or '{}') except (json.JSONDecodeError, TypeError): all_orders = {} if order_type: return all_orders.get(order_type, []) return all_orders @api.model def get_refresh_data(self): """ Lightweight method for periodic refresh. Returns only attendance status and approval count. Does NOT return full card data for performance. @return: Dict with attendance status and approval count """ result = { 'attendance': [], 'approval_count': 0 } user = self.env['res.users'].browse(self.env.uid) employee = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1) if not employee: return result # ATTENDANCE LOGIC: Matches get_data exactly # 1. Use correct model 'attendance.attendance' (Event-based) # 2. Convert to User's Timezone # 3. Determine status based on 'action' field last_attendance = self.env['attendance.attendance'].sudo().search( [('employee_id', '=', employee.id)], order='name desc', limit=1 ) if last_attendance: is_attendance = (last_attendance.action == 'sign_in') # Timezone Conversion user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz or 'UTC') time_str = '' if last_attendance.name: # 'name' is the Datetime field in UTC time_object = fields.Datetime.from_string(last_attendance.name) # Convert to user's timezone time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz) # Format to string for display (removing timezone offset info for cleanliness) time_str = time_in_timezone.strftime('%Y-%m-%d %H:%M:%S') result['attendance'] = [{ 'is_attendance': is_attendance, 'time': time_str, }] # Calculate approval count import ast # Iterate over all dashboard lines (which represent "cards" or sections of cards) # We process lines directly to be more efficient than iterating boards -> lines config_lines = self.env['base.dashbord.line'].sudo().search([]) approval_count = 0 for line in config_lines: # 1. User Access Check using the existing helper method # This checks if the current user is in the allowed groups for this line if not self.is_user(line.group_ids, user): continue # 2. Get the parent dashboard definition dashboard = line.board_id if not dashboard or not dashboard.model_id: continue # 3. Construct Domain (Replicating logic from get_data) domain = [] # Add Action Domain (if configured) if dashboard.action_domain: try: # Parse string domain to list dom_list = ast.literal_eval(dashboard.action_domain) # Filter out 'state' from action domain as per get_data logic # This prevents conflict with the specific state/stage we are checking below for item in dom_list: if isinstance(item, (list, tuple)) and len(item) > 0 and item[0] != 'state': domain.append(item) except Exception: # Invalid domain string, ignore action domain pass # Special Handling for HR Holidays Workflow if dashboard.model_name == 'hr.holidays': hr_holidays_workflow = self.env['ir.module.module'].sudo().search([('name', '=', 'hr_holidays_workflow')], limit=1) if hr_holidays_workflow and hr_holidays_workflow.state == 'installed': # Logic from get_data: OR condition for stage name matching state domain.append('|') domain.append(('stage_id.name', '=', line.state_id.state)) # Add State/Stage Filter # Using the exact same logic as get_data to target the specific bucket if line.state_id: domain.append(('state', '=', line.state_id.state)) elif line.stage_id: # stage_id in stage.stage is a Char field holding the ID, so we cast to int # This matches get_data: ('stage_id', '=', int(line.stage_id.stage_id)) try: target_stage_id = int(line.stage_id.stage_id) domain.append(('stage_id', '=', target_stage_id)) except (ValueError, TypeError): pass # 4. Execute Count Query try: # Ensure the model exists in the registry if dashboard.model_name in self.env: line_count = self.env[dashboard.model_name].sudo().search_count(domain) approval_count += line_count except Exception: # Failsafe: If domain is invalid or model doesn't exist, skip this line pass result['approval_count'] = approval_count return result @api.model def get_work_timer_data(self): """ Returns live work timer data for countdown display Leverages existing attendance.attendance model and logic """ employee = self.env.user.employee_id if not employee: return {'enabled': False, 'reason': 'no_employee'} # Check if feature is enabled in settings ICP = self.env['ir.config_parameter'].sudo() is_enabled = ICP.get_param('system_dashboard_classic.show_work_timer', 'True') == 'True' if not is_enabled: return {'enabled': False, 'reason': 'disabled_in_settings'} today = fields.Date.today() # Get last attendance record today using same model as attendance_duration last_att = self.env['attendance.attendance'].sudo().search([ ('employee_id', '=', employee.id), ('action_date', '=', today) ], order='name DESC', limit=1) if not last_att: return { 'enabled': True, 'status': 'not_checked_in', 'message': _('لم تسجل دخول بعد') } # Get planned hours from resource calendar calendar = employee.resource_calendar_id planned_hours = 8.0 # Default fallback if calendar: day_of_week = today.weekday() attendance_lines = calendar.attendance_ids.filtered( lambda a: int(a.dayofweek) == day_of_week ) if attendance_lines: planned_hours = sum(line.hour_to - line.hour_from for line in attendance_lines) # If sign_out - use attendance_duration directly (existing logic) if last_att.action == 'sign_out': return { 'enabled': True, 'status': 'checked_out', 'hours_worked': last_att.attendance_duration, 'hours_worked_formatted': last_att.attendance_duration_hhmmss, 'planned_hours': planned_hours } # If sign_in - return start time for client-side countdown # Use same timezone logic as get_refresh_data import pytz user_tz = pytz.timezone(self.env.context.get('tz', 'Asia/Riyadh') or self.env.user.tz or 'UTC') # Convert UTC datetime to user timezone sign_in_utc = last_att.name sign_in_local = pytz.utc.localize(sign_in_utc).astimezone(user_tz) return { 'enabled': True, 'status': 'checked_in', 'sign_in_time': sign_in_local.isoformat(), # ISO format for JS Date parsing 'planned_hours': planned_hours, 'planned_seconds': int(planned_hours * 3600) }