874 lines
46 KiB
Python
874 lines
46 KiB
Python
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'] = {}
|
|
|
|
return values
|
|
|
|
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)
|
|
|
|
# Gender-aware Arabic message
|
|
# تتواجد (male) / تتواجدين (female)
|
|
# لتتمكن (male) / لتتمكني (female)
|
|
employee_gender = getattr(employee_object, 'gender', 'male') or 'male'
|
|
if employee_gender == 'female':
|
|
ar_msg = "عذراً، أنتِ تتواجدين خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة %s متر تقريباً أو أكثر لتتمكني من التسجيل."
|
|
else:
|
|
ar_msg = "عذراً، أنت تتواجد خارج نطاق الحضور المسموح.\nيرجى الاقتراب مسافة %s متر تقريباً أو أكثر لتتمكن من التسجيل."
|
|
|
|
msg_formats = {
|
|
'ar': ar_msg,
|
|
'en': "Sorry, you are outside the allowed attendance zone.\nPlease move approximately %s meters or more closer to be able to check in."
|
|
}
|
|
msg = (msg_formats['ar'] if is_arabic else msg_formats['en']) % distance_outside
|
|
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
|