odex25_standard/odex25_base/system_dashboard_classic/models/models.py

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