1067 lines
44 KiB
Python
1067 lines
44 KiB
Python
# -*- coding: utf-8 -*-
|
|
from odoo import models, fields, api, _
|
|
from odoo.exceptions import AccessError, UserError
|
|
import ast
|
|
import json
|
|
from datetime import datetime, date
|
|
from dateutil.relativedelta import relativedelta, SA, SU, MO
|
|
from math import radians, sin, cos, sqrt, asin
|
|
import logging
|
|
|
|
_logger = logging.getLogger(__name__)
|
|
|
|
|
|
class SystemDashboard(models.Model):
|
|
_name = 'system_dashboard_classic.dashboard'
|
|
_description = 'System Dashboard'
|
|
|
|
name = fields.Char("")
|
|
|
|
# ======================================================================
|
|
# HELPER METHODS
|
|
# ======================================================================
|
|
|
|
def is_user(self, groups, user):
|
|
"""Check whether current user is in a certain group"""
|
|
for group in groups:
|
|
if user.id in group.users.ids:
|
|
return True
|
|
return False
|
|
|
|
def _get_bool_param(self, key, default=True):
|
|
"""
|
|
Helper to safely get boolean value from ir.config_parameter.
|
|
Odoo 18 saves boolean config_parameter as 'True'/'False' strings.
|
|
"""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
value = ICP.get_param(key, None)
|
|
# If never set, use default
|
|
if value is None or value == '':
|
|
return default
|
|
# Handle string 'False' and 'True'
|
|
if isinstance(value, str):
|
|
return value.lower() not in ('false', '0', 'no')
|
|
# Handle actual boolean
|
|
return bool(value)
|
|
|
|
def _check_module_installed(self, module_name):
|
|
"""Check if a module is installed"""
|
|
module = self.env['ir.module.module'].sudo().search(
|
|
[('name', '=', module_name)], limit=1
|
|
)
|
|
return bool(module and module.state == 'installed')
|
|
|
|
def _get_user_timezone(self):
|
|
"""Get user's timezone with fallback"""
|
|
tz = self.env.context.get('tz') or self.env.user.tz or 'UTC'
|
|
try:
|
|
import pytz
|
|
return pytz.timezone(tz)
|
|
except Exception:
|
|
import pytz
|
|
return pytz.UTC
|
|
|
|
# ======================================================================
|
|
# MAIN DATA METHOD (Called by JavaScript)
|
|
# ======================================================================
|
|
|
|
@api.model
|
|
def get_data(self):
|
|
"""
|
|
Main RPC method called from JavaScript on dashboard load.
|
|
Returns user data, employee info, statistics, and card configurations.
|
|
"""
|
|
# Initialize return data structure
|
|
values = {
|
|
'user': [],
|
|
'employee': [],
|
|
'timesheet': [],
|
|
'leaves': [],
|
|
'payroll': [],
|
|
'attendance': [],
|
|
'attendance_hours': [],
|
|
'cards': [],
|
|
'chart_types': {},
|
|
'chart_colors': {},
|
|
'card_orders': {},
|
|
'stats_visibility': {},
|
|
'celebration': {},
|
|
'gender_info': {},
|
|
'refresh_settings': {},
|
|
'enable_attendance_button': False,
|
|
'job_english': '',
|
|
}
|
|
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
|
|
# Load chart type settings
|
|
values['chart_types'] = {
|
|
'annual_leave': ICP.get_param('system_dashboard_classic.annual_leave_chart_type', 'donut'),
|
|
'salary_slips': ICP.get_param('system_dashboard_classic.salary_slips_chart_type', 'donut'),
|
|
'timesheet': ICP.get_param('system_dashboard_classic.timesheet_chart_type', 'donut'),
|
|
'attendance_hours': ICP.get_param('system_dashboard_classic.attendance_hours_chart_type', 'donut'),
|
|
}
|
|
|
|
# Load chart colors from settings
|
|
values['chart_colors'] = {
|
|
'primary': ICP.get_param('system_dashboard_classic.primary_color', '#0891b2'),
|
|
'warning': ICP.get_param('system_dashboard_classic.warning_color', '#f59e0b'),
|
|
'secondary': ICP.get_param('system_dashboard_classic.secondary_color', '#1e293b'),
|
|
'success': ICP.get_param('system_dashboard_classic.success_color', '#10b981'),
|
|
}
|
|
|
|
# Load attendance button setting
|
|
values['enable_attendance_button'] = self._get_bool_param(
|
|
'system_dashboard_classic.enable_attendance_button', 'False'
|
|
)
|
|
|
|
# Load stats visibility settings
|
|
values['stats_visibility'] = {
|
|
'show_annual_leave': self._get_bool_param('system_dashboard_classic.show_annual_leave'),
|
|
'show_salary_slips': self._get_bool_param('system_dashboard_classic.show_salary_slips'),
|
|
'show_timesheet': self._get_bool_param('system_dashboard_classic.show_timesheet'),
|
|
'show_attendance_hours': self._get_bool_param('system_dashboard_classic.show_attendance_hours'),
|
|
'show_attendance_section': self._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',
|
|
'honorific': 'أستاذ',
|
|
'pronoun_you': 'ك',
|
|
'verb_suffix': 'تَ',
|
|
}
|
|
|
|
# Get current user and employee
|
|
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
|
|
)
|
|
|
|
# Get job title in English
|
|
if employee_object and employee_object.job_id:
|
|
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 ''
|
|
else:
|
|
job_english = ''
|
|
|
|
values['job_english'] = job_english
|
|
|
|
# ==========================================
|
|
# CELEBRATION & GENDER DETECTION
|
|
# ==========================================
|
|
today = date.today()
|
|
|
|
if employee_object:
|
|
# Gender-aware personalization
|
|
employee_gender = getattr(employee_object, 'gender', 'male') or 'male'
|
|
if employee_gender == 'female':
|
|
values['gender_info'] = {
|
|
'gender': 'female',
|
|
'honorific': 'أستاذة',
|
|
'pronoun_you': 'كِ',
|
|
'verb_suffix': 'تِ',
|
|
}
|
|
|
|
# Check for BIRTHDAY
|
|
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
|
|
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:
|
|
if joining_date.month == today.month and joining_date.day == today.day:
|
|
years = today.year - joining_date.year
|
|
if years > 0:
|
|
values['celebration']['is_anniversary'] = True
|
|
values['celebration']['anniversary_years'] = years
|
|
|
|
# ==========================================
|
|
# ATTENDANCE DATA
|
|
# ==========================================
|
|
attendance_date = {'is_attendance': False, 'time': False}
|
|
|
|
if self._check_module_installed('hr_attendance'):
|
|
self._load_attendance_data(employee_object, attendance_date)
|
|
|
|
# ==========================================
|
|
# LEAVES DATA
|
|
# ==========================================
|
|
leaves_data = {'taken': 0, 'remaining_leaves': 0, 'is_module_installed': False}
|
|
|
|
if self._check_module_installed('hr_holidays'):
|
|
self._load_leaves_data(employee_object, leaves_data)
|
|
|
|
# ==========================================
|
|
# PAYROLL DATA
|
|
# ==========================================
|
|
payroll_data = {'taken': 0, 'payslip_remaining': 0, 'is_module_installed': False}
|
|
|
|
if self._check_module_installed('hr_payroll'):
|
|
self._load_payroll_data(employee_object, payroll_data)
|
|
|
|
# ==========================================
|
|
# TIMESHEET DATA
|
|
# ==========================================
|
|
timesheet_data = {'taken': 0, 'timesheet_remaining': 0, 'is_module_installed': False}
|
|
|
|
if self._check_module_installed('hr_timesheet'):
|
|
self._load_timesheet_data(employee_object, timesheet_data)
|
|
|
|
# ==========================================
|
|
# ATTENDANCE HOURS DATA
|
|
# ==========================================
|
|
attendance_hours_data = {'plan_hours': 0, 'official_hours': 0, 'is_module_installed': False}
|
|
self._load_attendance_hours_data(employee_object, attendance_hours_data, today)
|
|
|
|
# ==========================================
|
|
# DASHBOARD CARDS
|
|
# ==========================================
|
|
self._load_dashboard_cards(user, values)
|
|
|
|
# ==========================================
|
|
# FINALIZE RETURN DATA
|
|
# ==========================================
|
|
values['user'].append(user_id)
|
|
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)
|
|
values['attendance_hours'].append(attendance_hours_data)
|
|
|
|
# Load user's saved card order preferences
|
|
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
|
|
values['refresh_settings'] = {
|
|
'enabled': self._get_bool_param('system_dashboard_classic.refresh_enabled', 'False'),
|
|
'interval': int(ICP.get_param('system_dashboard_classic.refresh_interval', '60'))
|
|
}
|
|
|
|
return values
|
|
|
|
# ======================================================================
|
|
# DATA LOADING HELPER METHODS
|
|
# ======================================================================
|
|
|
|
def _load_attendance_data(self, employee, data):
|
|
"""Load attendance check-in/out status"""
|
|
import pytz
|
|
|
|
if not employee:
|
|
return
|
|
|
|
try:
|
|
# Try custom attendance model first (attendance.attendance)
|
|
if 'attendance.attendance' in self.env:
|
|
last_attendance = self.env['attendance.attendance'].sudo().search(
|
|
[('employee_id', '=', employee.id)], limit=1, order="name desc"
|
|
)
|
|
|
|
if last_attendance:
|
|
data['is_attendance'] = (last_attendance.action == 'sign_in')
|
|
user_tz = self._get_user_timezone()
|
|
|
|
# Odoo 18: Use to_datetime instead of from_string
|
|
if last_attendance.name:
|
|
time_object = fields.Datetime.to_datetime(last_attendance.name)
|
|
if time_object:
|
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
data['time'] = time_in_timezone
|
|
|
|
# Fallback to standard hr.attendance model
|
|
elif 'hr.attendance' in self.env:
|
|
last_attendance = self.env['hr.attendance'].sudo().search(
|
|
[('employee_id', '=', employee.id)], limit=1, order="check_in desc"
|
|
)
|
|
|
|
if last_attendance:
|
|
data['is_attendance'] = not last_attendance.check_out
|
|
user_tz = self._get_user_timezone()
|
|
|
|
check_time = last_attendance.check_out or last_attendance.check_in
|
|
if check_time:
|
|
time_object = fields.Datetime.to_datetime(check_time)
|
|
if time_object:
|
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
data['time'] = time_in_timezone
|
|
|
|
except Exception as e:
|
|
_logger.warning(f"Error loading attendance data: {e}")
|
|
|
|
def _load_leaves_data(self, employee, data):
|
|
"""Load annual leave statistics"""
|
|
if not employee:
|
|
return
|
|
|
|
try:
|
|
data['is_module_installed'] = True
|
|
|
|
allocations = self.env['hr.holidays'].sudo().search(
|
|
[('employee_id', '=', employee.id), ('holiday_status_id.leave_type', '=', 'annual'),
|
|
('type', '=', 'add'),('check_allocation_view', '=', 'balance') ])
|
|
|
|
|
|
if allocations:
|
|
taken = sum(alloc.leaves_taken for alloc in allocations)
|
|
remaining = sum(alloc.remaining_leaves for alloc in allocations)
|
|
data['taken'] = taken
|
|
data['remaining_leaves'] = remaining
|
|
|
|
except Exception as e:
|
|
_logger.warning(f"Error loading leaves data: {e}")
|
|
|
|
def _load_payroll_data(self, employee, data):
|
|
"""Load payslip statistics for current year"""
|
|
if not employee:
|
|
return
|
|
|
|
try:
|
|
data['is_module_installed'] = True
|
|
|
|
first_day = date(date.today().year, 1, 1)
|
|
last_day = date(date.today().year, 12, 31)
|
|
|
|
payslip_count = self.env['hr.payslip'].sudo().search_count([
|
|
('employee_id', '=', employee.id),
|
|
('date_from', '>=', first_day),
|
|
('date_to', '<=', last_day)
|
|
])
|
|
|
|
# Calculate expected slips based on contract start date
|
|
contract = self.env['hr.contract'].sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('state', '=', 'open')
|
|
], limit=1)
|
|
|
|
expected_slips = 12
|
|
if contract and contract.date_start and contract.date_start.year == date.today().year:
|
|
expected_slips = 12 - contract.date_start.month + 1
|
|
|
|
remaining_slips = max(0, expected_slips - payslip_count)
|
|
|
|
data['taken'] = payslip_count
|
|
data['payslip_remaining'] = remaining_slips
|
|
|
|
except Exception as e:
|
|
_logger.warning(f"Error loading payroll data: {e}")
|
|
|
|
def _load_timesheet_data(self, employee, data):
|
|
"""Load weekly timesheet statistics"""
|
|
if not employee:
|
|
return
|
|
|
|
try:
|
|
data['is_module_installed'] = True
|
|
|
|
# Determine week boundaries
|
|
calendar = employee.resource_calendar_id
|
|
today = date.today()
|
|
|
|
# Default week start (Saturday)
|
|
start_date = today + relativedelta(weeks=-1, days=1, weekday=SA)
|
|
end_date = start_date + relativedelta(days=6)
|
|
|
|
# Calculate total working hours
|
|
total_working_hours = 0
|
|
if calendar:
|
|
total_working_hours = calendar.get_work_hours_count(
|
|
datetime.combine(start_date, datetime.min.time()),
|
|
datetime.combine(end_date, datetime.max.time()),
|
|
compute_leaves=True
|
|
)
|
|
else:
|
|
total_working_hours = 40.0 # Default fallback
|
|
|
|
# Get actual timesheet hours
|
|
timesheet = self.env['account.analytic.line'].sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('date', '>=', start_date),
|
|
('date', '<=', end_date)
|
|
])
|
|
|
|
done_hours = sum(sheet.unit_amount for sheet in timesheet)
|
|
|
|
data['taken'] = done_hours
|
|
data['timesheet_remaining'] = max(0, total_working_hours - done_hours)
|
|
|
|
except Exception as e:
|
|
_logger.warning(f"Error loading timesheet data: {e}")
|
|
|
|
def _load_attendance_hours_data(self, employee, data, today):
|
|
"""Load monthly attendance hours statistics"""
|
|
if not employee:
|
|
return
|
|
|
|
try:
|
|
# Try to use hr.attendance.transaction if available
|
|
if 'hr.attendance.transaction' in self.env:
|
|
first_day_of_month = date(today.year, today.month, 1)
|
|
|
|
attendance_txns = self.env['hr.attendance.transaction'].sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('date', '>=', first_day_of_month),
|
|
('date', '<=', today)
|
|
])
|
|
|
|
if employee.resource_calendar_id:
|
|
plan_hours_total = employee.resource_calendar_id.get_work_hours_count(
|
|
datetime.combine(first_day_of_month, datetime.min.time()),
|
|
datetime.combine(today, datetime.max.time()),
|
|
compute_leaves=True
|
|
)
|
|
else:
|
|
plan_hours_total = sum(txn.plan_hours for txn in attendance_txns)
|
|
|
|
official_hours_total = sum(txn.official_hours for txn in attendance_txns)
|
|
|
|
data['plan_hours'] = round(plan_hours_total, 2)
|
|
data['official_hours'] = round(official_hours_total, 2)
|
|
data['is_module_installed'] = True
|
|
|
|
# Fallback to standard hr.attendance
|
|
elif 'hr.attendance' in self.env:
|
|
first_day_of_month = date(today.year, today.month, 1)
|
|
|
|
attendances = self.env['hr.attendance'].sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('check_in', '>=', datetime.combine(first_day_of_month, datetime.min.time())),
|
|
('check_in', '<=', datetime.combine(today, datetime.max.time()))
|
|
])
|
|
|
|
if employee.resource_calendar_id:
|
|
plan_hours_total = employee.resource_calendar_id.get_work_hours_count(
|
|
datetime.combine(first_day_of_month, datetime.min.time()),
|
|
datetime.combine(today, datetime.max.time()),
|
|
compute_leaves=True
|
|
)
|
|
else:
|
|
plan_hours_total = 8 * 22 # Default 8 hours * 22 work days
|
|
|
|
official_hours_total = sum(att.worked_hours for att in attendances if att.worked_hours)
|
|
|
|
data['plan_hours'] = round(plan_hours_total, 2)
|
|
data['official_hours'] = round(official_hours_total, 2)
|
|
data['is_module_installed'] = True
|
|
|
|
except Exception as e:
|
|
_logger.warning(f"Error loading attendance hours data: {e}")
|
|
|
|
def _load_dashboard_cards(self, user, values):
|
|
"""Load dashboard card configurations"""
|
|
base = self.env['base.dashbord'].search([])
|
|
|
|
for models in base:
|
|
for model in models:
|
|
try:
|
|
model.with_user(user).check_access('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):
|
|
# Get card names with translations
|
|
card_name_ar = model.with_context(lang='ar_001').name or model.name
|
|
card_name_en = model.with_context(lang='en_US').name or model.name
|
|
|
|
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'
|
|
|
|
# Build domain and state/stage info
|
|
self._build_card_line_data(model, line, mod, user)
|
|
|
|
mod['lines'] = sorted(mod['lines'], key=lambda i: i['id'])
|
|
|
|
if mod['name']:
|
|
values['cards'].append(mod)
|
|
|
|
# Add self-service cards
|
|
if model.is_self_service:
|
|
self._add_self_service_card(model, user, values)
|
|
|
|
except AccessError:
|
|
continue
|
|
|
|
def _build_card_line_data(self, model, line, mod, user):
|
|
"""Build domain and count data for a card line"""
|
|
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)
|
|
|
|
action_domain_click = []
|
|
action_domain_follow = []
|
|
|
|
if model.action_domain:
|
|
try:
|
|
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, SyntaxError):
|
|
pass
|
|
|
|
# Handle hr.holidays workflow special case
|
|
if model.model_name == 'hr.holidays':
|
|
if self._check_module_installed('hr_holidays_workflow'):
|
|
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))
|
|
)
|
|
|
|
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)
|
|
|
|
try:
|
|
state_to_click = self.env[model.model_name].search_count(action_domain_click)
|
|
except Exception:
|
|
state_to_click = 0
|
|
|
|
mod['domain_to_follow'].append(state_follow)
|
|
action_domain_follow.append((state_or_stage, 'not in', mod['domain_to_follow']))
|
|
|
|
try:
|
|
state_to_follow = self.env[model.model_name].search_count(action_domain_follow)
|
|
except Exception:
|
|
state_to_follow = 0
|
|
|
|
domain_follow_js = str(action_domain_follow).replace('(', '[').replace(')', ']')
|
|
domain_follow_js = ast.literal_eval(domain_follow_js)
|
|
|
|
# State name translation
|
|
state = ""
|
|
if line.state_id:
|
|
state = line.state_id.state if self.env.user.lang == 'en_US' else line.state_id.name
|
|
elif line.stage_id:
|
|
state = line.stage_id.name if self.env.user.lang == 'en_US' else line.stage_id.value
|
|
|
|
mod['lines'].append({
|
|
'id': line.state_id.id if line.state_id else line.stage_id.id,
|
|
'count_state_click': state_to_click,
|
|
'count_state_follow': state_to_follow,
|
|
'state_approval': _('') + '' + _(line.state_id.name) if line.state_id else '',
|
|
'state_folow': _('All Records'),
|
|
'domain_to_follow': state_follow,
|
|
'form_view': model.form_view_id.id,
|
|
'list_view': model.list_view_id.id,
|
|
'domain_click': domain_click,
|
|
'domain_follow': domain_follow_js,
|
|
'context': model.action_context,
|
|
})
|
|
|
|
def _add_self_service_card(self, model, user, values):
|
|
"""Add a self-service card to the values"""
|
|
card_name = model.name or model.model_id.name
|
|
service_action_domain = []
|
|
|
|
if model.action_domain:
|
|
try:
|
|
service_action = ast.literal_eval(model.action_domain)
|
|
for i in service_action:
|
|
if i[0] != 'state':
|
|
service_action_domain.append(i)
|
|
except (ValueError, SyntaxError):
|
|
pass
|
|
|
|
if model.search_field:
|
|
service_action_domain.append((model.search_field, '=', user.id))
|
|
|
|
# Icon configuration
|
|
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
|
|
|
|
try:
|
|
state_count = self.env[model.model_name].search_count(service_action_domain)
|
|
except Exception:
|
|
state_count = 0
|
|
|
|
values['cards'].append({
|
|
'type': 'selfs',
|
|
'name': card_name,
|
|
'name_english': card_name,
|
|
'name_arabic': card_name,
|
|
'model': model.model_name,
|
|
'state_count': state_count,
|
|
'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 or {},
|
|
})
|
|
|
|
# ======================================================================
|
|
# PUBLIC API METHODS
|
|
# ======================================================================
|
|
|
|
@api.model
|
|
def get_public_dashboard_colors(self):
|
|
"""Get dashboard colors for JavaScript theming"""
|
|
ICP = self.env['ir.config_parameter'].sudo()
|
|
return {
|
|
'primary': ICP.get_param('system_dashboard_classic.primary_color', '#0891b2'),
|
|
'secondary': ICP.get_param('system_dashboard_classic.secondary_color', '#1e293b'),
|
|
'success': ICP.get_param('system_dashboard_classic.success_color', '#10b981'),
|
|
'warning': ICP.get_param('system_dashboard_classic.warning_color', '#f59e0b'),
|
|
}
|
|
|
|
# ======================================================================
|
|
# GEOLOCATION HELPER
|
|
# ======================================================================
|
|
|
|
def _haversine_distance(self, lat1, lon1, lat2, lon2):
|
|
"""
|
|
Calculate the great-circle distance between two GPS points using Haversine formula.
|
|
Returns distance in meters.
|
|
"""
|
|
R = 6371000 # Earth's radius in meters
|
|
|
|
lat1_rad, lon1_rad = radians(lat1), radians(lon1)
|
|
lat2_rad, lon2_rad = radians(lat2), radians(lon2)
|
|
|
|
dlat = lat2_rad - lat1_rad
|
|
dlon = lon2_rad - lon1_rad
|
|
|
|
a = sin(dlat / 2) ** 2 + cos(lat1_rad) * cos(lat2_rad) * sin(dlon / 2) ** 2
|
|
c = 2 * asin(sqrt(a))
|
|
|
|
return R * c
|
|
|
|
# ======================================================================
|
|
# CHECK-IN/CHECK-OUT
|
|
# ======================================================================
|
|
|
|
@api.model
|
|
def checkin_checkout(self, latitude=None, longitude=None):
|
|
"""
|
|
Check-in or Check-out with zone-based geolocation validation.
|
|
"""
|
|
import pytz
|
|
_logger = logging.getLogger(__name__)
|
|
_logger.info("=== checkin_checkout called ===")
|
|
_logger.info(f"User: {self.env.user.name}")
|
|
_logger.info(f"Lat/Long: {latitude}, {longitude}")
|
|
|
|
user = self.env.user
|
|
employee = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
|
|
|
|
if not employee:
|
|
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 (if attendance.zone model exists)
|
|
if 'attendance.zone' in self.env:
|
|
validation_result = self._validate_attendance_zone(employee, latitude, longitude)
|
|
if validation_result.get('error'):
|
|
return validation_result
|
|
|
|
# Perform attendance action
|
|
return self._perform_attendance_action(employee, latitude, longitude)
|
|
|
|
def _validate_attendance_zone(self, employee, latitude, longitude):
|
|
"""Validate if employee is within allowed attendance zone"""
|
|
AttendanceZone = self.env['attendance.zone'].sudo()
|
|
is_arabic = 'ar' in self._context.get('lang', 'en_US')
|
|
|
|
employee_zones = AttendanceZone.search([('employee_ids', 'in', employee.id)])
|
|
general_zone = employee_zones.filtered(lambda z: z.general)
|
|
|
|
if general_zone:
|
|
return {} # General zone allows from anywhere
|
|
|
|
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}
|
|
|
|
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}
|
|
|
|
# Check if within any zone
|
|
is_in_zone = False
|
|
closest_distance = float('inf')
|
|
allowed_range = 0
|
|
valid_zones = 0
|
|
|
|
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 += 1
|
|
distance = self._haversine_distance(latitude, longitude, zone_lat, zone_lon)
|
|
|
|
if distance <= zone_range:
|
|
is_in_zone = True
|
|
break
|
|
|
|
if distance < closest_distance:
|
|
closest_distance = distance
|
|
allowed_range = zone_range
|
|
|
|
if not is_in_zone:
|
|
if valid_zones == 0:
|
|
msg = ("بيانات منطقة الحضور غير مكتملة.\nيرجى التواصل مع إدارة النظام لتحديث الإحداثيات."
|
|
if is_arabic else
|
|
"Attendance zone data is incomplete.\nPlease contact system administration to update coordinates.")
|
|
return {'error': True, 'message': msg}
|
|
|
|
distance_outside = int(closest_distance - allowed_range)
|
|
if distance_outside >= 1000:
|
|
distance_km = round(distance_outside / 1000, 1)
|
|
dist_str = f"{distance_km} كيلو متر" if is_arabic else f"{distance_km} kilometers"
|
|
else:
|
|
dist_str = f"{distance_outside} متر" if is_arabic else f"{distance_outside} meters"
|
|
|
|
gender = getattr(employee, 'gender', 'male') or 'male'
|
|
if is_arabic:
|
|
if gender == 'female':
|
|
msg = f"عذراً، أنتِ تتواجدين خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة {dist_str} تقريباً أو أكثر لتتمكني من التسجيل."
|
|
else:
|
|
msg = f"عذراً، أنت تتواجد خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة {dist_str} تقريباً أو أكثر لتتمكن من التسجيل."
|
|
else:
|
|
msg = f"Sorry, you are outside the allowed attendance zone.\nPlease move approximately {dist_str} or more closer to be able to check in."
|
|
|
|
return {'error': True, 'message': msg}
|
|
|
|
return {}
|
|
|
|
def _perform_attendance_action(self, employee, latitude, longitude):
|
|
"""Perform the actual attendance check-in/out action"""
|
|
import pytz
|
|
|
|
user_tz = self._get_user_timezone()
|
|
|
|
# Try custom attendance model first
|
|
if 'attendance.attendance' in self.env:
|
|
Attendance = self.env['attendance.attendance']
|
|
|
|
last_attendance = Attendance.sudo().search(
|
|
[('employee_id', '=', employee.id)], limit=1, order="name desc"
|
|
)
|
|
|
|
vals = {
|
|
'employee_id': employee.id,
|
|
'action_type': 'manual',
|
|
'latitude': str(latitude) if latitude else False,
|
|
'longitude': str(longitude) if longitude else False,
|
|
}
|
|
|
|
if last_attendance and last_attendance.action == 'sign_in':
|
|
vals['action'] = 'sign_out'
|
|
is_attendance = False
|
|
else:
|
|
vals['action'] = 'sign_in'
|
|
is_attendance = True
|
|
|
|
Attendance.create(vals)
|
|
|
|
# Fetch updated record
|
|
last_attendance = Attendance.sudo().search(
|
|
[('employee_id', '=', employee.id)], limit=1, order="name desc"
|
|
)
|
|
|
|
time_in_timezone = False
|
|
if last_attendance and last_attendance.name:
|
|
time_object = fields.Datetime.to_datetime(last_attendance.name)
|
|
if time_object:
|
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
|
|
return {'is_attendance': is_attendance, 'time': time_in_timezone}
|
|
|
|
# Fallback to standard hr.attendance
|
|
elif 'hr.attendance' in self.env:
|
|
HrAttendance = self.env['hr.attendance']
|
|
|
|
last_attendance = HrAttendance.sudo().search(
|
|
[('employee_id', '=', employee.id)], limit=1, order="check_in desc"
|
|
)
|
|
|
|
if last_attendance and not last_attendance.check_out:
|
|
# Check out
|
|
last_attendance.write({'check_out': fields.Datetime.now()})
|
|
is_attendance = False
|
|
check_time = last_attendance.check_out
|
|
else:
|
|
# Check in
|
|
new_attendance = HrAttendance.create({'employee_id': employee.id})
|
|
is_attendance = True
|
|
check_time = new_attendance.check_in
|
|
|
|
time_in_timezone = False
|
|
if check_time:
|
|
time_object = fields.Datetime.to_datetime(check_time)
|
|
if time_object:
|
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
|
|
return {'is_attendance': is_attendance, 'time': time_in_timezone}
|
|
|
|
return {'error': True, 'message': 'Attendance module not configured'}
|
|
|
|
# ======================================================================
|
|
# CARD ORDER MANAGEMENT
|
|
# ======================================================================
|
|
|
|
@api.model
|
|
def save_card_order(self, order_type, card_ids):
|
|
"""Save card order preference for current user"""
|
|
user = self.env.user
|
|
|
|
try:
|
|
current_orders = json.loads(user.dashboard_card_orders or '{}')
|
|
except (json.JSONDecodeError, TypeError):
|
|
current_orders = {}
|
|
|
|
current_orders[order_type] = card_ids
|
|
|
|
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"""
|
|
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
|
|
|
|
# ======================================================================
|
|
# REFRESH DATA METHOD (Lightweight)
|
|
# ======================================================================
|
|
|
|
@api.model
|
|
def get_refresh_data(self):
|
|
"""
|
|
Lightweight method for periodic refresh.
|
|
Returns only attendance status and approval count.
|
|
"""
|
|
import pytz
|
|
|
|
result = {'attendance': [], 'approval_count': 0}
|
|
|
|
user = self.env.user
|
|
employee = self.env['hr.employee'].sudo().search([('user_id', '=', user.id)], limit=1)
|
|
|
|
if not employee:
|
|
return result
|
|
|
|
# Attendance status
|
|
if 'attendance.attendance' in self.env:
|
|
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')
|
|
user_tz = self._get_user_timezone()
|
|
time_str = ''
|
|
|
|
if last_attendance.name:
|
|
time_object = fields.Datetime.to_datetime(last_attendance.name)
|
|
if time_object:
|
|
time_in_timezone = pytz.utc.localize(time_object).astimezone(user_tz)
|
|
time_str = time_in_timezone.strftime('%Y-%m-%d %H:%M:%S')
|
|
|
|
result['attendance'] = [{'is_attendance': is_attendance, 'time': time_str}]
|
|
|
|
# Calculate approval count
|
|
config_lines = self.env['base.dashbord.line'].sudo().search([])
|
|
approval_count = 0
|
|
|
|
for line in config_lines:
|
|
if not self.is_user(line.group_ids, user):
|
|
continue
|
|
|
|
dashboard = line.board_id
|
|
if not dashboard or not dashboard.model_id:
|
|
continue
|
|
|
|
domain = []
|
|
|
|
if dashboard.action_domain:
|
|
try:
|
|
dom_list = ast.literal_eval(dashboard.action_domain)
|
|
for item in dom_list:
|
|
if isinstance(item, (list, tuple)) and len(item) > 0 and item[0] != 'state':
|
|
domain.append(item)
|
|
except Exception:
|
|
pass
|
|
|
|
if line.state_id:
|
|
domain.append(('state', '=', line.state_id.state))
|
|
elif line.stage_id:
|
|
try:
|
|
target_stage_id = int(line.stage_id.stage_id)
|
|
domain.append(('stage_id', '=', target_stage_id))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
|
|
try:
|
|
if dashboard.model_name in self.env:
|
|
line_count = self.env[dashboard.model_name].sudo().search_count(domain)
|
|
approval_count += line_count
|
|
except Exception:
|
|
pass
|
|
|
|
result['approval_count'] = approval_count
|
|
return result
|
|
|
|
# ======================================================================
|
|
# WORK TIMER DATA
|
|
# ======================================================================
|
|
|
|
@api.model
|
|
def get_work_timer_data(self):
|
|
"""Returns live work timer data for countdown display"""
|
|
employee = self.env.user.employee_id
|
|
|
|
if not employee:
|
|
return {'enabled': False, 'reason': 'no_employee'}
|
|
|
|
if not self._get_bool_param('system_dashboard_classic.show_work_timer', 'True'):
|
|
return {'enabled': False, 'reason': 'disabled_in_settings'}
|
|
|
|
today = fields.Date.today()
|
|
|
|
# Get calendar for planned hours
|
|
calendar = employee.resource_calendar_id
|
|
if calendar:
|
|
if hasattr(calendar, 'working_hours') and calendar.working_hours:
|
|
planned_hours = calendar.working_hours
|
|
elif hasattr(calendar, 'hours_per_day') and calendar.hours_per_day:
|
|
planned_hours = calendar.hours_per_day
|
|
else:
|
|
planned_hours = 8.0
|
|
else:
|
|
planned_hours = 8.0
|
|
|
|
# Check attendance status
|
|
if 'attendance.attendance' in self.env:
|
|
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': _('لم تسجل دخول بعد')
|
|
}
|
|
|
|
if last_att.action == 'sign_out':
|
|
return {
|
|
'enabled': True,
|
|
'status': 'checked_out',
|
|
'hours_worked': getattr(last_att, 'attendance_duration', 0),
|
|
'hours_worked_formatted': getattr(last_att, 'attendance_duration_hhmmss', '00:00:00'),
|
|
'planned_hours': planned_hours
|
|
}
|
|
|
|
# Currently checked in
|
|
last_sign_out = self.env['attendance.attendance'].sudo().search([
|
|
('employee_id', '=', employee.id),
|
|
('action_date', '=', today),
|
|
('action', '=', 'sign_out'),
|
|
('id', '<', last_att.id)
|
|
], order='name DESC', limit=1)
|
|
|
|
previous_duration = getattr(last_sign_out, 'attendance_duration', 0) if last_sign_out else 0
|
|
|
|
sign_in_utc = last_att.name
|
|
sign_in_local = fields.Datetime.context_timestamp(self, sign_in_utc)
|
|
|
|
return {
|
|
'enabled': True,
|
|
'status': 'checked_in',
|
|
'sign_in_time': sign_in_local.isoformat(),
|
|
'planned_hours': planned_hours,
|
|
'planned_seconds': int(planned_hours * 3600),
|
|
'previous_duration': previous_duration,
|
|
'previous_seconds': int(previous_duration * 3600)
|
|
}
|
|
|
|
return {'enabled': False, 'reason': 'attendance_module_not_available'}
|