[IMP] hr_(holidays)_attendance : Revamp Attendance V17

Part-of: odoo/odoo#135439
This commit is contained in:
Jurgen (jugj)
2023-10-19 14:47:18 +00:00
committed by jugj-odoo
parent 6173b5a0e8
commit bf67f1e20e
58 changed files with 1793 additions and 1430 deletions
-1
View File
@@ -3,4 +3,3 @@
from . import controllers
from . import models
from . import report
+45 -2
View File
@@ -22,10 +22,10 @@ actions(Check in/Check out) performed by them.
'security/ir.model.access.csv',
'views/hr_attendance_view.xml',
'views/hr_attendance_overtime_view.xml',
'report/hr_attendance_report_views.xml',
'views/hr_department_view.xml',
'views/hr_employee_view.xml',
'views/res_config_settings_views.xml',
'views/hr_attendance_kiosk_templates.xml'
],
'demo': [
'data/hr_attendance_demo.xml'
@@ -37,8 +37,51 @@ actions(Check in/Check out) performed by them.
'hr_attendance/static/src/**/*',
],
'web.qunit_suite_tests': [
'hr_attendance/static/tests/client_action_tests.js',
'hr_attendance/static/tests/hr_attendance_mock_server.js',
],
'web.qunit_mobile_suite_tests': [
'hr_attendance/static/tests/hr_attendance_mock_server.js',
],
'hr_attendance.assets_public_attendance': [
# Front-end libraries
('include', 'web._assets_helpers'),
('include', 'web._assets_frontend_helpers'),
'web/static/src/scss/pre_variables.scss',
'web/static/lib/bootstrap/scss/_variables.scss',
('include', 'web._assets_bootstrap_frontend'),
('include', 'web._assets_bootstrap_backend'),
'/web/static/lib/odoo_ui_icons/*',
'/web/static/lib/bootstrap/scss/_functions.scss',
'/web/static/lib/bootstrap/scss/_mixins.scss',
'/web/static/lib/bootstrap/scss/utilities/_api.scss',
'web/static/src/libs/fontawesome/css/font-awesome.css',
('include', 'web._assets_core'),
# Public Kiosk app and its components
"hr_attendance/static/src/public_kiosk/**/*",
"hr_attendance/static/src/hr_attendance.scss",
'hr_attendance/static/src/components/**/*',
"web/static/src/views/fields/formatters.js",
# Barcode reader utils
"web/static/src/webclient/barcode/barcode_scanner.js",
"web/static/src/webclient/barcode/barcode_scanner.xml",
"web/static/src/webclient/barcode/barcode_scanner.scss",
"web/static/src/webclient/barcode/crop_overlay.js",
"web/static/src/webclient/webclient_layout.scss",
"web/static/src/webclient/barcode/crop_overlay.xml",
"web/static/src/webclient/barcode/crop_overlay.scss",
"web/static/src/webclient/barcode/ZXingBarcodeDetector.js",
"barcodes/static/src/components/barcode_scanner.js",
"barcodes/static/src/components/barcode_scanner.xml",
"barcodes/static/src/components/barcode_scanner.scss",
"barcodes/static/src/barcode_service.js",
# Kanban view mock
"web/static/src/views/kanban/kanban_controller.scss",
"web/static/src/search/search_panel/search_panel.scss",
"web/static/src/search/control_panel/control_panel.scss",
]
},
'license': 'LGPL-3',
}
+141 -2
View File
@@ -1,12 +1,151 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import http
from odoo import http, _
from odoo.http import request
import datetime
class HrAttendance(http.Controller):
@staticmethod
def _get_company(token):
company = request.env['res.company'].sudo().search([('attendance_kiosk_key', '=', token)])
return company
@staticmethod
def _get_employee_info_response(employee):
response = {}
if employee:
response = {
'id': employee.id,
'employee_name': employee.name,
'employee_avatar': employee.image_1920,
'hours_today': employee.hours_today,
'total_overtime': employee.total_overtime,
'last_attendance_worked_hours': employee.last_attendance_worked_hours,
'last_check_in': employee.last_check_in,
'attendance_state': employee.attendance_state,
'hours_previously_today': employee.hours_previously_today,
'kiosk_delay': employee.company_id.attendance_kiosk_delay * 1000,
'attendance': {'check_in': employee.last_attendance_id.check_in,
'check_out': employee.last_attendance_id.check_out},
'overtime_today': request.env['hr.attendance.overtime'].sudo().search([
('employee_id', '=', employee.id), ('date', '=', datetime.date.today()),
('adjustment', '=', False)]).duration or 0,
'use_pin': employee.company_id.attendance_kiosk_use_pin,
'display_systray': employee.company_id.attendance_from_systray,
'display_overtime': employee.company_id.hr_attendance_display_overtime
}
return response
@staticmethod
def _get_geoip_response(mode, latitude=False, longitude=False):
return {
'city': request.geoip.city.name or _('Unknown'),
'country_name': request.geoip.country.name or request.geoip.continent.name or _('Unknown'),
'latitude': latitude or request.geoip.location.latitude or False,
'longitude': longitude or request.geoip.location.longitude or False,
'ip_address': request.geoip.ip,
'browser': request.httprequest.user_agent.browser,
'mode': mode
}
@http.route('/hr_attendance/kiosk_mode_menu', auth='user', type='http')
def kiosk_menu_item_action(self):
if request.env.user.user_has_groups("hr_attendance.group_hr_attendance_manager"):
# Auto log out will prevent users from forgetting to log out of their session
# before leaving the kiosk mode open to the public. This is a prevention security
# measure.
request.session.logout(keep_db=True)
return request.redirect(request.env.user.company_id.attendance_kiosk_url)
else:
return request.not_found()
@http.route('/hr_attendance/kiosk_keepalive', auth='user', type='json')
def kiosk_keepalive(self):
request.session.touch()
return {}
@http.route(["/hr_attendance/<token>"], type='http', auth='public', website=True, sitemap=True)
def open_kiosk_mode(self, token):
company = self._get_company(token)
if not company:
return request.not_found()
else:
employee_list = [{"id": e["id"],
"name": e["name"],
"avatar": e["avatar_1024"].decode(),
"job": e["job_id"][1] if e["job_id"] else False,
"department": {"id": e["department_id"][0] if e["department_id"] else False,
"name": e["department_id"][1] if e["department_id"] else False
}
} for e in request.env['hr.employee'].sudo().search_read(domain=[('company_id', '=', company.id)],
fields=["id",
"name",
"avatar_1024",
"job_id",
"department_id"])]
departement_list = [{'id': dep["id"],
'name': dep["name"],
'count': dep["total_employee"]
} for dep in request.env['hr.department'].sudo().search_read(domain=[('company_id', '=', company.id)],
fields=["id",
"name",
"total_employee"])]
request.session.logout(keep_db=True)
return request.render(
'hr_attendance.public_kiosk_mode',
{
'kiosk_backend_info': {
'token': token,
'company_id': company.id,
'company_name': company.name,
'employees': employee_list,
'departments': departement_list,
'kiosk_mode': company.attendance_kiosk_mode,
'barcode_source': company.attendance_barcode_source
}
}
)
@http.route('/hr_attendance/attendance_employee_data', type="json", auth="public")
def employee_attendance_data(self, token, employee_id):
company = self._get_company(token)
if company:
employee = request.env['hr.employee'].sudo().browse(employee_id)
if employee.company_id == company:
return self._get_employee_info_response(employee)
return {}
@http.route('/hr_attendance/attendance_barcode_scanned', type="json", auth="public")
def scan_barcode(self, token, barcode):
company = self._get_company(token)
if company:
employee = request.env['hr.employee'].sudo().search([('barcode', '=', barcode), ('company_id', '=', company.id)], limit=1)
if employee:
employee._attendance_action_change(self._get_geoip_response('kiosk'))
return self._get_employee_info_response(employee)
return {}
@http.route('/hr_attendance/manual_selection', type="json", auth="public")
def manual_selection(self, token, employee_id, pin_code):
company = self._get_company(token)
if company:
employee = request.env['hr.employee'].sudo().browse(employee_id)
if employee.company_id == company and ((not company.attendance_kiosk_use_pin) or (employee.pin == pin_code)):
employee.sudo()._attendance_action_change(self._get_geoip_response('kiosk'))
return self._get_employee_info_response(employee)
return {}
@http.route('/hr_attendance/systray_check_in_out', type="json", auth="user")
def systray_attendance(self, latitude=False, longitude=False):
employee = request.env.user.employee_id
geo_ip_response = self._get_geoip_response(mode='systray',
latitude=latitude,
longitude=longitude)
employee._attendance_action_change(geo_ip_response)
return self._get_employee_info_response(employee)
@http.route('/hr_attendance/attendance_user_data', type="json", auth="user")
def user_attendance_data(self):
employee = request.env.user.employee_id
return self._get_employee_info_response(employee)
@@ -2,7 +2,7 @@
<odoo>
<data noupdate="True">
<record id="base.user_demo" model="res.users">
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_user'))]"/>
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<record id="hr.employee_al" model="hr.employee">
@@ -11,6 +11,7 @@
</record>
<record id="hr.employee_admin" model="hr.employee">
<field name="pin">0000</field>
<field name="barcode">456</field>
</record>
-1
View File
@@ -6,6 +6,5 @@ from . import hr_attendance_overtime
from . import hr_employee_base
from . import hr_employee
from . import hr_employee_public
from . import ir_ui_menu
from . import res_company
from . import res_users
+115 -12
View File
@@ -14,12 +14,17 @@ from odoo.tools import format_datetime
from odoo.osv.expression import AND, OR
from odoo.tools.float_utils import float_is_zero
from odoo.exceptions import AccessError
from odoo.tools import format_duration
def get_google_maps_url(latitude, longitude):
return "https://maps.google.com?q=%s,%s" % (latitude, longitude)
class HrAttendance(models.Model):
_name = "hr.attendance"
_description = "Attendance"
_order = "check_in desc"
_inherit = "mail.thread"
def _default_employee(self):
return self.env.user.employee_id
@@ -27,25 +32,101 @@ class HrAttendance(models.Model):
employee_id = fields.Many2one('hr.employee', string="Employee", default=_default_employee, required=True, ondelete='cascade', index=True)
department_id = fields.Many2one('hr.department', string="Department", related="employee_id.department_id",
readonly=True)
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True)
check_out = fields.Datetime(string="Check Out")
check_in = fields.Datetime(string="Check In", default=fields.Datetime.now, required=True, tracking=True)
check_out = fields.Datetime(string="Check Out", tracking=True)
worked_hours = fields.Float(string='Worked Hours', compute='_compute_worked_hours', store=True, readonly=True)
color = fields.Integer(compute='_compute_color')
overtime_hours = fields.Float(string="Over Time", compute='_compute_overtime_hours', store=True)
in_latitude = fields.Float(string="Latitude", digits=(10, 7), readonly=True)
in_longitude = fields.Float(string="Longitude", digits=(10, 7), readonly=True)
in_country_name = fields.Char(string="Country", help="Based on IP Address", readonly=True)
in_city = fields.Char(string="City", readonly=True)
in_ip_address = fields.Char(string="IP Address", readonly=True)
in_browser = fields.Char(string="Browser", readonly=True)
in_mode = fields.Selection(string="Mode",
selection=[('kiosk', "Kiosk"),
('systray', "Systray"),
('manual', "Manual")],
readonly=True,
default='manual')
out_latitude = fields.Float(digits=(10, 7), readonly=True)
out_longitude = fields.Float(digits=(10, 7), readonly=True)
out_country_name = fields.Char(help="Based on IP Address", readonly=True)
out_city = fields.Char(readonly=True)
out_ip_address = fields.Char(readonly=True)
out_browser = fields.Char(readonly=True)
out_mode = fields.Selection(selection=[('kiosk', "Kiosk"),
('systray', "Systray"),
('manual', "Manual")],
readonly=True,
default='manual')
def _compute_color(self):
for attendance in self:
if attendance.check_out:
attendance.color = 1 if attendance.worked_hours > 16 else 0
else:
attendance.color = 1 if attendance.check_in < (datetime.today() - timedelta(days=1)) else 10
@api.depends('worked_hours')
def _compute_overtime_hours(self):
self.env['hr.attendance'].flush_model(['worked_hours'])
self.env['hr.attendance.overtime'].flush_model(['duration'])
self.env.cr.execute('''
SELECT att.id as att_id,
att.worked_hours as att_wh,
ot.id as ot_id,
ot.duration as ot_d,
ot.date as od,
att.check_in as ad
FROM hr_attendance att
INNER JOIN hr_attendance_overtime ot
ON date_trunc('day',att.check_in) = date_trunc('day', ot.date)
AND date_trunc('day',att.check_out) = date_trunc('day', ot.date)
AND att.employee_id IN %s
AND att.employee_id = ot.employee_id
ORDER BY att.check_in DESC
''', (tuple(self.employee_id.ids),))
a = self.env.cr.dictfetchall()
grouped_dict = dict()
for row in a:
if row['ot_id'] and row['att_wh']:
if row['ot_id'] not in grouped_dict:
grouped_dict[row['ot_id']] = {'attendances': [(row['att_id'], row['att_wh'])], 'overtime_duration': row['ot_d']}
else:
grouped_dict[row['ot_id']]['attendances'].append((row['att_id'], row['att_wh']))
att_progress_values = dict()
for ot in grouped_dict:
ot_bucket = grouped_dict[ot]['overtime_duration']
for att in grouped_dict[ot]['attendances']:
if ot_bucket > 0:
sub_time = att[1] - ot_bucket
if sub_time < 0:
att_progress_values[att[0]] = 0
ot_bucket -= att[1]
else:
att_progress_values[att[0]] = float(((att[1] - ot_bucket) / att[1])*100)
ot_bucket = 0
else:
att_progress_values[att[0]] = 100
for attendance in self:
attendance.overtime_hours = attendance.worked_hours * ((100 - att_progress_values.get(attendance.id, 100))/100)
@api.depends('employee_id', 'check_in', 'check_out')
def _compute_display_name(self):
for attendance in self:
if not attendance.check_out:
attendance.display_name = _(
"%s from %s",
attendance.employee_id.name,
format_datetime(self.env, attendance.check_in, dt_format=False)
"From %s",
format_datetime(self.env, attendance.check_in, dt_format="HH:mm"),
)
else:
attendance.display_name = _(
"%s from %s to %s",
attendance.employee_id.name,
format_datetime(self.env, attendance.check_in, dt_format=False),
format_datetime(self.env, attendance.check_out, dt_format=False),
"%s : (%s-%s)",
format_duration(attendance.worked_hours),
format_datetime(self.env, attendance.check_in, dt_format="HH:mm"),
format_datetime(self.env, attendance.check_out, dt_format="HH:mm"),
)
def _get_employee_calendar(self):
@@ -156,7 +237,7 @@ class HrAttendance(models.Model):
overtime_to_unlink = self.env['hr.attendance.overtime']
overtime_vals_list = []
affected_employees = self.env['hr.employee']
for emp, attendance_dates in employee_attendance_dates.items():
# get_attendances_dates returns the date translated from the local timezone without tzinfo,
# and contains all the date which we need to check for overtime
@@ -295,10 +376,16 @@ class HrAttendance(models.Model):
'duration': overtime_duration,
'duration_real': overtime_duration
})
affected_employees |= overtime.employee_id
elif overtime:
overtime_to_unlink |= overtime
self.env['hr.attendance.overtime'].sudo().create(overtime_vals_list)
created_overtimes = self.env['hr.attendance.overtime'].sudo().create(overtime_vals_list)
employees_worked_hours_to_compute = (affected_employees.ids +
created_overtimes.employee_id.ids +
overtime_to_unlink.employee_id.ids)
overtime_to_unlink.sudo().unlink()
self.env.add_to_compute(self._fields['overtime_hours'],
self.search([('employee_id', 'in', employees_worked_hours_to_compute)]))
@api.model_create_multi
def create(self, vals_list):
@@ -309,7 +396,7 @@ class HrAttendance(models.Model):
def write(self, vals):
if vals.get('employee_id') and \
vals['employee_id'] not in self.env.user.employee_ids.ids and \
not self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
not self.env.user.has_group('hr_attendance.group_hr_attendance_officer'):
raise AccessError(_("Do not have access, user cannot edit the attendances that are not his own."))
attendances_dates = self._get_attendances_dates()
result = super(HrAttendance, self).write(vals)
@@ -330,3 +417,19 @@ class HrAttendance(models.Model):
@api.returns('self', lambda value: value.id)
def copy(self, default=None):
raise exceptions.UserError(_('You cannot duplicate an attendance.'))
def action_in_attendance_maps(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': get_google_maps_url(self.in_latitude, self.in_longitude),
'target': 'new'
}
def action_out_attendance_maps(self):
self.ensure_one()
return {
'type': 'ir.actions.act_url',
'url': get_google_maps_url(self.out_latitude, self.out_longitude),
'target': 'new'
}
+114 -81
View File
@@ -11,33 +11,70 @@ from odoo.tools import float_round
class HrEmployee(models.Model):
_inherit = "hr.employee"
attendance_manager_id = fields.Many2one(
'res.users', store=True, readonly=False,
domain="[('share', '=', False), ('company_ids', 'in', company_id)]",
groups="hr_attendance.group_hr_attendance_manager",
help="The user set in Attendance will access the attendance of the employee through the dedicated app and will be able to edit them.")
attendance_ids = fields.One2many(
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
'hr.attendance', 'employee_id', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
last_attendance_id = fields.Many2one(
'hr.attendance', compute='_compute_last_attendance_id', store=True,
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
last_check_in = fields.Datetime(
related='last_attendance_id.check_in', store=True,
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user", tracking=False)
last_check_out = fields.Datetime(
related='last_attendance_id.check_out', store=True,
groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user", tracking=False)
attendance_state = fields.Selection(
string="Attendance Status", compute='_compute_attendance_state',
selection=[('checked_out', "Checked out"), ('checked_in', "Checked in")],
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
hours_last_month = fields.Float(
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
hours_today = fields.Float(
compute='_compute_hours_today',
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
hours_previously_today = fields.Float(
compute='_compute_hours_today',
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
last_attendance_worked_hours = fields.Float(
compute='_compute_hours_today',
groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
hours_last_month_display = fields.Char(
compute='_compute_hours_last_month', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
compute='_compute_hours_last_month')
overtime_ids = fields.One2many(
'hr.attendance.overtime', 'employee_id', groups="hr_attendance.group_hr_attendance_user,hr.group_hr_user")
'hr.attendance.overtime', 'employee_id', groups="hr_attendance.group_hr_attendance_officer,hr.group_hr_user")
total_overtime = fields.Float(
compute='_compute_total_overtime', compute_sudo=True,
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance,hr.group_hr_user")
compute='_compute_total_overtime', compute_sudo=True)
@api.model_create_multi
def create(self, vals_list):
officer_group = self.env.ref('hr_attendance.group_hr_attendance_officer', raise_if_not_found=False)
group_updates = []
for vals in vals_list:
if officer_group and vals.get('attendance_manager_id'):
group_updates.append((4, vals['attendance_manager_id']))
if group_updates:
officer_group.sudo().write({'users': group_updates})
return super().create(vals_list)
def write(self, values):
old_officers = self.env['res.users']
if 'attendance_manager_id' in values:
old_officers = self.attendance_manager_id
# Officer was added
if values['attendance_manager_id']:
officer = self.env['res.users'].browse(values['attendance_manager_id'])
officers_group = self.env.ref('hr_attendance.group_hr_attendance_officer', raise_if_not_found=False)
if officers_group and not officer.has_group('hr_attendance.group_hr_attendance_officer'):
officer.sudo().write({'groups_id': [(4, officers_group.id)]})
res = super(HrEmployee, self).write(values)
old_officers.sudo()._clean_attendance_officers()
return res
@api.depends('overtime_ids.duration', 'attendance_ids')
def _compute_total_overtime(self):
@@ -48,30 +85,26 @@ class HrEmployee(models.Model):
employee.total_overtime = 0
def _compute_hours_last_month(self):
"""
Compute hours in the current month, if we are the 15th of october, will compute hours from 1 oct to 15 oct
"""
now = fields.Datetime.now()
now_utc = pytz.utc.localize(now)
for employee in self:
tz = pytz.timezone(employee.tz or 'UTC')
now_tz = now_utc.astimezone(tz)
start_tz = now_tz + relativedelta(months=-1, day=1, hour=0, minute=0, second=0, microsecond=0)
start_tz = now_tz.replace(day=1, hour=0, minute=0, second=0, microsecond=0)
start_naive = start_tz.astimezone(pytz.utc).replace(tzinfo=None)
end_tz = now_tz + relativedelta(day=1, hour=0, minute=0, second=0, microsecond=0)
end_tz = now_tz
end_naive = end_tz.astimezone(pytz.utc).replace(tzinfo=None)
attendances = self.env['hr.attendance'].search([
('employee_id', '=', employee.id),
'&',
('check_in', '<=', end_naive),
('check_out', '>=', start_naive),
('check_in', '>=', start_naive),
('check_out', '<=', end_naive),
])
hours = 0
for attendance in attendances:
check_in = max(attendance.check_in, start_naive)
check_out = min(attendance.check_out, end_naive)
hours += (check_out - check_in).total_seconds() / 3600.0
employee.hours_last_month = round(hours, 2)
employee.hours_last_month = round(sum([att.worked_hours for att in attendances]), 2)
employee.hours_last_month_display = "%g" % employee.hours_last_month
def _compute_hours_today(self):
@@ -88,12 +121,18 @@ class HrEmployee(models.Model):
('employee_id', '=', employee.id),
('check_in', '<=', now),
'|', ('check_out', '>=', start_naive), ('check_out', '=', False),
])
], order='check_in asc')
hours_previously_today = 0
worked_hours = 0
attendance_worked_hours = 0
for attendance in attendances:
delta = (attendance.check_out or now) - max(attendance.check_in, start_naive)
worked_hours += delta.total_seconds() / 3600.0
attendance_worked_hours = delta.total_seconds() / 3600.0
worked_hours += attendance_worked_hours
hours_previously_today += attendance_worked_hours
employee.last_attendance_worked_hours = attendance_worked_hours
hours_previously_today -= attendance_worked_hours
employee.hours_previously_today = hours_previously_today
employee.hours_today = worked_hours
@api.depends('attendance_ids')
@@ -101,7 +140,7 @@ class HrEmployee(models.Model):
for employee in self:
employee.last_attendance_id = self.env['hr.attendance'].search([
('employee_id', '=', employee.id),
], limit=1)
], order="check_in desc", limit=1)
@api.depends('last_attendance_id.check_in', 'last_attendance_id.check_out', 'last_attendance_id')
def _compute_attendance_state(self):
@@ -109,55 +148,7 @@ class HrEmployee(models.Model):
att = employee.last_attendance_id.sudo()
employee.attendance_state = att and not att.check_out and 'checked_in' or 'checked_out'
@api.model
def attendance_scan(self, barcode):
""" Receive a barcode scanned from the Kiosk Mode and change the attendances of corresponding employee.
Returns either an action or a warning.
"""
employee = self.sudo().search([('barcode', '=', barcode)], limit=1)
if employee:
return employee._attendance_action('hr_attendance.hr_attendance_action_kiosk_mode')
return {'warning': _("No employee corresponding to Badge ID '%(barcode)s.'") % {'barcode': barcode}}
def attendance_manual(self, next_action, entered_pin=None):
self.ensure_one()
attendance_user_and_no_pin = self.user_has_groups(
'hr_attendance.group_hr_attendance_user,'
'!hr_attendance.group_hr_attendance_use_pin')
can_check_without_pin = attendance_user_and_no_pin or (self.user_id == self.env.user and entered_pin is None)
if can_check_without_pin or entered_pin is not None and entered_pin == self.sudo().pin:
return self._attendance_action(next_action)
if not self.user_has_groups('hr_attendance.group_hr_attendance_user'):
return {'warning': _('To activate Kiosk mode without pin code, you must have access right as an Officer or above in the Attendance app. Please contact your administrator.')}
return {'warning': _('Wrong PIN')}
def _attendance_action(self, next_action):
""" Changes the attendance of the employee.
Returns an action to the check in/out message,
next_action defines which menu the check in/out message should return to. ("My Attendances" or "Kiosk Mode")
"""
self.ensure_one()
employee = self.sudo()
action_message = self.env["ir.actions.actions"]._for_xml_id("hr_attendance.hr_attendance_action_greeting_message")
action_message['previous_attendance_change_date'] = employee.last_attendance_id and (employee.last_attendance_id.check_out or employee.last_attendance_id.check_in) or False
action_message['employee_name'] = employee.name
action_message['barcode'] = employee.barcode
action_message['next_action'] = next_action
action_message['hours_today'] = employee.hours_today
action_message['kiosk_delay'] = employee.company_id.attendance_kiosk_delay * 1000
if employee.user_id:
modified_attendance = employee.with_user(employee.user_id).sudo()._attendance_action_change()
else:
modified_attendance = employee._attendance_action_change()
action_message['attendance'] = modified_attendance.read()[0]
action_message['total_overtime'] = employee.total_overtime
# Overtime have an unique constraint on the day, no need for limit=1
action_message['overtime_today'] = self.env['hr.attendance.overtime'].sudo().search([
('employee_id', '=', employee.id), ('date', '=', fields.Date.context_today(self)), ('adjustment', '=', False)]).duration or 0
return {'action': action_message}
def _attendance_action_change(self):
def _attendance_action_change(self, geo_information=None):
""" Check In/Check Out action
Check In: create a new attendance record
Check Out: modify check_out field of appropriate attendance record
@@ -166,15 +157,57 @@ class HrEmployee(models.Model):
action_date = fields.Datetime.now()
if self.attendance_state != 'checked_in':
vals = {
'employee_id': self.id,
'check_in': action_date,
}
if geo_information:
vals = {
'employee_id': self.id,
'check_in': action_date,
**{'in_%s' % key: geo_information[key] for key in geo_information}
}
else:
vals = {
'employee_id': self.id,
'check_in': action_date,
}
return self.env['hr.attendance'].create(vals)
attendance = self.env['hr.attendance'].search([('employee_id', '=', self.id), ('check_out', '=', False)], limit=1)
if attendance:
attendance.check_out = action_date
if geo_information:
attendance.write({
'check_out': action_date,
**{'out_%s' % key: geo_information[key] for key in geo_information}
})
else:
attendance.write({
'check_out': action_date
})
else:
raise exceptions.UserError(_('Cannot perform check out on %(empl_name)s, could not find corresponding check in. '
'Your attendances have probably been modified manually by human resources.') % {'empl_name': self.sudo().name, })
return attendance
def action_open_last_month_attendances(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": _("Attendances This Month"),
"res_model": "hr.attendance",
"views": [[self.env.ref('hr_attendance.hr_attendance_employee_simple_tree_view').id, "tree"]],
"context": {
"create": 0
},
"domain": [('employee_id', '=', self.id),
('check_in', ">=", fields.datetime.today().replace(day=1, hour=0, minute=0))]
}
def action_open_last_month_overtime(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": _("Overtime"),
"res_model": "hr.attendance.overtime",
"views": [[False, "tree"]],
"context": {
"create": 0
},
"domain": [('employee_id', '=', self.id)]
}
@@ -8,22 +8,16 @@ class HrEmployeePublic(models.Model):
# These are required for manual attendance
attendance_state = fields.Selection(related='employee_id.attendance_state', readonly=True,
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
groups="hr_attendance.group_hr_attendance_officer")
hours_today = fields.Float(related='employee_id.hours_today', readonly=True,
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
groups="hr_attendance.group_hr_attendance_officer")
last_attendance_id = fields.Many2one(related='employee_id.last_attendance_id', readonly=True,
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
groups="hr_attendance.group_hr_attendance_officer")
total_overtime = fields.Float(related='employee_id.total_overtime', readonly=True,
groups="hr_attendance.group_hr_attendance_kiosk,hr_attendance.group_hr_attendance")
def action_employee_kiosk_confirm(self):
self.ensure_one()
return {
'type': 'ir.actions.client',
'name': 'Confirm',
'tag': 'hr_attendance_kiosk_confirm',
'employee_id': self.id,
'employee_name': self.name,
'employee_state': self.attendance_state,
'employee_hours_today': self.hours_today,
}
groups="hr_attendance.group_hr_attendance_officer")
attendance_manager_id = fields.Many2one(related='employee_id.attendance_manager_id',
groups="hr_attendance.group_hr_attendance_officer")
last_check_in = fields.Datetime(related='employee_id.last_check_in',
groups="hr_attendance.group_hr_attendance_officer")
last_check_out = fields.Datetime(related='employee_id.last_check_out',
groups="hr_attendance.group_hr_attendance_officer")
-14
View File
@@ -1,14 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models
class IrUiMenu(models.Model):
_inherit = 'ir.ui.menu'
def _load_menus_blacklist(self):
res = super()._load_menus_blacklist()
if self.env.user.has_group('hr_attendance.group_hr_attendance_user'):
res.append(self.env.ref('hr_attendance.menu_hr_attendance_attendances_overview').id)
return res
+26 -2
View File
@@ -1,8 +1,10 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import fields, models
from odoo import fields, models, api
from odoo.osv.expression import OR
import uuid
from werkzeug.urls import url_join
class ResCompany(models.Model):
@@ -12,7 +14,7 @@ class ResCompany(models.Model):
overtime_start_date = fields.Date(string="Extra Hours Starting Date")
overtime_company_threshold = fields.Integer(string="Tolerance Time In Favor Of Company", default=0)
overtime_employee_threshold = fields.Integer(string="Tolerance Time In Favor Of Employee", default=0)
hr_attendance_display_overtime = fields.Boolean(string="Display Extra Hours")
attendance_kiosk_mode = fields.Selection([
('barcode', 'Barcode / RFID'),
('barcode_manual', 'Barcode / RFID and Manual Selection'),
@@ -24,6 +26,15 @@ class ResCompany(models.Model):
('back', 'Back Camera'),
], string='Barcode Source', default='front')
attendance_kiosk_delay = fields.Integer(default=10)
attendance_kiosk_key = fields.Char(default=lambda s: uuid.uuid4().hex, copy=False, groups='hr_attendance.group_hr_attendance_manager')
attendance_kiosk_url = fields.Char(compute="_compute_attendance_kiosk_url")
attendance_kiosk_use_pin = fields.Boolean(string='Employee PIN Identification')
attendance_from_systray = fields.Boolean(string='Attendance From Systray', default=True)
@api.depends("attendance_kiosk_key")
def _compute_attendance_kiosk_url(self):
for company in self:
company.attendance_kiosk_url = url_join(company.get_base_url(), '/hr_attendance/%s' % company.attendance_kiosk_key)
def write(self, vals):
search_domain = False # Overtime to generate
@@ -72,3 +83,16 @@ class ResCompany(models.Model):
self.env['hr.attendance'].search(search_domain)._update_overtime()
return res
def _regenerate_attendance_kiosk_key(self):
self.ensure_one()
self.write({
'attendance_kiosk_key': uuid.uuid4().hex
})
def _action_open_kiosk_mode(self):
return {
'type': 'ir.actions.act_url',
'target': 'self',
'url': '/hr_attendance/kiosk_mode_menu'
}
@@ -7,9 +7,6 @@ from odoo import api, fields, models
class ResConfigSettings(models.TransientModel):
_inherit = 'res.config.settings'
group_attendance_use_pin = fields.Boolean(
string='Employee PIN',
implied_group="hr_attendance.group_hr_attendance_use_pin")
hr_attendance_overtime = fields.Boolean(
string="Count Extra Hours", readonly=False)
overtime_start_date = fields.Date(string="Extra Hours Starting Date", readonly=False)
@@ -17,9 +14,13 @@ class ResConfigSettings(models.TransientModel):
string="Tolerance Time In Favor Of Company", readonly=False)
overtime_employee_threshold = fields.Integer(
string="Tolerance Time In Favor Of Employee", readonly=False)
hr_attendance_display_overtime = fields.Boolean(related='company_id.hr_attendance_display_overtime', readonly=False)
attendance_kiosk_mode = fields.Selection(related='company_id.attendance_kiosk_mode', readonly=False)
attendance_barcode_source = fields.Selection(related='company_id.attendance_barcode_source', readonly=False)
attendance_kiosk_delay = fields.Integer(related='company_id.attendance_kiosk_delay', readonly=False)
attendance_kiosk_url = fields.Char(related='company_id.attendance_kiosk_url')
attendance_kiosk_use_pin = fields.Boolean(related='company_id.attendance_kiosk_use_pin', readonly=False)
attendance_from_systray = fields.Boolean(related="company_id.attendance_from_systray", readonly=False)
@api.model
def get_values(self):
@@ -47,3 +48,7 @@ class ResConfigSettings(models.TransientModel):
]
if any(self[field] != company[field] for field in fields_to_check):
company.write({field: self[field] for field in fields_to_check})
def regenerate_kiosk_key(self):
if self.user_has_groups("hr_attendance.group_hr_attendance_manager"):
self.company_id._regenerate_attendance_kiosk_key()
+40 -3
View File
@@ -1,8 +1,7 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import models, fields
from odoo import models, fields, _
class User(models.Model):
_inherit = ['res.users']
@@ -13,6 +12,8 @@ class User(models.Model):
last_check_in = fields.Datetime(related='employee_id.last_attendance_id.check_in')
last_check_out = fields.Datetime(related='employee_id.last_attendance_id.check_out')
total_overtime = fields.Float(related='employee_id.total_overtime')
attendance_manager_id = fields.Many2one(related='employee_id.attendance_manager_id', readonly=False)
display_extra_hours = fields.Boolean(related='company_id.hr_attendance_display_overtime')
@property
def SELF_READABLE_FIELDS(self):
@@ -22,5 +23,41 @@ class User(models.Model):
'attendance_state',
'last_check_in',
'last_check_out',
'total_overtime'
'total_overtime',
'attendance_manager_id',
'display_extra_hours',
]
def _clean_attendance_officers(self):
attendance_officers = self.env['hr.employee'].search(
[('attendance_manager_id', 'in', self.ids)]).attendance_manager_id
officers_to_remove_ids = self - attendance_officers
if officers_to_remove_ids:
self.env.ref('hr_attendance.group_hr_attendance_officer').users = [(3, user.id) for user in
officers_to_remove_ids]
def action_open_last_month_attendances(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": _("Attendances This Month"),
"res_model": "hr.attendance",
"views": [[self.env.ref('hr_attendance.hr_attendance_employee_simple_tree_view').id, "tree"]],
"context": {
"create": 0
},
"domain": [('employee_id', '=', self.employee_id.id),
('check_in', ">=", fields.datetime.today().replace(day=1, hour=0, minute=0))]
}
def action_open_last_month_overtime(self):
self.ensure_one()
return {
"type": "ir.actions.act_window",
"name": _("Overtime"),
"res_model": "hr.attendance.overtime",
"views": [[False, "tree"]],
"context": {
"create": 0
},
"domain": [('employee_id', '=', self.employee_id.id)]
}
-4
View File
@@ -1,4 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from . import hr_attendance_report
@@ -1,73 +0,0 @@
# -*- coding: utf-8 -*-
# Part of Odoo. See LICENSE file for full copyright and licensing details.
from odoo import api, fields, models, tools
class HRAttendanceReport(models.Model):
_name = "hr.attendance.report"
_description = "Attendance Statistics"
_auto = False
department_id = fields.Many2one('hr.department', string="Department", readonly=True)
employee_id = fields.Many2one('hr.employee', string="Employee", readonly=True)
company_id = fields.Many2one('res.company', string="Company", readonly=True)
check_in = fields.Date("Check In", readonly=True)
worked_hours = fields.Float("Hours Worked", readonly=True)
overtime_hours = fields.Float("Extra Hours", readonly=True)
@api.model
def _select(self):
return """
SELECT
hra.id,
hr_employee.department_id,
hra.employee_id,
hr_employee.company_id,
hra.check_in,
hra.worked_hours,
coalesce(ot.duration, 0) as overtime_hours
"""
@api.model
def _from(self):
return """
FROM (
SELECT
id,
row_number() over (partition by employee_id, CAST(check_in AS DATE)) as ot_check,
employee_id,
CAST(check_in
at time zone 'utc'
at time zone
(SELECT calendar.tz FROM resource_calendar as calendar
INNER JOIN hr_employee as employee ON employee.id = hr_attendance.employee_id
WHERE calendar.id = employee.resource_calendar_id)
as DATE) as check_in,
worked_hours
FROM
hr_attendance
) as hra
"""
def _join(self):
return """
LEFT JOIN hr_employee ON hr_employee.id = hra.employee_id
LEFT JOIN hr_attendance_overtime ot
ON hra.ot_check = 1
AND ot.employee_id = hra.employee_id
AND ot.date = hra.check_in
AND ot.adjustment = FALSE
"""
def init(self):
tools.drop_view_if_exists(self.env.cr, self._table)
self.env.cr.execute("""
CREATE OR REPLACE VIEW %s AS (
%s
%s
%s
)
""" % (self._table, self._select(), self._from(), self._join())
)
@@ -1,71 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_attendance_report_view_search" model="ir.ui.view">
<field name="name">hr.attendance.report.view.search</field>
<field name="model">hr.attendance.report</field>
<field name="arch" type="xml">
<search string="HR Attendance Search">
<field name="employee_id"/>
<field name="department_id" operator="child_of"/>
<filter name="check_in" string="Check In" date="check_in" default_period="last_month"/>
<group expand="0" string="Group By">
<filter string="Employee" name="groupby_employee" context="{'group_by': 'employee_id'}"/>
<filter string="Check In" name="groupby_check_in" context="{'group_by': 'check_in'}"/>
</group>
</search>
</field>
</record>
<record id="hr_attendance_report_view_pivot" model="ir.ui.view">
<field name="name">hr.attendance.report.view.pivot</field>
<field name="model">hr.attendance.report</field>
<field name="arch" type="xml">
<pivot string="Attendance" js_class="attendance_report_pivot">
<field name="employee_id" type="row"/>
<field name="check_in" type="col"/>
<field name="worked_hours" type="measure" widget="float_time"/>
<field name="overtime_hours" type="measure" widget="float_time"/>
</pivot>
</field>
</record>
<record id="hr_attendance_report_view_graph" model="ir.ui.view">
<field name="name">hr.attendance.report.view.graph</field>
<field name="model">hr.attendance.report</field>
<field name="arch" type="xml">
<graph string="Attendance Statistics" stacked="0" js_class="attendance_report_graph">
<field name="employee_id"/>
<field name="check_in"/>
<field name="overtime_hours" type="measure" />
<field name="worked_hours" type="measure" />
</graph>
</field>
</record>
<record id="hr_attendance_report_action" model="ir.actions.act_window">
<field name="name">Attendance Analysis</field>
<field name="res_model">hr.attendance.report</field>
<field name="view_mode">graph,pivot</field>
<field name="search_view_id" ref="hr_attendance_report_view_search"/>
<field name="context">{'group_by': ['check_in:day', 'employee_id'], 'search_default_check_in': '1'}</field>
</record>
<record id="hr_attendance_report_action_filtered" model="ir.actions.act_window">
<field name="name">Attendance Analysis</field>
<field name="res_model">hr.attendance.report</field>
<field name="view_mode">graph,pivot</field>
<field name="search_view_id" ref="hr_attendance_report_view_search"/>
<field name="context">{
'group_by': ['check_in:day', 'employee_id'],
'search_default_department_id': [active_id]}
</field>
</record>
<menuitem
id="menu_hr_attendance_report"
name="Reporting"
sequence="30"
parent="hr_attendance.menu_hr_attendance_root"
action="hr_attendance_report_action"
groups="hr_attendance.group_hr_attendance_user"/>
</odoo>
@@ -4,48 +4,36 @@
<field name="sequence">14</field>
</record>
<record id="group_hr_attendance_kiosk" model="res.groups">
<field name="name">User: Only kiosk mode</field>
<field name="category_id" ref="base.module_category_human_resources_employees"/>
<field name="comment">The user will be able to open the kiosk mode and validate the employee PIN.</field>
<field name="implied_ids" eval="[(6, 0, [ref('base.group_user')])]"/>
<record id="group_hr_attendance_own_reader" model="res.groups">
<field name="name">User: Read his own attendances</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="comment">The user will have access to his own attendances on his user / employee profile</field>
</record>
<record id="hr.group_hr_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_kiosk'))]"/>
<record id="base.group_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_own_reader'))]"/>
</record>
<record id="group_hr_attendance" model="res.groups">
<field name="name">User</field>
<field name="category_id" ref="base.module_category_human_resources_attendances"/>
<field name="comment">The user will gain access to the human resources attendance menu, enabling him to manage his own attendance.</field>
</record>
<record id="group_hr_attendance_user" model="res.groups">
<field name="name">Officer: Manage all attendances</field>
<field name="category_id" ref="base.module_category_human_resources_attendances"/>
<field name="implied_ids" eval="[(4, ref('hr.group_hr_user')), (4, ref('hr_attendance.group_hr_attendance'))]"/>
<record id="group_hr_attendance_officer" model="res.groups">
<field name="name">Officer: Manage attendances</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="comment">The user will have access to the attendance records and reporting of employees where he's set as an attendance manager</field>
</record>
<record id="group_hr_attendance_manager" model="res.groups">
<field name="name">Administrator</field>
<field name="category_id" ref="base.module_category_human_resources_attendances"/>
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_user'))]"/>
<field name="users" eval="[(4, ref('base.user_root')), (4, ref('base.user_admin'))]"/>
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_officer'))]"/>
</record>
<record id="base.default_user" model="res.users">
<field name="groups_id" eval="[(4,ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<record id="group_hr_attendance_use_pin" model="res.groups">
<field name="name">Enable PIN use</field>
<field name="category_id" ref="base.module_category_hidden"/>
<field name="comment">The user will have to enter his PIN to check in and out manually at the company screen.</field>
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<data noupdate="1">
<!-- Attendances -->
<record id="hr_attendance_rule_employee_company" model="ir.rule">
<field name="name">Employee multi company rule</field>
<field name="model_id" ref="model_hr_attendance"/>
@@ -53,33 +41,43 @@
<field name="domain_force">['|',('employee_id.company_id','=',False),('employee_id.company_id', 'in', company_ids)]</field>
</record>
<record id="hr_attendance_rule_attendance_manager" model="ir.rule">
<field name="name">attendance officer: full access</field>
<record id="hr_attendance_rule_attendance_admin" model="ir.rule">
<field name="name">Attendance Administrator: Full access</field>
<field name="model_id" ref="model_hr_attendance"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance_user'))]"/>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<record id="hr_attendance_rule_attendance_manual" model="ir.rule">
<field name="name">Manual Attendance: own attendances</field>
<record id="hr_attendance_rule_attendance_manager_restrict" model="ir.rule">
<field name="name">Attendance Officer: Restrict Attendances to managed employees</field>
<field name="model_id" ref="model_hr_attendance"/>
<field name="domain_force">
[
'|',
'&amp;',
('employee_id.attendance_manager_id', '=', user.id),
('employee_id.user_id', '=', user.id),
'&amp;',
('employee_id.user_id', '!=', user.id),
('employee_id.attendance_manager_id', '=', user.id)
]
</field>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_officer'))]"/>
<field name="perm_create" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_unlink" eval="1"/>
<field name="perm_read" eval="1"/>
</record>
<record id="hr_attendance_rule_attendance_simple_user" model="ir.rule">
<field name="name">Attendance base user: Read his own attendances in other apps</field>
<field name="model_id" ref="model_hr_attendance"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_create" eval="1"/>
<field name="perm_unlink" eval="0"/>
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance'))]"/>
</record>
<record id="hr_attendance_rule_attendance_employee" model="ir.rule">
<field name="name">user: read own attendance only</field>
<field name="model_id" ref="model_hr_attendance"/>
<field name="domain_force">[('employee_id.user_id','=',user.id)]</field>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="0"/>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_own_reader'))]"/>
<field name="perm_create" eval="0"/>
<field name="perm_write" eval="0"/>
<field name="perm_unlink" eval="0"/>
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
<field name="perm_read" eval="1"/>
</record>
<!-- Overtime -->
@@ -90,29 +88,44 @@
<field name="domain_force">['|',('employee_id.company_id','=',False),('employee_id.company_id', 'in', company_ids)]</field>
</record>
<record id="hr_attendance_rule_attendance_overtime_manager" model="ir.rule">
<field name="name">attendance officer: full access</field>
<record id="hr_attendance_rule_attendance_officer_overtime_restrict" model="ir.rule">
<field name="name">Attendance Officer: Restrict Overtime to managed employees</field>
<field name="model_id" ref="model_hr_attendance_overtime"/>
<field name="domain_force">
[
'|',
'&amp;',
('employee_id.attendance_manager_id', '=', user.id),
('employee_id.user_id', '=', user.id),
'&amp;',
('employee_id.user_id', '!=', user.id),
('employee_id.attendance_manager_id', '=', user.id)
]
</field>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_officer'))]"/>
<field name="perm_create" eval="1"/>
<field name="perm_write" eval="1"/>
<field name="perm_unlink" eval="1"/>
<field name="perm_read" eval="1"/>
</record>
<record id="hr_attendance_rule_attendance_overtime_admin" model="ir.rule">
<field name="name">Attendance Admin: full access</field>
<field name="model_id" ref="model_hr_attendance_overtime"/>
<field name="domain_force">[(1,'=',1)]</field>
<field name="groups" eval="[(4,ref('hr_attendance.group_hr_attendance_user'))]"/>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<record id="hr_attendance_rule_attendance_overtime_employee" model="ir.rule">
<field name="name">user: read and modify own attendance only</field>
<record id="hr_attendance_rule_attendance_overtime_simple_user" model="ir.rule">
<field name="name">Attendance base user: Read his own overtime</field>
<field name="model_id" ref="model_hr_attendance_overtime"/>
<field name="domain_force">[('employee_id.user_id', '=', user.id)]</field>
<field name="perm_read" eval="1"/>
<field name="perm_write" eval="0"/>
<field name="groups" eval="[(4, ref('hr_attendance.group_hr_attendance_own_reader'))]"/>
<field name="perm_create" eval="0"/>
<field name="perm_write" eval="0"/>
<field name="perm_unlink" eval="0"/>
<field name="groups" eval="[(4,ref('base.group_user'))]"/>
</record>
<!-- Report -->
<record id="hr_attendance_report_rule_multi_company" model="ir.rule">
<field name="name">Attendance report multi company rule</field>
<field name="model_id" ref="model_hr_attendance_report"/>
<field name="domain_force">[('company_id', 'in', company_ids)]</field>
<field name="perm_read" eval="1"/>
</record>
</data>
</odoo>
@@ -1,7 +1,7 @@
id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink
access_hr_attendance_officer,hr.attendance.user,model_hr_attendance,group_hr_attendance_user,1,1,1,1
access_hr_attendance_user,hr.attendance.system.user,model_hr_attendance,group_hr_attendance,1,1,1,0
access_hr_attendance_system_user,hr.attendance.system.user,model_hr_attendance,base.group_user,1,0,0,0
access_hr_attendance_overtime_user,hr.attendance.overtime.user,model_hr_attendance_overtime,hr_attendance.group_hr_attendance_user,1,1,1,1
access_hr_attendance_overtime_system_user,hr.attendance.overtime.system.user,model_hr_attendance_overtime,base.group_user,1,0,0,0
access_hr_attendance_report_user,hr.attendance.report.user,model_hr_attendance_report,hr_attendance.group_hr_attendance_user,1,0,0,0
access_hr_attendance_admin,hr.attendance.admin,model_hr_attendance,group_hr_attendance_manager,1,1,1,1
access_hr_attendance_admin_overtime,hr.attendance.admin.overtime,model_hr_attendance_overtime,group_hr_attendance_manager,1,1,1,1
access_hr_attendance_officer,hr.attendance.officer,model_hr_attendance,group_hr_attendance_officer,1,1,1,1
access_hr_attendance_officer_overtime,hr.attendance.officer.overtime,model_hr_attendance_overtime,group_hr_attendance_officer,1,1,1,1
access_hr_attendance_user,hr.attendance.user,model_hr_attendance,group_hr_attendance_own_reader,1,0,0,0
access_hr_attendance_overtime_user,hr.attendance.overtime.user,model_hr_attendance_overtime,group_hr_attendance_own_reader,1,0,0,0
1 id name model_id:id group_id:id perm_read perm_write perm_create perm_unlink
2 access_hr_attendance_officer access_hr_attendance_admin hr.attendance.user hr.attendance.admin model_hr_attendance group_hr_attendance_user group_hr_attendance_manager 1 1 1 1
3 access_hr_attendance_user access_hr_attendance_admin_overtime hr.attendance.system.user hr.attendance.admin.overtime model_hr_attendance model_hr_attendance_overtime group_hr_attendance group_hr_attendance_manager 1 1 1 0 1
4 access_hr_attendance_system_user access_hr_attendance_officer hr.attendance.system.user hr.attendance.officer model_hr_attendance base.group_user group_hr_attendance_officer 1 0 1 0 1 0 1
5 access_hr_attendance_overtime_user access_hr_attendance_officer_overtime hr.attendance.overtime.user hr.attendance.officer.overtime model_hr_attendance_overtime hr_attendance.group_hr_attendance_user group_hr_attendance_officer 1 1 1 1
6 access_hr_attendance_overtime_system_user access_hr_attendance_user hr.attendance.overtime.system.user hr.attendance.user model_hr_attendance_overtime model_hr_attendance base.group_user group_hr_attendance_own_reader 1 0 0 0
7 access_hr_attendance_report_user access_hr_attendance_overtime_user hr.attendance.report.user hr.attendance.overtime.user model_hr_attendance_report model_hr_attendance_overtime hr_attendance.group_hr_attendance_user group_hr_attendance_own_reader 1 0 0 0
@@ -0,0 +1,35 @@
<svg width="1920" height="1080" viewBox="0 0 1920 1080" xmlns="http://www.w3.org/2000/svg">
<path d="M3.51001 1080H76.35L1153.55 0H3.51001V1080Z" fill="url(#o_app_switcher_gradient_01)"/>
<path d="M76.35 1080H842.98L1920 0.18V0H1153.55L76.35 1080Z" fill="url(#o_app_switcher_gradient_02)"/>
<path d="M1920 0.180176L842.98 1080H1063.11L1920 220.88V0.180176Z" fill="url(#o_app_switcher_gradient_03)"/>
<path d="M1920 1080V220.88L1063.11 1080H1920Z" fill="url(#o_app_switcher_gradient_04)"/>
<rect width="1920" height="1080" fill="url(#o_app_switcher_gradient_05)" fill-opacity="0.25"/>
<rect width="1920" height="1080" fill="#E9E6F9" fill-opacity="0.25"/>
<defs>
<linearGradient id="o_app_switcher_gradient_01" x1="-222.43" y1="727.19" x2="904.26" y2="-76.67" gradientUnits="userSpaceOnUse">
<stop offset="0.1" stop-color="white"/>
<stop offset="0.36" stop-color="#FEFEFE"/>
<stop offset="0.68" stop-color="#EAE7F9"/>
<stop offset="1" stop-color="#E4E9F7"/>
</linearGradient>
<linearGradient id="o_app_switcher_gradient_02" x1="407.23" y1="1021.82" x2="1848.47" y2="-153.08" gradientUnits="userSpaceOnUse">
<stop offset="0.32" stop-color="#FEFEFE"/>
<stop offset="0.66" stop-color="#EAE7F9"/>
<stop offset="1" stop-color="#E5E2F6"/>
</linearGradient>
<linearGradient id="o_app_switcher_gradient_03" x1="1142.33" y1="846.57" x2="1951.83" y2="136.16" gradientUnits="userSpaceOnUse">
<stop offset="0.15" stop-color="white"/>
<stop offset="0.51" stop-color="#F7F0FD"/>
<stop offset="0.85" stop-color="#F0E7F9"/>
</linearGradient>
<linearGradient id="o_app_switcher_gradient_04" x1="1409.74" y1="1071" x2="2070.98" y2="526.01" gradientUnits="userSpaceOnUse">
<stop offset="0.45" stop-color="white"/>
<stop offset="0.88" stop-color="#F7F0FD"/>
<stop offset="1" stop-color="#ECE5F8"/>
</linearGradient>
<radialGradient id="o_app_switcher_gradient_05" cx="0" cy="0" r="1" gradientUnits="userSpaceOnUse" gradientTransform="translate(960 540) rotate(90) scale(540 960)">
<stop stop-color="#9996A9" stop-opacity="0.53"/>
<stop offset="1" stop-color="#7A768F"/>
</radialGradient>
</defs>
</svg>

After

Width:  |  Height:  |  Size: 2.0 KiB

@@ -1,224 +0,0 @@
/** @odoo-module **/
import { _t } from "@web/core/l10n/translation";
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
import { Component, onWillStart, onWillUnmount, useState } from "@odoo/owl";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
const { Duration } = luxon;
export class GreetingMessage extends Component {
setup() {
const { action } = this.props;
this.actionService = useService("action");
this.notificationService = useService("notification");
this.orm = useService("orm");
this.user = useService("user");
this.kioskDelay = action.kiosk_delay;
this.state = useState({
message: "",
randomMessage: "",
showWarningMessage: false,
});
onWillStart(this.onWillStart);
onWillUnmount(() => this.return_to_main_menu && clearTimeout(this.return_to_main_menu));
let activeBarcode = true;
if (!this.props.action.attendance) {
activeBarcode = false;
return;
}
this.next_action =
action.next_action || "hr_attendance.hr_attendance_action_my_attendances";
// no listening to barcode scans if we aren't coming from the kiosk mode (and thus not going back to it with next_action)
if (
this.next_action !== "hr_attendance.hr_attendance_action_kiosk_mode" &&
this.next_action.tag !== "hr_attendance_kiosk_mode"
) {
activeBarcode = false;
}
this.attendance = action.attendance;
// We receive the check in/out times in UTC
// This widget only deals with display, which should be in browser's TimeZone
this.attendance.check_in =
this.attendance.check_in && deserializeDateTime(this.attendance.check_in);
this.attendance.check_out =
this.attendance.check_out && deserializeDateTime(this.attendance.check_out);
this.previous_attendance_change_date =
action.previous_attendance_change_date &&
deserializeDateTime(action.previous_attendance_change_date);
const formatDateTime = registry.category("formatters").get("datetime");
this.attendance.check_in_time =
this.attendance.check_in && formatDateTime(this.attendance.check_in);
this.attendance.check_out_time =
this.attendance.check_out && formatDateTime(this.attendance.check_out);
const formatFloatTime = registry.category("formatters").get("float_time");
// extra hours amount displayed in the greeting message template.
this.total_overtime_float = action.total_overtime; // Used for comparison in template
this.total_overtime = formatFloatTime(this.total_overtime_float);
this.today_overtime_float = action.overtime_today;
this.today_overtime = formatFloatTime(this.today_overtime_float);
if (action.hours_today) {
const duration = Duration.fromObject({ hours: action.hours_today })
.toFormat("hh-mm")
.split("-");
this.hours_today = _t("%(hours)s hours, %(minutes)s minutes", {
hours: duration[0],
minutes: duration[1],
});
}
this.employee_name = action.employee_name;
this.attendanceBarcode = action.barcode;
const barcode = useService("barcode");
this.lockScanner = false;
if (activeBarcode) {
useBus(barcode.bus, "barcode_scanned", this.onBarcodeScanned.bind(this));
}
}
async onBarcodeScanned(ev) {
if (this.lockScanner) {
return;
}
const { barcode } = ev.detail;
if (this.attendanceBarcode !== barcode) {
this.lockScanner = true;
if (this.return_to_main_menu) {
// in case of multiple scans in the greeting message view, delete the timer, a new one will be created.
clearTimeout(this.return_to_main_menu);
}
const result = await this.orm.call("hr.employee", "attendance_scan", [barcode]);
if (result.action) {
this.actionService.doAction(result.action);
} else if (result.warning) {
this.notificationService.add(result.warning, { type: "danger" });
this.setTimeoutNextAction();
this.lockScanner = false;
}
}
}
onClickDismiss() {
this.actionService.doAction(this.next_action, { clearBreadcrumbs: true });
}
async onWillStart() {
// if no correct action given (due to an erroneous back or refresh from the browser), we set the dismiss button to return
// to the (likely) appropriate menu, according to the user access rights
if (!this.attendance) {
const hasGroup = await this.user.hasGroup("hr_attendance.group_hr_attendance_user");
if (hasGroup) {
this.next_action = "hr_attendance.hr_attendance_action_kiosk_mode";
} else {
this.next_action = "hr_attendance.hr_attendance_action_my_attendances";
}
return;
}
this.setTimeoutNextAction();
this.attendance.check_out ? this.setFarewellMessage() : this.setWelcomeMessage();
}
setWelcomeMessage() {
const now = this.attendance.check_in;
if (now.hour < 5) {
this.state.message = _t("Good night");
} else if (now.hour < 12) {
if (now.hour < 8 && Math.random() < 0.3) {
if (Math.random() < 0.75) {
this.state.message = _t("The early bird catches the worm");
} else {
this.state.message = _t("First come, first served");
}
} else {
this.state.message = _t("Good morning");
}
} else if (now.hour < 17) {
this.state.message = _t("Good afternoon");
} else if (now.hour < 23) {
this.state.message = _t("Good evening");
} else {
this.state.message = _t("Good night");
}
if (this.previous_attendance_change_date) {
const last_check_out_date = this.previous_attendance_change_date;
if (now - last_check_out_date > 24 * 7 * 60 * 60 * 1000) {
this.state.randomMessage = _t("Glad to have you back, it's been a while!");
} else {
if (Math.random() < 0.02) {
this.state.randomMessage = _t(
"If a job is worth doing, it is worth doing well!"
);
}
}
}
}
setFarewellMessage() {
const now = this.attendance.check_out;
if (this.previous_attendance_change_date) {
const last_check_in_date = this.previous_attendance_change_date;
if (now - last_check_in_date > 1000 * 60 * 60 * 12) {
this.state.showWarningMessage = true;
if (this.return_to_main_menu) {
clearTimeout(this.return_to_main_menu);
}
this.lockScanner = true;
} else if (now - last_check_in_date > 1000 * 60 * 60 * 8) {
this.state.randomMessage = _t("Another good day's work! See you soon!");
}
}
if (now.hour < 12) {
this.state.message = _t("Have a good day!");
} else if (now.hour < 14) {
this.state.message = _t("Have a nice lunch!");
if (Math.random() < 0.05) {
this.state.randomMessage = _t(
"Eat breakfast as a king, lunch as a merchant and supper as a beggar"
);
} else if (Math.random() < 0.06) {
this.state.randomMessage = _t("An apple a day keeps the doctor away");
}
} else if (now.hour < 17) {
this.state.message = _t("Have a good afternoon");
} else {
if (now.hour < 18 && Math.random() < 0.2) {
this.state.message = _t(
"Early to bed and early to rise, makes a man healthy, wealthy and wise"
);
} else {
this.state.message = _t("Have a good evening");
}
}
}
setTimeoutNextAction() {
if (this.kioskDelay > 0) {
this.return_to_main_menu = setTimeout(() => {
this.actionService.doAction(this.next_action, { clearBreadcrumbs: true });
}, this.kioskDelay);
}
}
}
GreetingMessage.template = "hr_attendance.GreetingMessage";
GreetingMessage.components = { CardLayout };
registry.category("actions").add("hr_attendance_greeting_message", GreetingMessage);
@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.GreetingMessage">
<CardLayout>
<t t-if="attendance">
<t t-call="hr_attendance.UserBadge">
<t t-set="userId" t-value="attendance.employee_id[0]"/>
<t t-set="userName" t-value="employee_name"/>
</t>
<div t-if="attendance.check_out" class="flex-grow-1">
<h1 class="mt-5">Goodbye <t t-esc="employee_name"/>!</h1>
<h2 class="o_hr_attendance_message_message mt4 mb24" t-esc="state.message"/>
<div class="alert alert-info fs-2 mx-3" role="status">
Checked out at <b><t t-esc="attendance.check_out_time"/></b>
<br/><b><t t-esc="hours_today"/></b>
</div>
<div t-att-class="'alert ' + (today_overtime_float &gt;= 0 ? 'alert-success' : 'alert-danger') + ' h3 mx-3'" role="status">
Extra hours today:
<span t-esc="today_overtime"/>
</div>
<t t-if="total_overtime_float &gt; 0">
Total extra hours: <span t-esc="total_overtime"/>
</t>
<h3 class="o_hr_attendance_random_message fst-italic mb24" t-esc="state.randomMessage"/>
<div t-if="showWarningMessage" class="o_hr_attendance_warning_message mt24 alert alert-warning" role="alert">
<b>Warning! Last check in was over 12 hours ago.</b><br/>If this isn't right, please contact Human Resource staff
</div>
</div>
<div t-else="" class="flex-grow-1">
<h1 class="mt-5 mb0">Welcome <t t-esc="employee_name"/>!</h1>
<h2 class="o_hr_attendance_message_message mt4 mb24" t-esc="state.message"/>
<div class="alert alert-info fs-2 mx-3" role="status">
Checked in at <b><t t-esc="attendance.check_in_time"/></b>
</div>
<h3 class="o_hr_attendance_random_message mb24" t-esc="state.randomMessage"/>
<div class="o_hr_attendance_warning_message mt24 alert alert-warning" style="display:none" role="alert"/>
</div>
<div class="flex-grow-1">
<button class="o_hr_attendance_button_dismiss align-self-center btn btn-primary btn-lg px-5 py-3" t-on-click="() => this.onClickDismiss()">
<span class="fs-2" t-if="attendance.check_out">Goodbye</span>
<span class="fs-2" t-else="">OK</span>
</button>
</div>
</t>
<t t-else="">
<div class="flex-grow-1">
<div class="alert alert-warning mt-5 mx-3" role="alert">
<h4 class="alert-heading">Invalid request</h4>
<p>Please return to the main menu.</p>
</div>
</div>
<div class="flex-grow-1">
<button class="o_hr_attendance_button_dismiss btn btn-primary btn-lg fs-2 px-5 py-3" t-on-click="() => this.onClickDismiss()">
<i class="oi oi-chevron-left me-2"/>
<span class="fs-2">Go back</span>
</button>
</div>
</t>
</CardLayout>
</t>
</templates>
@@ -1,109 +0,0 @@
/** @odoo-module **/
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
import { CheckInOut } from "@hr_attendance/components/check_in_out/check_in_out";
import { Component, onWillStart, useState } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class KioskConfirm extends Component {
setup() {
this.actionService = useService("action");
this.notification = useService("notification");
this.orm = useService("orm");
this.user = useService("user");
this.padButtons = [
...Array.from({ length: 9 }, (_, i) => [i + 1]), // [[1], ..., [9]]
["C", "btn-warning"],
[0],
["OK", "btn-primary"],
];
this.state = useState({
codePin: "",
});
this.lockPad = false;
this.nextAction = "hr_attendance.hr_attendance_action_kiosk_mode";
const { employee_id, employee_name, employee_state, employee_hours_today } =
this.props.action;
this.employeeId = employee_id;
this.employeeName = employee_name;
this.checkedIn = employee_state === "checked_in";
this.employeeHoursToday = registry.category("formatters").get("float_time")(
employee_hours_today
);
onWillStart(this.onWillStart);
}
async onWillStart() {
this.use_pin = await this.user.hasGroup("hr_attendance.group_hr_attendance_use_pin");
if (this.use_pin) {
browser.addEventListener('keydown', async (ev) => {
const allowedKeys = [...Array(10).keys()].reduce((acc, value) => { // { from '0': '0' ... to '9': '9' }
acc[value] = value;
return acc;
}, {
'Delete': 'C',
'Enter': 'OK',
'Backspace': null,
});
const key = ev.key;
if (!Object.keys(allowedKeys).includes(key)) {
return;
}
ev.preventDefault();
ev.stopPropagation();
if (allowedKeys[key]) {
await this.onClickPadButton(allowedKeys[key]);
}
else {
this.state.codePin = this.state.codePin.substring(0, this.state.codePin.length - 1);
}
});
}
}
onClickBack() {
this.actionService.doAction(this.nextAction, { clearBreadcrumbs: true });
}
async onClickPadButton(value) {
if (this.lockPad) {
return;
}
if (value === "C") {
this.state.codePin = "";
} else if (value === "OK") {
this.lockPad = true;
const result = await this.orm.call("hr.employee", "attendance_manual", [
[this.employeeId],
this.nextAction,
this.state.codePin,
]);
if (result.action) {
this.actionService.doAction(result.action);
} else if (result.warning) {
this.notification.add(result.warning, { type: "danger" });
this.state.codePin = "";
browser.setTimeout(() => {
this.lockPad = false;
}, 500);
}
} else {
this.state.codePin += value;
}
}
}
KioskConfirm.template = "hr_attendance.KioskConfirm";
KioskConfirm.components = { CardLayout, CheckInOut };
registry.category("actions").add("hr_attendance_kiosk_confirm", KioskConfirm);
@@ -1,63 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.UserBadge">
<div class="o_hr_attendance_user_badge o_home_menu_background d-flex align-items-end justify-content-center flex-grow-1 pt-5 pt-md-4 bg-odoo">
<img class="img rounded-circle mb-n5" t-attf-src="/web/image?model=hr.employee.public&amp;field=avatar_128&amp;id=#{userId}" t-att-title="userName" height="80" t-att-alt="userName"/>
</div>
</t>
<t t-name="hr_attendance.KioskConfirm">
<CardLayout>
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
<i class="oi oi-chevron-left me-2"/> Go back
</button>
<t t-if="employeeId" t-call="hr_attendance.UserBadge">
<t t-set="userId" t-value="employeeId"/>
<t t-set="userName" t-value="employeeName"/>
</t>
<button t-on-click="() => this.onClickBack()" class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
<i class="oi fa-2x fa-fw oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
</button>
<div t-if="employeeId" class="flex-grow-1">
<h1 class="mt-5 mb8" t-esc="employeeName"/>
<h3 class="mt8 mb24"><t t-if="!checkedIn">Welcome!</t><t t-else="">Want to check out?</t></h3>
<h4 class="mt0 mb0 text-muted" t-if="checkedIn">Today's work hours: <span t-esc="employeeHoursToday"/></h4>
<CheckInOut t-if="!use_pin" checkedIn="checkedIn" employeeId="employeeId" nextAction="nextAction"/>
<t t-else="">
<h3 class="mt-4 mb0 text-muted">Please enter your PIN to <b t-if="checkedIn">check out</b><b t-else="">check in</b></h3>
<div class="row">
<div class="col-md-8 offset-md-2 o_hr_attendance_pin_pad">
<div class="row g-0" >
<div class="col-12 mb8 mt8">
<input t-att-value="state.codePin" class="o_hr_attendance_PINbox border-0 bg-white fs-1 text-center" type="password" disabled="true"/>
</div>
</div>
<div class="row g-0">
<t t-foreach="padButtons" t-as="btn" t-key="btn[0]">
<div class="col-4 p-1">
<a href="#" t-on-click="() => this.onClickPadButton(btn[0])" t-attf-class="o_hr_attendance_PINbox_button btn {{btn[1]? btn[1] : 'btn-secondary border'}} btn-block btn-lg d-flex align-items-center justify-content-center">
<t t-esc="btn[0]"/>
</a>
</div>
</t>
</div>
</div>
</div>
</t>
</div>
<div t-else="" class="alert alert-danger mx-3" role="alert">
<h4 class="alert-heading">Error: could not find corresponding employee.</h4>
<p>Please return to the main menu.</p>
</div>
<a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
</CardLayout>
</t>
</templates>
@@ -1,93 +0,0 @@
/** @odoo-module **/
import { BarcodeScanner } from "@barcodes/components/barcode_scanner";
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
import { Component, onWillStart } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
import { registry } from "@web/core/registry";
import { useBus, useService } from "@web/core/utils/hooks";
import { url } from "@web/core/utils/urls";
export class KioskMode extends Component {
setup() {
this.actionService = useService("action");
this.companyService = useService("company");
this.notification = useService("notification");
this.rpc = useService("rpc");
this.orm = useService("orm");
const barcode = useService("barcode");
useBus(barcode.bus, "barcode_scanned", (ev) => this.onBarcodeScanned(ev.detail.barcode));
this.lockScanner = false;
onWillStart(this.onWillStart);
// Make a RPC call every day to keep the session alive
browser.setInterval(() => this.callServer(), 60 * 60 * 1000 * 24);
}
async onWillStart() {
const companyId = this.companyService.currentCompany.id;
const currentCompany = (
await this.orm.searchRead(
"res.company",
[["id", "=", companyId]],
["name", "attendance_kiosk_mode", "attendance_barcode_source"][
("employee_id", "date_from", "date_to", "number_of_days")
]
)
)[0];
this.companyName = currentCompany.name;
this.companyImageUrl = url("/web/image", {
model: "res.company",
id: companyId,
field: "logo",
});
this.kioskMode = currentCompany.attendance_kiosk_mode;
this.barcodeSource = currentCompany.attendance_barcode_source;
}
onClickAttendanceEmployees() {
this.actionService.doAction("hr_attendance.hr_employee_attendance_action_kanban", {
additionalContext: { no_group_by: true },
});
}
async onBarcodeScanned(barcode) {
if (this.lockScanner) {
return;
}
this.lockScanner = true;
const result = await this.orm.call("hr.employee", "attendance_scan", [barcode]);
if (result.action) {
this.actionService.doAction(result.action);
} else if (result.warning) {
this.notification.add(result.warning, { type: "danger" });
this.lockScanner = false;
}
}
callServer() {
// Make a call to the database to avoid the auto close of the session
this.rpc("/hr_attendance/kiosk_keepalive", {});
}
}
class KioskModeBarcodeScanner extends BarcodeScanner {
get facingMode() {
if (this.props.barcodeSource == "front") {
return "user";
}
return super.facingMode;
}
}
KioskModeBarcodeScanner.props = {
...BarcodeScanner.props,
barcodeSource: String,
};
KioskMode.template = "hr_attendance.KioskMode";
KioskMode.components = { CardLayout, BarcodeScanner: KioskModeBarcodeScanner };
registry.category("actions").add("hr_attendance_kiosk_mode", KioskMode);
@@ -1,31 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.KioskMode">
<CardLayout kioskModeClasses="'o_barcode_main pt-5'">
<h2 class="mb-2"><small>Welcome to</small> <t t-esc="companyName"/></h2>
<img t-att-src="companyImageUrl" alt="Company Logo" class="o_hr_attendance_kiosk_company_image align-self-center img img-fluid mb-3" width="200"/>
<div class="o_hr_attendance_kiosk_welcome_row d-flex flex-column pb-5">
<div class="col-md-5 mt-5 mb-5 mb-md-0 align-self-center" t-if="kioskMode != 'manual'">
<BarcodeScanner t-if="barcodeSource !== 'scanner'" onBarcodeScanned="(ev) => this.onBarcodeScanned(ev)" barcodeSource="barcodeSource"/>
<div t-else="" class="o_barcode_mobile_container">
<img src="/barcodes/static/img/barcode.png" alt="Barcode"/>
<span class="o_barcode_laser"/>
</div>
<h6 class="mt-2 text-muted">Scan your badge</h6>
</div>
<div class="mt-5 align-self-end" t-if="kioskMode == 'barcode_manual'">
<button class="o_hr_attendance_button_employees btn btn-link" t-on-click="() => this.onClickAttendanceEmployees()">
Identify Manually
</button>
</div>
<div class="mt-5 align-self-center" t-if="kioskMode == 'manual'">
<button class="o_hr_attendance_button_employees btn btn-primary px-5 py-3 mt-4 mb-2" t-on-click="() => this.onClickAttendanceEmployees()">
<span class="fs-2">Identify Manually</span>
</button>
</div>
</div>
</CardLayout>
</t>
</templates>
@@ -1,40 +0,0 @@
/** @odoo-module **/
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
import { CheckInOut } from "@hr_attendance/components/check_in_out/check_in_out";
import { Component, onWillStart } from "@odoo/owl";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
export class MyAttendances extends Component {
setup() {
this.orm = useService("orm");
this.user = useService("user");
this.nextAction = "hr_attendance.hr_attendance_action_my_attendances";
this.employee = false;
this.checkedIn = false;
onWillStart(this.onWillStart);
}
async onWillStart() {
const result = await this.orm.searchRead(
"hr.employee.public",
[["user_id", "=", this.user.userId]],
["attendance_state", "name", "hours_today"]
);
this.employee = result[0];
if (this.employee) {
this.hoursToday = registry.category("formatters").get("float_time")(
this.employee.hours_today
);
this.checkedIn = this.employee.attendance_state === "checked_in";
}
}
}
MyAttendances.template = "hr_attendance.MyAttendances";
MyAttendances.components = { CardLayout, CheckInOut };
registry.category("actions").add("hr_attendance_my_attendances", MyAttendances);
@@ -1,26 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.MyAttendances">
<CardLayout>
<t t-if="employee">
<t t-call="hr_attendance.UserBadge">
<t t-set="userId" t-value="employee.id"/>
<t t-set="userName" t-value="employee.name"/>
</t>
<div class="flex-grow-1">
<h1 class="mt-5" t-esc="employee.name"/>
<h3><t t-if="!checkedIn">Welcome!</t><t t-else="">Want to check out?</t></h3>
<h4 class="mt0 mb0 text-muted" t-if="checkedIn">Today's work hours: <span t-esc="hoursToday"/></h4>
</div>
<CheckInOut checkedIn="checkedIn" employeeId="employee.id" nextAction="nextAction"/>
</t>
<div t-else="" class="alert alert-warning">
<b>Warning</b>: Your user should be linked to an employee to use attendance.<br/> Please contact your administrator.
</div>
</CardLayout>
</t>
</templates>
@@ -0,0 +1,79 @@
/* @odoo-module */
import { Component, useState, onWillStart } from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
import { deserializeDateTime } from "@web/core/l10n/dates";
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { useDebounced } from "@web/core/utils/timing";
const { DateTime } = luxon;
export class ActivityMenu extends Component {
static components = {Dropdown, DropdownItem};
static props = [];
static template = "hr_attendance.attendance_menu";
setup() {
this.rpc = useService("rpc");
this.ui = useState(useService("ui"));
this.userService = useService("user");
this.employee = false;
this.state = useState({
checkedIn: false,
isDisplayed: false
});
this.date_formatter = registry.category("formatters").get("float_time")
this.onClickSignInOut = useDebounced(this.signInOut, 200, true);
onWillStart(this.onWillStart);
}
async onWillStart() {
await this.searchReadEmployee();
}
async searchReadEmployee(){
const result = await this.rpc("/hr_attendance/attendance_user_data");
this.employee = result;
if (this.employee.id) {
this.hoursToday = this.date_formatter(
this.employee.hours_today
);
this.hoursPreviouslyToday = this.date_formatter(
this.employee.hours_previously_today
);
this.lastAttendanceWorkedHours = this.date_formatter(
this.employee.last_attendance_worked_hours
);
this.lastCheckIn = deserializeDateTime(this.employee.last_check_in).toLocaleString(DateTime.TIME_SIMPLE);
this.state.checkedIn = this.employee.attendance_state === "checked_in";
this.isFirstAttendance = this.employee.hours_previously_today === 0;
this.state.isDisplayed = this.employee.display_systray
}
}
async signInOut() {
navigator.geolocation.getCurrentPosition(
async ({coords: {latitude, longitude}}) => {
await this.rpc("/hr_attendance/systray_check_in_out", {
latitude,
longitude
})
await this.searchReadEmployee()
},
async err => {
await this.rpc("/hr_attendance/systray_check_in_out")
await this.searchReadEmployee()
}
)
}
}
export const systrayAttendance = {
Component: ActivityMenu,
};
registry
.category("systray")
.add("hr_attendance.attendance_menu", systrayAttendance, { sequence: 100 });
@@ -0,0 +1,32 @@
.att_container {
display: flex;
justify-content: normal;
align-items: flex-end;
min-width: 150px;
padding-left: 13px;
}
.o_hour_att {
margin-right: 10px;
}
.hour_label {
display: flex;
flex-direction: column;
.span{
line-height: 1;
}
}
.o_att_menu_container{
display: flex;
align-items: center;
flex-direction: column;
flex-wrap: wrap;
align-content: center;
}
.o_hr_attendance_systray_check_in_btn{
width: 150px;
height: 40px;
}
@@ -0,0 +1,61 @@
<?xml version="1.0" encoding="UTF-8" ?>
<templates xml:space="preserve">
<t t-name="hr_attendance.attendance_menu" owl="1">
<t t-if="this.state.isDisplayed">
<Dropdown position="'bottom-end'" beforeOpen.bind="searchReadEmployee" menuClass="`p-0 ${ ui.isSmall ? 'start-0 w-100 mh-100 bottom-0 d-flex flex-column' : ''}`">
<div class="d-flex flex-column flex-grow-1">
<t t-set-slot="toggler">
<i t-attf-class="fa fa-circle text-{{ this.state.checkedIn ? 'success' : 'warning' }}" role="img" aria-label="Attendance"></i>
</t>
<t t-set-slot="default">
<div class="o_att_menu_container m-1">
<button t-on-click="() => this.onClickSignInOut()" t-attf-class="o_hr_attendance_systray_check_in_btn btn btn-{{ this.state.checkedIn ? 'warning' : 'success' }} align-self-center">
<span class="align-middle text-white" t-if="!this.state.checkedIn">Check IN</span>
<i t-attf-class="fa fa-sign-{{ this.state.checkedIn ? 'out' : 'in' }} align-middle"/>
<span class="align-middle" t-if="this.state.checkedIn">Check OUT</span>
</button>
<t t-if="this.state.checkedIn">
<div class="att_container">
<div class="o_hour_att">
<h2 t-esc="this.lastAttendanceWorkedHours"/>
</div>
<div class="hour_label">
<span>Since</span>
<span><t t-esc="this.lastCheckIn"/></span>
</div>
</div>
<t t-if="!this.isFirstAttendance">
<div class="att_container">
<div class="o_hour_att">
<h2 t-esc="this.hoursPreviouslyToday"/>
</div>
<div class="hour_label">
<span>Previously</span>
<span>Today</span>
</div>
</div>
</t>
</t>
<t t-if="!this.isFirstAttendance or !this.state.checkedIn">
<t t-if="this.state.checkedIn">
<div class="att_container" style="border-top: 2px solid #dee2e6;"/>
</t>
<div class="att_container">
<div class="o_hour_att">
<h2 t-esc="this.hoursToday"/>
</div>
<div class="hour_label">
<span>Hours</span>
<span>Today</span>
</div>
</div>
</t>
</div>
</t>
</div>
</Dropdown>
</t>
</t>
</templates>
@@ -2,7 +2,7 @@
<templates xml:space="preserve">
<t t-name="hr_attendance.CardLayout">
<div class="o_hr_attendance_kiosk_mode_container o_home_menu_background d-flex flex-column align-items-center justify-content-center h-100 text-center position-relative">
<div class="o_hr_attendance_kiosk_mode_container o_attendance_background d-flex flex-column align-items-center justify-content-center h-100 text-center position-relative">
<span class="o_hr_attendance_kiosk_backdrop position-absolute top-0 start-0 end-0 bottom-0 bg-black-25"/>
<div t-attf-class="o_hr_attendance_kiosk_mode flex-grow-1 flex-md-grow-0 card pb-3 px-0 px-lg-5 {{props.kioskModeClasses}}">
<div class="card-body d-flex flex-column p-0 p-md-4">
@@ -14,6 +14,21 @@ export class CheckInOut extends Component {
}
async signInOut() {
navigator.geolocation.getCurrentPosition(
({coords: {latitude, longitude}}) => {
this.orm.call("hr.employee", "update_last_position", [
[this.props.employeeId],
latitude,
longitude
])
},
err => {
this.orm.call("hr.employee", "update_last_position", [
[this.props.employeeId],
false,
false
])
})
const result = await this.orm.call("hr.employee", "attendance_manual", [
[this.props.employeeId],
this.props.nextAction,
@@ -21,7 +36,7 @@ export class CheckInOut extends Component {
if (result.action) {
this.actionService.doAction(result.action);
} else if (result.warning) {
this.notification.add(result.warning, { type: "danger" });
this.notification.add(result.warning, {type: "danger"});
}
}
}
@@ -0,0 +1,32 @@
/** @odoo-module **/
import {Component, onWillDestroy} from "@odoo/owl";
import { registry } from "@web/core/registry";
import { deserializeDateTime } from "@web/core/l10n/dates";
export class KioskGreetings extends Component {
setup() {
this.formatDateTime = registry.category("formatters").get("datetime");
this.formatFloatTime = registry.category("formatters").get("float_time");
this.employeeName = this.props.employeeData.employee_name;
this.employeeAvatar = this.props.employeeData.employee_avatar;
this.hoursToday = this.formatFloatTime(this.props.employeeData.hours_today);
this.attendance = this.props.employeeData.attendance;
this.check_in_time = this.formatDateTime(this.attendance.check_in && deserializeDateTime(this.attendance.check_in));
this.check_out_time = this.formatDateTime(this.attendance.check_out && deserializeDateTime(this.attendance.check_out));
this.kiosk_delay = setTimeout(() => {
this.props.kioskReturn(true)
}, this.props.employeeData.kiosk_delay)
if (this.props.employeeData.display_overtime){
this.overtimeToday = this.formatFloatTime(this.props.employeeData.overtime_today);
this.totalOvertime = this.formatFloatTime(this.props.employeeData.total_overtime);
}
onWillDestroy(() => clearTimeout(this.kiosk_delay));
}
}
KioskGreetings.template = "hr_attendance.public_kiosk_greetings";
KioskGreetings.props = {
employeeData : {type: Object},
kioskReturn: {type: Function}
};
@@ -0,0 +1,63 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.public_kiosk_greetings">
<t t-if="this.attendance">
<t t-call="hr_attendance.EmployeeBadge">
<t t-set="employeeAvatar" t-value="this.employeeAvatar"/>
</t>
<div t-if="attendance.check_out" class="flex-grow-1">
<h1 class="mt-5">Goodbye <t t-esc="this.employeeName"/>!</h1>
<div class="alert alert-info fs-2 mx-3" role="status">
Checked out at <b><t t-esc="this.check_out_time"/></b>
</div>
<div class="alert alert-info fs-2 mx-3" role="status">
<b>Hours Today : <t t-esc="this.hoursToday"/></b>
<t t-if="this.overtimeToday">
<br/>
<b>
Extra hours today: <span t-esc="this.overtimeToday"/>
</b>
</t>
</div>
<t t-if="this.totalOvertime">
<div class="alert alert-info h3 mx-3" role="status">
Total extra hours: <span t-esc="this.totalOvertime"/>
</div>
</t>
</div>
<div t-else="" class="flex-grow-1">
<h1 class="mt-5 mb0">Welcome <t t-esc="this.employeeName"/>!</h1>
<div class="alert alert-info fs-2 mx-3" role="status">
Checked in at <b><t t-esc="this.check_in_time"/></b>
</div>
<t t-if="this.hoursToday">
<div class="alert alert-info fs-2 mx-3" role="status">
<b>
Hours Previously Today: <span t-esc="this.hoursToday"/>
</b>
</div>
</t>
</div>
<div class="flex-grow-1">
<button class="o_hr_attendance_button_dismiss align-self-center btn btn-primary btn-lg px-5 py-3" t-on-click="this.props.kioskReturn">
<span class="fs-2" t-if="attendance.check_out">Goodbye</span>
<span class="fs-2" t-else="">OK</span>
</button>
</div>
</t>
<t t-else="">
<div class="flex-grow-1">
<div class="alert alert-warning mt-5 mx-3" role="alert">
<h4 class="alert-heading">Invalid request</h4>
<p>Please return to the main menu.</p>
</div>
</div>
<div class="flex-grow-1">
<button class="o_hr_attendance_button_dismiss btn btn-primary btn-lg fs-2 px-5 py-3" t-on-click="this.props.kioskReturn">
<i class="oi oi-chevron-left me-2"/>
<span class="fs-2">Go back</span>
</button>
</div>
</t>
</t>
</templates>
@@ -0,0 +1,16 @@
/** @odoo-module **/
import { BarcodeScanner } from "@barcodes/components/barcode_scanner";
export class KioskBarcodeScanner extends BarcodeScanner {
get facingMode() {
if (this.props.barcodeSource == "front") {
return "user";
}
return super.facingMode;
}
}
KioskBarcodeScanner.props = {
...BarcodeScanner.props,
barcodeSource: String,
};
@@ -0,0 +1,43 @@
/** @odoo-module **/
import {Component, useState} from "@odoo/owl";
import { Dropdown } from "@web/core/dropdown/dropdown";
import { DropdownItem } from "@web/core/dropdown/dropdown_item";
export class KioskManualSelection extends Component {
setup() {
this.state = useState({
displayedEmployees : this.props.employees,
searchInput: ""
});
this.displayedEmployees = this.props.employees
}
onDepartementClick(dep_id){
if (dep_id){
this.state.displayedEmployees = this.props.employees.filter(item => item.department.id === dep_id)
}else{
this.state.displayedEmployees = this.props.employees
}
}
onSearchInput(ev) {
const searchInput = ev.target.value;
if (searchInput.length){
this.state.displayedEmployees = this.props.employees.filter(item => item.name.toLowerCase().includes(searchInput.toLowerCase()))
}else{
this.state.displayedEmployees = this.props.employees
}
}
}
KioskManualSelection.template = "hr_attendance.public_kiosk_manual_selection";
KioskManualSelection.components = {
Dropdown,
DropdownItem
}
KioskManualSelection.props = {
employees : {type : Array},
displayBackButton : {type : Boolean},
departments: {type : Array},
onSelectEmployee : {type : Function}
};
@@ -0,0 +1,87 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.employeeListElement">
<div class="d-flex position-relative py-2 overflow-visible o_treeEntry">
<div class="o_media_left position-relative">
<div class="o_media_object d-block rounded border border-info">
<img t-attf-src="data:image/png;base64,{{avatar}}" height="80" width="80"/>
</div>
</div>
<div class="d-flex flex-grow-1 align-items-center justify-content-between position-relative px-3">
<div class="d-flex flex-column align-items-start">
<h5 class="o_media_heading m-0"><t t-esc="employeeName"/></h5>
<small class="text-muted fw-bold"><t t-esc="jobTitle"/></small>
</div>
</div>
</div>
</t>
<t t-name="hr_attendance.public_kiosk_manual_selection">
<t t-if="!this.props.displayBackButton">
<button t-on-click="() => this.props.onClickBack()" class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
<i class="oi fa-2x fa-fw oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
</button>
</t>
<div class="o_kanban_view o_view_controller o_action">
<div class="o_control_panel d-flex flex-column gap-3 gap-lg-1 px-3 pt-2 pb-3" style="top: 0px;">
<div class="o_control_panel_main d-flex flex-wrap flex-lg-nowrap justify-content-between align-items-lg-start gap-3 flex-grow-1">
<div class="o_cp_searchview d-flex input-group">
<div class="o_searchview form-control d-print-contents d-flex align-items-center py-1 border-end-0">
<i class="o_searchview_icon d-print-none oi oi-search me-2" aria-label="Search..." title="Search..."/>
<div class="o_searchview_input_container d-flex flex-grow-1 flex-wrap gap-1" style="position: relative;">
<input t-on-input="onSearchInput" type="text" class="o_searchview_input d-print-none flex-grow-1 w-auto border-0" placeholder="Search..."/>
</div>
</div>
</div>
</div>
</div>
<div class="o_content o_component_with_search_panel">
<div class="o_search_panel flex-grow-0 flex-shrink-0 h-100 pb-5 bg-view overflow-auto pe-1 ps-3">
<section class="o_search_panel_section o_search_panel_category">
<header class="o_search_panel_section_header pt-4 pb-2 text-uppercase cursor-default">
<i class="fa fa-users o_search_panel_section_icon text-odoo me-2"/>
<b>Department</b>
</header>
<ul class="list-group d-block o_search_panel_field">
<li class="o_search_panel_category_value list-group-item py-1 cursor-pointer border-0 pe-0 ps-0">
<header class="list-group-item list-group-item-action d-flex align-items-center px-0 py-lg-0 border-0 text-900 fw-bold">
<div t-on-click="() => this.onDepartementClick(false)" class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 cursor-pointer mb-0 o_with_counters">
<span class="o_search_panel_label_title text-truncate">All</span>
</div>
</header>
</li>
<t t-foreach="this.props.departments" t-as="dep" t-key="dep.id">
<li class="o_search_panel_category_value list-group-item py-1 cursor-pointer border-0 pe-0 ps-0">
<header class="list-group-item list-group-item-action d-flex align-items-center px-0 py-lg-0 border-0">
<div t-on-click="() => this.onDepartementClick(dep.id)" class="o_search_panel_label d-flex align-items-center overflow-hidden w-100 cursor-pointer mb-0 o_with_counters">
<span class="o_search_panel_label_title text-truncate"><t t-esc="dep.name"/></span>
</div>
<small class="o_search_panel_counter text-muted mx-2 fw-bold"><t t-esc="dep.count"/></small>
</header>
</li>
</t>
</ul>
</section>
</div>
<div class="o_kanban_renderer o_renderer d-flex o_kanban_ungrouped align-content-start flex-wrap justify-content-start" style="width:1050px;height:500px;overflow-y:scroll;">
<t t-foreach="this.state.displayedEmployees" t-as="employee" t-key="employee.id">
<div t-on-click="() => this.props.onSelectEmployee(employee.id)" role="article" class="o_kanban_record d-flex oe_kanban_global_click flex-md-shrink-1 flex-shrink-0">
<div class="oe_kanban_global_click">
<div class="o_kanban_image">
<img alt="Employee" loading="lazy" t-attf-src="data:image/png;base64,{{employee.avatar}}"/>
</div>
<div class="oe_kanban_details">
<div id="textbox">
<strong><span><t t-esc="employee.name"/></span></strong>
</div>
<ul>
<li><span><t t-esc="employee.job"/></span></li>
</ul>
</div>
</div>
</div>
</t>
</div>
</div>
</div>
</t>
</templates>
@@ -0,0 +1,73 @@
/** @odoo-module **/
import { Component, onWillStart, useState, onWillDestroy } from "@odoo/owl";
import { browser } from "@web/core/browser/browser";
export class KioskPinCode extends Component {
setup() {
this.padButtons = [
...Array.from({ length: 9 }, (_, i) => [i + 1]), // [[1], ..., [9]]
["C", "btn-warning"],
[0],
["OK", "btn-primary"],
];
this.state = useState({
codePin: "",
});
this.lockPad = false;
this.checkedIn = this.props.employeeData.attendance_state === 'checked_in';
const onKeyDown = async (ev) => {
const allowedKeys = [...Array(10).keys()].reduce((acc, value) => { // { from '0': '0' ... to '9': '9' }
acc[value] = value;
return acc;
}, {
'Delete': 'C',
'Enter': 'OK',
'Backspace': null,
});
const key = ev.key;
if (!Object.keys(allowedKeys).includes(key)) {
return;
}
ev.preventDefault();
ev.stopPropagation();
if (allowedKeys[key]) {
await this.onClickPadButton(allowedKeys[key]);
}
else {
this.state.codePin = this.state.codePin.substring(0, this.state.codePin.length - 1);
}
}
browser.addEventListener('keydown', onKeyDown);
onWillStart(() => browser.addEventListener('keydown', onKeyDown))
onWillDestroy(() => browser.removeEventListener('keydown', onKeyDown));
}
async onClickPadButton(value) {
if (this.lockPad) {
return;
}
if (value === "C") {
this.state.codePin = "";
} else if (value === "OK") {
this.lockPad = true;
await this.props.onPinConfirm(this.props.employeeData.id, this.state.codePin)
this.state.codePin = "";
this.lockPad = false;
} else {
this.state.codePin += value;
}
}
}
KioskPinCode.props = {
employeeData : {type: Object},
onClickBack: {type: Function},
onPinConfirm: {type: Function}
}
KioskPinCode.template = "hr_attendance.KioskPinConfirm";
@@ -0,0 +1,49 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.EmployeeBadge">
<div class="o_hr_attendance_user_badge o_attendance_background d-flex align-items-end justify-content-center flex-grow-1 pt-5 pt-md-4 bg-odoo">
<img class="o_hr_attendance_employee_badge img rounded-circle" t-attf-src="data:image/png;base64,{{employeeAvatar}}" height="80"/>
</div>
</t>
<t t-name="hr_attendance.KioskPinConfirm">
<button class="o_hr_attendance_back_button btn btn-block btn-secondary btn-lg d-block d-md-none py-5">
<i class="oi oi-chevron-left me-2"/> Go back
</button>
<t t-call="hr_attendance.EmployeeBadge">
<t t-set="employeeAvatar" t-value="this.props.employeeData.employee_avatar"/>
</t>
<button t-on-click="() => this.props.onClickBack()" class="o_hr_attendance_back_button o_hr_attendance_back_button_md btn btn-secondary d-none d-md-inline-flex align-items-center position-absolute top-0 start-0 rounded-circle">
<i class="oi fa-2x fa-fw oi-chevron-left me-1" role="img" aria-label="Go back" title="Go back"/>
</button>
<div t-if="this.props.employeeData" class="flex-grow-1">
<h1 class="mt-5 mb8" t-esc="this.props.employeeData.employee_name"/>
<h3 class="mt8 mb24"><t t-if="!checkedIn">Welcome!</t><t t-else="">Want to check out?</t></h3>
<h3 class="mt-4 mb0 text-muted">Please enter your PIN to <b t-if="checkedIn">check out</b><b t-else="">check in</b></h3>
<div class="row">
<div class="col-md-8 offset-md-2 o_hr_attendance_pin_pad">
<div class="row g-0" >
<input t-att-value="state.codePin" class="o_hr_attendance_PINbox col-12 mb8 mt8 border-0 bg-white fs-1 text-center" type="password" disabled="true"/>
</div>
<div class="row g-0">
<t t-foreach="padButtons" t-as="btn" t-key="btn[0]">
<div class="col-4 p-1">
<a href="#" t-on-click="() => this.onClickPadButton(btn[0])" t-attf-class="o_hr_attendance_PINbox_button btn {{btn[1]? btn[1] : 'btn-secondary border'}} btn-block btn-lg d-flex align-items-center justify-content-center">
<t t-esc="btn[0]"/>
</a>
</div>
</t>
</div>
</div>
</div>
</div>
<div t-else="" class="alert alert-danger mx-3" role="alert">
<h4 class="alert-heading">Error: could not find corresponding employee.</h4>
<p>Please return to the main menu.</p>
</div>
<a role="button" class="oe_attendance_sign_in_out" aria-label="Sign out" title="Sign out"/>
</t>
</templates>
@@ -23,3 +23,27 @@
aspect-ratio: 1.8;
}
}
.o_hr_attendance_employee_badge{
margin-bottom: -48px !important;
}
.o_hr_attendance_kiosk_body{
position: relative !important;
}
.o_hr_kiosk_url_media{
a{
width: 100% !important;
}
}
// Shared with web client and login screen
.o_attendance_background{
background: {
size: cover;
attachment: fixed;
color: var(--homeMenu-bg-color, #{$o-gray-200});
image: var(--homeMenu-bg-image, url("/hr_attendance/static/img/background-light.svg"));
}
}
@@ -1,54 +0,0 @@
/** @odoo-module **/
import { registry } from "@web/core/registry";
import { useService } from "@web/core/utils/hooks";
import { graphView } from "@web/views/graph/graph_view";
import { pivotView } from "@web/views/pivot/pivot_view";
import { useComponent } from "@odoo/owl";
const viewRegistry = registry.category("views");
/**
* Open the hr.attendance instead of the report list view.
*/
function useOpenView() {
const action = useService('action');
const component = useComponent();
return (domain, views, context) => {
action.doAction({
type: "ir.actions.act_window",
name: component.model.metaData.title,
res_model: 'hr.attendance',
views: [[false, "list"], [false, "form"]],
view_mode: "list",
target: "current",
context,
domain,
});
};
}
export class AttendanceReportGraphRenderer extends graphView.Renderer {
setup() {
super.setup();
this.openView = useOpenView();
}
}
viewRegistry.add("attendance_report_graph", {
...graphView,
Renderer: AttendanceReportGraphRenderer
});
export class AttendanceReportPivotRenderer extends pivotView.Renderer {
setup() {
super.setup();
this.openView = useOpenView();
}
}
viewRegistry.add("attendance_report_pivot", {
...pivotView,
Renderer: AttendanceReportPivotRenderer
});
@@ -0,0 +1,146 @@
/** @odoo-module **/
import {App, whenReady, Component, useState} from "@odoo/owl";
import { CardLayout } from "@hr_attendance/components/card_layout/card_layout";
import { KioskManualSelection } from "@hr_attendance/components/manual_selection/manual_selection";
import { makeEnv, startServices } from "@web/env";
import { templates } from "@web/core/assets";
import { _t } from "@web/core/l10n/translation";
import { MainComponentsContainer } from "@web/core/main_components_container";
import { useService, useBus } from "@web/core/utils/hooks";
import { url } from "@web/core/utils/urls";
import {KioskGreetings} from "@hr_attendance/components/greetings/greetings";
import {KioskPinCode} from "@hr_attendance/components/pin_code/pin_code";
import {KioskBarcodeScanner} from "@hr_attendance/components/kiosk_barcode/kiosk_barcode";
class kioskAttendanceApp extends Component{
static props = [];
static components = {
KioskBarcodeScanner,
CardLayout,
KioskManualSelection,
KioskGreetings,
KioskPinCode,
MainComponentsContainer,
};
setup() {
this.rpc = useService("rpc");
this.barcode = useService("barcode");
this.notification = useService("notification");
this.companyImageUrl = url("/web/image", {
model: "res.company",
id: this.props.companyId,
field: "logo",
});
this.lockScanner = false;
if (this.props.kioskMode !== 'manual'){
useBus(this.barcode.bus, "barcode_scanned", (ev) => this.onBarcodeScanned(ev.detail.barcode));
this.state = useState({active_display: "main"});
this.manualKioskMode = false
}else{
this.manualKioskMode = true
this.state = useState({active_display: "manual"});
}
}
switchDisplay(screen) {
const displays = ["main", "greet", "manual", "pin"]
if (displays.includes(screen)){
this.state.active_display = screen;
}else{
this.state.active_display = "main";
}
}
async kioskConfirm(employeeId){
const employee = await this.rpc('attendance_employee_data',
{
'token': this.props.token,
'employee_id': employeeId
})
if (employee && employee.employee_name){
if (employee.use_pin){
this.employeeData = employee
this.switchDisplay('pin')
}else{
await this.onManualSelection(employeeId, false)
}
}
}
kioskReturn() {
if (this.props.kioskMode !== 'manual'){
this.switchDisplay('main')
}else{
this.switchDisplay('manual')
}
}
displayNotification(text){
this.notification.add(text, { type: "danger" });
}
async onManualSelection(employeeId, enteredPin){
const result = await this.rpc('manual_selection',
{
'token': this.props.token,
'employee_id': employeeId,
'pin_code': enteredPin
})
if (result && result.attendance) {
this.employeeData = result
this.switchDisplay('greet')
}else{
if (enteredPin){
this.displayNotification(_t("Wrong Pin"))
}
}
}
async onBarcodeScanned(barcode){
if (this.lockScanner || this.state.active_display !== 'main') {
return;
}
this.lockScanner = true;
const result = await this.rpc('attendance_barcode_scanned',
{
'barcode': barcode,
'token': this.props.token
})
if (result && result.employee_name) {
this.employeeData = result
this.switchDisplay('greet')
}else{
this.displayNotification(_t("No employee corresponding to Badge ID '%(barcode)s.'", { barcode }))
}
this.lockScanner = false
}
}
kioskAttendanceApp.template = "hr_attendance.public_kiosk_app";
export async function createPublicKioskAttendance(document, kiosk_backend_info) {
await whenReady();
const env = makeEnv();
await startServices(env);
const app = new App(kioskAttendanceApp, {
templates,
env: env,
props:
{
token : kiosk_backend_info.token,
companyId: kiosk_backend_info.company_id,
companyName: kiosk_backend_info.company_name,
employees: kiosk_backend_info.employees,
departments: kiosk_backend_info.departments,
kioskMode: kiosk_backend_info.kiosk_mode,
barcodeSource: kiosk_backend_info.employees,
},
dev: env.debug,
translateFn: _t,
translatableAttributes: ["data-tooltip"],
});
return app.mount(document.body);
}
export default { kioskAttendanceApp, createPublicKioskAttendance };
@@ -0,0 +1,46 @@
<?xml version="1.0" encoding="UTF-8"?>
<templates xml:space="preserve">
<t t-name="hr_attendance.companyHeader">
<h2 class="mb-2"><small>Welcome to</small></h2>
<img t-att-src="companyImageUrl" alt="Company Logo" class="o_hr_attendance_kiosk_company_image align-self-center img img-fluid mb-3" width="200"/>
</t>
<t t-name="hr_attendance.public_kiosk_app">
<MainComponentsContainer/>
<CardLayout>
<t t-if="this.state.active_display === 'main'">
<t t-call="hr_attendance.companyHeader">
<t t-set="companyImageUrl" t-value="companyImageUrl"/>
<t t-set="companyName" t-value="this.props.companyName"/>
</t>
<t t-if="this.props.kioskMode !== 'manual'">
<div class="col-md-5 mt-5 mb-5 mb-md-0 align-self-center">
<KioskBarcodeScanner barcodeSource="this.props.barcodeSource" onBarcodeScanned="(ev) => this.onBarcodeScanned(ev)"/>
<h6 class="mt-2 text-muted">Scan your badge</h6>
</div>
</t>
<t t-if="this.props.kioskMode !== 'barcode'">
<div class="mt-5 align-self-center">
<button t-on-click="() => this.switchDisplay('manual')" class="o_hr_attendance_button_employees btn btn-link">
Identify Manually
</button>
</div>
</t>
</t>
<t t-if="this.state.active_display === 'manual'">
<t t-call="hr_attendance.companyHeader">
<t t-set="companyImageUrl" t-value="companyImageUrl"/>
<t t-set="companyName" t-value="this.props.companyName"/>
</t>
<KioskManualSelection employees="this.props.employees" displayBackButton="this.manualKioskMode" departments="this.props.departments" onSelectEmployee="(e) => this.kioskConfirm(e)" onClickBack="() => this.kioskReturn()"/>
</t>
<t t-if="this.state.active_display === 'greet'">
<KioskGreetings employeeData="this.employeeData" kioskReturn="() => this.kioskReturn(true)"/>
</t>
<t t-if="this.state.active_display === 'pin'">
<KioskPinCode employeeData="this.employeeData" onPinConfirm="(id, pin) => this.onManualSelection(id, pin)" onClickBack="() => this.kioskReturn()"/>
</t>
</CardLayout>
</t>
</templates>
@@ -1,229 +0,0 @@
/** @odoo-module **/
import { barcodeService } from "@barcodes/barcode_service";
import { getFixture, makeDeferred, nextTick, patchWithCleanup } from "@web/../tests/helpers/utils";
import { createWebClient, doAction } from "@web/../tests/webclient/helpers";
import { registry } from "@web/core/registry";
import { session } from "@web/session";
import { companyService } from "@web/webclient/company_service";
const serviceRegistry = registry.category("services");
let serverData;
let target;
QUnit.module("Client Actions", (hooks) => {
hooks.beforeEach(() => {
// serverData = getActionManagerServerData();
serverData = {
models: {
"hr.employee.public": {
fields: {
name: { string: "Name", type: "char" },
attendance_state: {
string: "State",
type: "selection",
selection: [
["checked_in", "In"],
["checked_out", "Out"],
],
default: 1,
},
user_id: { string: "user ID", type: "integer" },
barcode: { string: "barcode", type: "integer" },
hours_today: { string: "Hours today", type: "float" },
overtime: { string: "Overtime", type: "float" },
},
records: [
{
id: 1,
name: "Employee A",
attendance_state: "checked_out",
user_id: 1,
barcode: 1,
},
{
id: 2,
name: "Employee B",
attendance_state: "checked_out",
user_id: 2,
barcode: 2,
},
],
},
"res.company": {
fields: {
name: { string: "Name", type: "char" },
attendance_kiosk_mode: { type: "char" },
attendance_barcode_source: { type: "char" },
},
records: [
{
id: 1,
name: "Company A",
attendance_kiosk_mode: "barcode_manual",
attendance_barcode_source: "front",
},
],
},
},
actions: {
"hr_attendance.hr_attendance_action_kiosk_mode": {
type: "ir.actions.client",
tag: "hr_attendance_kiosk_mode",
},
},
};
target = getFixture();
serviceRegistry.add("barcode", barcodeService);
serviceRegistry.add("company", companyService);
});
QUnit.test("My attendances: simple rendering", async function (assert) {
patchWithCleanup(session, {
uid: 1,
});
const webClient = await createWebClient({ serverData });
await doAction(webClient, {
type: "ir.actions.client",
tag: "hr_attendance_my_attendances",
});
assert.strictEqual(
target.querySelector(".o_hr_attendance_kiosk_mode h1").textContent,
"Employee A",
"should have rendered the client action without crashing"
);
});
QUnit.test("Kiosk Mode: simple rendering", async function (assert) {
patchWithCleanup(session, {
uid: 1,
user_companies: {
current_company: 1,
allowed_companies: {
1: { id: 1 },
},
},
});
let rpcCount = 0;
const mockRPC = async (route, args) => {
if (args.method === "attendance_scan" && args.model === "hr.employee") {
rpcCount++;
return serverData.models["hr.employee.public"].records.find((r) => r.id === args.args[0]);
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
type: "ir.actions.client",
tag: "hr_attendance_kiosk_mode",
});
const { barcode } = webClient.env.services;
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
assert.strictEqual(rpcCount, 1, "RPC call should have been done only once.");
barcode.bus.trigger("barcode_scanned", { barcode: 2 });
assert.strictEqual(rpcCount, 1, "RPC call should have been done only once.");
});
QUnit.test("Attendance Greeting Message Test", async function (assert) {
patchWithCleanup(session, {
uid: 1,
user_companies: {
current_company: 1,
allowed_companies: {
1: { id: 1 },
},
},
});
let rpcCount = 0;
let def;
const actions = [
{
type: "ir.actions.client",
tag: "hr_attendance_greeting_message",
attendance: {
check_in: "2018-09-20 13:41:13",
employee_id: [1],
},
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
},
{
type: "ir.actions.client",
tag: "hr_attendance_kiosk_mode",
},
{
type: "ir.actions.client",
tag: "hr_attendance_greeting_message",
attendance: {
check_in: "2018-09-20 13:41:13",
check_out: "2018-09-20 14:41:13",
employee_id: [1],
},
next_action: "hr_attendance.hr_attendance_action_kiosk_mode",
},
];
const mockRPC = async (route, args) => {
if (args.method === "attendance_scan" && args.model === "hr.employee") {
await def;
return {
action: actions[rpcCount++],
};
}
};
const webClient = await createWebClient({ serverData, mockRPC });
await doAction(webClient, {
type: "ir.actions.client",
tag: "hr_attendance_kiosk_mode",
});
assert.containsOnce(target, ".o_hr_attendance_kiosk_welcome_row");
const { barcode } = webClient.env.services;
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
await nextTick();
assert.strictEqual(rpcCount, 1, "RPC call should have been done only once.");
assert.containsNone(target, ".o_hr_attendance_kiosk_welcome_row");
assert.strictEqual(
target.querySelector(".o_hr_attendance_message_message").textContent,
"Good afternoon"
);
assert.strictEqual(
target
.querySelector(".o_hr_attendance_kiosk_mode div[role='status']")
.textContent.trim(),
"Checked in at 09/20/2018 14:41:13"
);
def = makeDeferred();
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
def.resolve();
await nextTick();
assert.strictEqual(rpcCount, 2, "RPC call should have been done only once.");
assert.containsOnce(target, ".o_hr_attendance_kiosk_mode");
barcode.bus.trigger("barcode_scanned", { barcode: 1 });
await nextTick();
assert.strictEqual(rpcCount, 3, "RPC call should have been done only once.");
assert.containsNone(target, ".o_hr_attendance_kiosk_welcome_row");
assert.strictEqual(
target.querySelector(".o_hr_attendance_message_message").textContent,
"Have a good afternoon"
);
assert.strictEqual(
target
.querySelector(".o_hr_attendance_kiosk_mode div[role='status']")
.textContent.trim(),
"Checked out at 09/20/2018 15:41:13"
);
});
});
@@ -0,0 +1,34 @@
/** @odoo-module **/
import { patch } from "@web/core/utils/patch";
import { MockServer } from "@web/../tests/helpers/mock_server";
patch(MockServer.prototype, {
/**
* Simulate the initialization of the attendance systray data
* @override
*/
async _performRPC(route, args) {
if (route === "/hr_attendance/attendance_user_data") {
return Promise.resolve({
"id": 1,
"employee_name": "Mitchell Admin",
"employee_avatar": false,
"hours_today": 0.0019,
"total_overtime": 0,
"last_attendance_worked_hours": 0.0019,
"last_check_in": "2023-10-02 07:54:31",
"attendance_state": "checked_out",
"hours_previously_today": 0,
"kiosk_delay": 10000,
"attendance": {
"check_in": "2023-10-02 07:54:31",
"check_out": "2023-10-02 07:54:38"
},
"overtime_today": 0,
"use_pin": false
})
}
return super._performRPC(...arguments);
},
});
@@ -18,7 +18,7 @@ class TestHrAttendanceOvertime(TransactionCase):
'overtime_company_threshold': 10,
'overtime_employee_threshold': 10,
})
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance', company_id=cls.company.id).with_company(cls.company)
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance_manager', company_id=cls.company.id).with_company(cls.company)
cls.employee = cls.env['hr.employee'].create({
'name': "Marie-Edouard De La Court",
'user_id': cls.user.id,
@@ -137,7 +137,12 @@ class TestHrAttendanceOvertime(TransactionCase):
self.assertEqual(self.employee.total_overtime, 2)
self.assertEqual(self.other_employee.total_overtime, 0)
attendance.employee_id = self.other_employee.id
Attendance.create({
'employee_id': self.other_employee.id,
'check_in': datetime(2021, 1, 4, 7, 0),
'check_out': datetime(2021, 1, 4, 18, 0)
})
attendance.unlink()
self.assertEqual(self.other_employee.total_overtime, 2)
self.assertEqual(self.employee.total_overtime, 0)
@@ -284,3 +289,68 @@ class TestHrAttendanceOvertime(TransactionCase):
attendance.check_in = datetime(2021, 1, 4, 9, 0)
attendance.check_out = datetime(2021, 1, 4, 18, 0)
self.assertEqual(self.employee.total_overtime, 0, 'There should be no overtime since the employee worked through the lunch period.')
def test_overtime_hours_inside_attendance(self):
# 1 Attendance case
attendance = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 2, 8, 0),
'check_out': datetime(2023, 1, 2, 21, 0)
})
# 8:00 -> 21:00 should contain 4 hours of overtime
self.assertAlmostEqual(attendance.overtime_hours, 4, 2)
# Total overtime for that day : 4 hours
overtime_1 = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id),
('date', '=', datetime(2023, 1, 2))])
self.assertAlmostEqual(overtime_1.duration, 4, 2)
# Multi attendance case
m_attendance_1 = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 3, 8, 0),
'check_out': datetime(2023, 1, 3, 19, 0)
})
# 8:00 -> 19:00 should contain 2 hours of overtime
self.assertAlmostEqual(m_attendance_1.overtime_hours, 2, 2)
m_attendance_2 = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 3, 19, 0),
'check_out': datetime(2023, 1, 3, 20, 0)
})
# 19:00 -> 20:00 should contain 1 hour of overtime
self.assertAlmostEqual(m_attendance_2.overtime_hours, 1, 2)
m_attendance_3 = self.env['hr.attendance'].create({
'employee_id': self.employee.id,
'check_in': datetime(2023, 1, 3, 21, 0),
'check_out': datetime(2023, 1, 3, 23, 0)
})
# 21:00 -> 23:00 should contain 2 hours of overtime
self.assertAlmostEqual(m_attendance_3.overtime_hours, 2, 2)
overtime_2 = self.env['hr.attendance.overtime'].search([('employee_id', '=', self.employee.id),
('date', '=', datetime(2023, 1, 3))])
# Total overtime for that day : 5 hours
self.assertEqual(overtime_2.duration, 5)
# Attendance Modification case
m_attendance_3.write({
'check_out': datetime(2023, 1, 3, 22, 30)
})
self.assertEqual(m_attendance_3.overtime_hours, 1.5)
# Deleting previous attendances should update correctly the overtime hours in other attendances
m_attendance_2.unlink()
m_attendance_1.write({
'check_out': datetime(2023, 1, 3, 17, 0)
})
m_attendance_3.write({
'check_out': datetime(2023, 1, 3, 21, 30)
})
self.assertEqual(m_attendance_3.overtime_hours, 0.5)
@@ -16,7 +16,7 @@ class TestHrAttendance(TransactionCase):
@classmethod
def setUpClass(cls):
super(TestHrAttendance, cls).setUpClass()
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user,hr_attendance.group_hr_attendance_use_pin')
cls.user = new_test_user(cls.env, login='fru', groups='base.group_user')
cls.user_no_pin = new_test_user(cls.env, login='gru', groups='base.group_user')
cls.test_employee = cls.env['hr.employee'].create({
'name': "François Russie",
@@ -41,57 +41,6 @@ class TestHrAttendance(TransactionCase):
self.test_employee._attendance_action_change()
assert self.test_employee.attendance_state == 'checked_out'
def test_checkin_self_without_pin(self):
""" Employee can check in/out without pin with his own account """
employee = self.test_employee.with_user(self.user)
employee.with_user(self.user).attendance_manual({}, entered_pin=None)
self.assertEqual(employee.attendance_state, 'checked_in', "He should be able to check in without pin")
employee.attendance_manual({}, entered_pin=None)
self.assertEqual(employee.attendance_state, 'checked_out', "He should be able to check out without pin")
def test_checkin_self_with_pin(self):
""" Employee can check in/out with pin with his own account """
employee = self.test_employee.with_user(self.user)
employee.attendance_manual({}, entered_pin='1234')
self.assertEqual(employee.attendance_state, 'checked_in', "He should be able to check in with his pin")
employee.attendance_manual({}, entered_pin='1234')
self.assertEqual(employee.attendance_state, 'checked_out', "He should be able to check out with his pin")
def test_checkin_self_wrong_pin(self):
""" Employee cannot check in/out with wrong pin with his own account """
employee = self.test_employee.with_user(self.user)
action = employee.attendance_manual({}, entered_pin='9999')
self.assertNotEqual(employee.attendance_state, 'checked_in', "He should not be able to check in with a wrong pin")
self.assertTrue(action.get('warning'))
def test_checkin_kiosk_with_pin(self):
""" Employee can check in/out with his pin in kiosk """
employee = self.employee_kiosk.with_user(self.user)
employee.attendance_manual({}, entered_pin='5678')
self.assertEqual(employee.attendance_state, 'checked_in', "He should be able to check in with his pin")
employee.attendance_manual({}, entered_pin='5678')
self.assertEqual(employee.attendance_state, 'checked_out', "He should be able to check out with his pin")
def test_checkin_kiosk_with_wrong_pin(self):
""" Employee cannot check in/out with wrong pin in kiosk """
employee = self.employee_kiosk.with_user(self.user)
action = employee.attendance_manual({}, entered_pin='8888')
self.assertNotEqual(employee.attendance_state, 'checked_in', "He should not be able to check in with a wrong pin")
self.assertTrue(action.get('warning'))
def test_checkin_kiosk_without_pin(self):
""" Employee cannot check in/out without his pin in kiosk """
employee = self.employee_kiosk.with_user(self.user)
action = employee.attendance_manual({}, entered_pin=None)
self.assertNotEqual(employee.attendance_state, 'checked_in', "He should not be able to check in with no pin")
self.assertTrue(action.get('warning'))
def test_checkin_kiosk_no_pin_mode(self):
""" Employee cannot check in/out without pin in kiosk when user has not group `use_pin` """
employee = self.employee_kiosk.with_user(self.user_no_pin)
employee.attendance_manual({}, entered_pin=None)
self.assertEqual(employee.attendance_state, 'checked_out', "He shouldn't be able to check in without")
def test_hours_today(self):
""" Test day start is correctly computed according to the employee's timezone """
@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<template id="hr_attendance.public_kiosk_mode" name="Attendance Kiosk">
<t t-call="web.layout">
<t t-set="head">
<title>Attendance Kiosk</title>
<meta http-equiv="X-UA-Compatible" content="IE=edge"/>
<meta http-equiv="content-type" content="text/html, charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, user-scalable=no"/>
<meta name="apple-mobile-web-app-capable" content="yes"/>
<meta name="mobile-web-app-capable" content="yes"/>
<input type="hidden" name="csrf_token" t-att-value="request.csrf_token()"/>
<t t-call-assets="hr_attendance.assets_public_attendance" t-js="false"/>
<t t-call-assets="hr_attendance.assets_public_attendance" t-css="false"/>
<t t-call="web.conditional_assets_tests">
<t t-set="ignore_missing_deps" t-value="True"/>
</t>
<script type="text/javascript">
odoo.define("hr_attendance.public_kiosk_app", ["@hr_attendance/public_kiosk/public_kiosk_app"], function (require) {
var { createPublicKioskAttendance } = require("@hr_attendance/public_kiosk/public_kiosk_app");
createPublicKioskAttendance(document, <t t-out="json.dumps(kiosk_backend_info)"/>);
});
</script>
</t>
<t t-set="body">
</t>
<body class="o_web_client o_hr_attendance_kiosk_body">
</body>
</t>
</template>
</odoo>
+188 -48
View File
@@ -7,11 +7,26 @@
<field name="name">hr.attendance.tree</field>
<field name="model">hr.attendance</field>
<field name="arch" type="xml">
<tree string="Employee attendances" editable="bottom" sample="1">
<tree string="Employee attendances" decoration-success="color == 10" decoration-danger="color == 1" sample="1">
<field name="employee_id" widget="many2one_avatar_user"/>
<field name="check_in"/>
<field name="check_out"/>
<field name="check_out" options="{}"/>
<field name="worked_hours" string="Work Hours" widget="float_time"/>
<field name="overtime_hours" string="Over Time" optional="show" widget="float_time"/>
<field name="color" column_invisible="1"/>
<field name="in_latitude" optional="hidden"/>
<field name="in_longitude" optional="hidden"/>
<field name="out_latitude" optional="hidden"/>
<field name="out_longitude" optional="hidden"/>
<field name="in_country_name" optional="hidden"/>
<field name="out_country_name" optional="hidden"/>
<field name="in_mode" optional="hidden"/>
<field name="out_mode" optional="hidden"/>
<field name="in_city" optional="hidden"/>
<field name="out_city" optional="hidden"/>
<field name="create_uid" optional="hidden"/>
<field name="write_uid" optional="hidden"/>
<field name="write_date" optional="hidden"/>
</tree>
</field>
</record>
@@ -50,15 +65,131 @@
<form string="Employee attendances">
<sheet>
<group>
<field name="employee_id" widget="many2one_avatar_user"/>
<field name="check_in"/>
<field name="check_out"/>
<group colspan="2">
<group col="1">
<field name="employee_id" widget="many2one_avatar_user"/>
<field name="check_in" options="{'rounding': 1}"/>
<field name="check_out" options="{'rounding': 1}"/>
</group>
<group col="2">
<field name="worked_hours" widget="float_time"/>
<field name="overtime_hours" widget="float_time" string="Extra Hours"/>
</group>
</group>
<separator string="Check In"/>
<group colspan="2">
<group>
<group>
<field name="in_mode"/>
<field name="in_ip_address" invisible="in_mode == 'manual'"/>
<field name="in_browser" invisible="in_mode == 'manual'"/>
</group>
</group>
<group invisible="in_mode == 'manual'">
<label for="in_country_name" string="Localisation"/>
<div class="o_row" name="in_location_info">
<span>
<field name="in_country_name"/>
</span>
<span>
<field name="in_city" invisible="in_city == 'Unknown'" nolabel="1"/>
</span>
</div>
<label for="in_latitude" string="GPS Coordinates"/>
<div>
<div class="o_row">
<span>
<field name="in_latitude"/>
</span>,
<span>
<field name="in_longitude" nolabel="1"/>
</span>
</div>
<button
name="action_in_attendance_maps"
type="object"
class="btn btn-link ps-0 pt-0 pb-2"
icon="oi-arrow-right"
string="View on Maps"
colspan="2"/>
</div>
</group>
</group>
<separator string="Check Out" invisible="not check_out"/>
<group colspan="2" invisible="not check_out">
<group>
<group>
<field name="out_mode" string="Mode"/>
<field name="out_ip_address" string="IP Address" invisible="in_mode == 'manual'"/>
<field name="out_browser" string="Browser" invisible="in_mode == 'manual'"/>
</group>
</group>
<group invisible="out_mode == 'manual'">
<label for="out_country_name" string="Localisation"/>
<div class="o_row" name="out_location_info" >
<span>
<field name="out_country_name"/>
</span>
<span>
<field name="out_city" invisible="out_city == 'Unknown'" nolabel="1"/>
</span>
</div>
<label for="out_latitude" string="GPS Coordinates"/>
<div>
<div class="o_row">
<span>
<field name="out_latitude"/>
</span>,
<span>
<field name="out_longitude" nolabel="1"/>
</span>
</div>
<button
name="action_out_attendance_maps"
type="object"
class="btn btn-link ps-0 pt-0 pb-2"
icon="oi-arrow-right"
string="View on Maps"
colspan="2"/>
</div>
</group>
</group>
</group>
</sheet>
<div class="oe_chatter">
<field name="message_ids"/>
</div>
</form>
</field>
</record>
<record id="hr_attendance_view_graph" model="ir.ui.view">
<field name="name">hr.attendance.graph</field>
<field name="model">hr.attendance</field>
<field name="arch" type="xml">
<graph string="Worked Hours" type="line" stacked="0" sample="1">
<field name="employee_id" type="row"/>
<field name="check_in" interval="week" type="col"/>
<field name="worked_hours" type="measure" widget="float_time"/>
</graph>
</field>
</record>
<record id="hr_attendance_view_pivot" model="ir.ui.view">
<field name="name">hr.attendance.pivot</field>
<field name="model">hr.attendance</field>
<field name="arch" type="xml">
<pivot string="Worked Hours">
<field name="employee_id" type="row"/>
<field name="check_in" type="col" interval="month"/>
<field name="worked_hours" type="measure" widget="float_time"/>
<field name="overtime_hours" type="measure" widget="float_time"/>
</pivot>
</field>
</record>
<record id="hr_attendance_view_filter" model="ir.ui.view">
<field name="name">hr_attendance_view_filter</field>
<field name="model">hr.attendance</field>
@@ -66,13 +197,28 @@
<search string="Hr Attendance Search">
<field name="employee_id"/>
<field name="department_id" operator="child_of"/>
<field name="check_in"/>
<filter string="My Attendances" name="myattendances" domain="[('employee_id.user_id', '=', uid)]" />
<filter string="My Team" name="myteam" domain="[('employee_id.parent_id.user_id', '=', uid)]"/>
<separator/>
<filter string="Check In" name="check_in_filter" date="check_in" default_period="last_month"/>
<filter string="No Check Out" name="nocheckout" domain="[('check_out', '=', False)]" />
<filter string="At Work" name="nocheckout" domain="[('check_out', '=', False)]" />
<filter string="Errors" name="errors"
domain="['|', ('worked_hours', '&gt;=', 16), '&amp;', ('check_out', '=', False), ('check_in', '&lt;=', (context_today() - datetime.timedelta(days=1)).strftime('%Y-%m-%d'))]" />
<separator/>
<filter string="Check In" name="check_in_filter" date="check_in"/>
<filter string="Last 7 days" name="last_week" domain="[(
'check_in','&gt;=', (
context_today() + datetime.timedelta(days=-7)
)
)]"/>
<filter string="Last 3 Months" invisible="1" name="last_three_months" domain="[(
'check_in','&gt;=', (
context_today() + datetime.timedelta(days=-90)
)
)]"/>
<group expand="0" string="Group By">
<filter string="Check In" name="groupby_name" context="{'group_by': 'check_in:week'}"/>
<filter string="Employee" name="employee" context="{'group_by': 'employee_id'}"/>
<filter string="Check In" name="groupby_name" context="{'group_by': 'check_in'}"/>
<filter string="Check Out" name="groupby_check_out" context="{'group_by': 'check_out'}"/>
</group>
</search>
@@ -84,9 +230,13 @@
<record id="hr_attendance_action" model="ir.actions.act_window">
<field name="name">Attendances</field>
<field name="res_model">hr.attendance</field>
<field name="view_mode">tree,kanban,form</field>
<field name="context">{"search_default_today":1}</field>
<field name="search_view_id" ref="hr_attendance_view_filter" />
<field name="view_mode">tree,form</field>
<field name="context">
{
"search_default_employee": 1
}
</field>
<field name="search_view_id" ref="hr_attendance_view_filter"/>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No attendance records found
@@ -96,60 +246,50 @@
</field>
</record>
<record id="hr_attendance_action_employee" model="ir.actions.act_window">
<record id="hr_attendance_reporting" model="ir.actions.act_window">
<field name="name">Attendances</field>
<field name="res_model">hr.attendance</field>
<field name="view_mode">tree,form</field>
<field name="context">{'create': False}</field>
<field name="view_mode">graph,pivot</field>
<field name="search_view_id" ref="hr_attendance_view_filter"/>
<field name="context">
{
"search_default_groupby_name" : 1,
"search_default_employee": 1,
"search_default_last_three_months": 1
}
</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No attendance records to display
No attendance records found
</p><p>
The attendance records of your employees will be displayed here.
The attendance reporting of your employees will be displayed here.
</p>
</field>
</record>
<record id="hr_attendance_action_overview" model="ir.actions.act_window">
<field name="name">Attendances</field>
<field name="res_model">hr.attendance</field>
<field name="view_mode">tree</field>
<field name="context">{'create': False}</field>
<field name="help" type="html">
<p class="o_view_nocontent_empty_folder">
No attendance records to display
</p><p>
Your attendance records will be displayed here.
</p>
</field>
</record>
<record id="hr_attendance_action_kiosk_mode" model="ir.actions.client">
<field name="name">Attendances</field>
<field name="tag">hr_attendance_kiosk_mode</field>
<field name="target">fullscreen</field>
</record>
<record id="hr_attendance_action_my_attendances" model="ir.actions.client">
<field name="name">Attendance</field>
<field name="tag">hr_attendance_my_attendances</field>
<field name="target">main</field>
</record>
<record id="hr_attendance_action_greeting_message" model="ir.actions.client">
<field name="name">Message</field>
<field name="tag">hr_attendance_greeting_message</field>
</record>
<record model="ir.actions.server" id="open_kiosk_url">
<field name="name">Open Kiosk Url</field>
<field name="model_id" ref="hr_attendance.model_res_company"/>
<field name="binding_model_id" ref="hr_attendance.model_res_company"/>
<field name="state">code</field>
<field name="code">
action = model._action_open_kiosk_mode()
</field>
<field name="groups_id" eval="[(4, ref('hr_attendance.group_hr_attendance_manager'))]"/>
</record>
<!-- Menus -->
<menuitem id="menu_hr_attendance_root" name="Attendances" sequence="205" groups="hr_attendance.group_hr_attendance,hr_attendance.group_hr_attendance_kiosk" web_icon="hr_attendance,static/description/icon.png"/>
<menuitem id="menu_hr_attendance_root" name="Attendances" sequence="205" groups="hr_attendance.group_hr_attendance_officer" web_icon="hr_attendance,static/description/icon.png"/>
<menuitem id="menu_hr_attendance_my_attendances" name="Check In / Check Out" parent="menu_hr_attendance_root" sequence="1" groups="hr_attendance.group_hr_attendance" action="hr_attendance_action_my_attendances"/>
<menuitem id="menu_hr_attendance_kiosk_no_user_mode" name="Kiosk Mode" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_manager" action="open_kiosk_url"/>
<menuitem id="menu_hr_attendance_attendances_overview" name="Attendances" parent="menu_hr_attendance_root" sequence="1" groups="hr_attendance.group_hr_attendance" action="hr_attendance_action_overview"/>
<menuitem id="menu_hr_attendance_reporting" name="Reporting" parent="menu_hr_attendance_root" sequence="15" groups="hr_attendance.group_hr_attendance_officer" action="hr_attendance_reporting"/>
<menuitem id="menu_hr_attendance_kiosk_no_user_mode" name="Kiosk Mode" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_kiosk" action="hr_attendance_action_kiosk_mode"/>
<menuitem id="menu_hr_attendance_view_attendances" name="Attendances" parent="menu_hr_attendance_root" sequence="10" groups="hr_attendance.group_hr_attendance_user" action="hr_attendance_action"/>
<menuitem id="menu_hr_attendance_view_attendances" name="Overview" parent="menu_hr_attendance_root" sequence="5" groups="hr_attendance.group_hr_attendance_officer" action="hr_attendance_action"/>
</odoo>
@@ -7,7 +7,7 @@
<field name="arch" type="xml">
<data>
<xpath expr="//div[hasclass('o_kanban_manage_reports')]" position="inside">
<a role="menuitem" class="dropdown-item" name="%(hr_attendance_report_action_filtered)d" type="action" groups="hr_attendance.group_hr_attendance_user">
<a role="menuitem" class="dropdown-item" name="%(hr_attendance_reporting)d" type="action" groups="hr_attendance.group_hr_attendance_officer">
Attendances
</a>
</xpath>
+52 -20
View File
@@ -8,31 +8,29 @@
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<field name="attendance_state" invisible="1"/>
<field name="hours_last_month" groups="hr_attendance.group_hr_attendance_user" invisible="1"/>
<button name="%(hr_attendance_action)d"
<field name="hours_last_month" groups="hr_attendance.group_hr_attendance_officer" invisible="1"/>
<button name="action_open_last_month_attendances"
class="oe_stat_button"
icon="fa-clock-o"
type="action"
context="{'search_default_employee_id': id, 'search_default_check_in_filter': '1'}"
groups="hr_attendance.group_hr_attendance_user"
type="object"
groups="hr_attendance.group_hr_attendance_officer"
invisible="hours_last_month == 0"
help="Worked hours last month">
help="Worked hours this month">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="hours_last_month_display" widget="float_time"/> Hours
</span>
<span class="o_stat_text">
Last Month
This Month
</span>
</div>
</button>
<button name="%(hr_attendance_overtime_action)d"
<button name="action_open_last_month_overtime"
class="oe_stat_button"
icon="fa-history"
type="action"
type="object"
invisible="total_overtime == 0.0"
context="{'search_default_employee_id': id}"
groups="hr_attendance.group_hr_attendance_user">
groups="hr_attendance.group_hr_attendance_officer">
<div class="o_stat_info">
<span class="o_stat_value text-success" invisible="total_overtime &lt; 0">
<field name="total_overtime" widget="float_time"/>
@@ -44,6 +42,12 @@
</div>
</button>
</xpath>
<xpath expr="//group[@name='managers']" position="inside">
<field name="attendance_manager_id" string="Attendance" widget="many2one_avatar_user"/>
</xpath>
<xpath expr="//group[@name='managers']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
</field>
</record>
@@ -54,30 +58,29 @@
<field name="arch" type="xml">
<xpath expr="//div[@name='button_box']" position="inside">
<field name="employee_ids" invisible="1"/>
<button name="%(hr_attendance_action_employee)d"
<button name="action_open_last_month_attendances"
class="oe_stat_button"
icon="fa-calendar"
type="action"
context="{'search_default_employee_id': employee_ids, 'search_default_check_in_filter': '1'}"
type="object"
groups="base.group_user"
help="Worked hours last month">
help="Worked hours this month">
<div class="o_field_widget o_stat_info">
<span class="o_stat_value">
<field name="hours_last_month_display" widget="float_time"/> Hours
</span>
<span class="o_stat_text">
Last Month
This Month
</span>
</div>
</button>
</xpath>
<xpath expr="//div[@name='button_box']" position="inside">
<button name="%(hr_attendance_overtime_action)d"
<field name="display_extra_hours" invisible="1"/>
<button name="action_open_last_month_overtime"
class="oe_stat_button"
icon="fa-history"
type="action"
invisible="total_overtime == 0.0"
context="{'search_default_employee_id': employee_ids}"
type="object"
invisible="total_overtime == 0.0 or not display_extra_hours"
groups="base.group_user"
help="Amount of extra hours">
<div class="o_stat_info">
@@ -91,6 +94,12 @@
</div>
</button>
</xpath>
<xpath expr="//group[@name='managers']" position="inside">
<field name="attendance_manager_id" string="Attendance" widget="many2one_avatar_user"/>
</xpath>
<xpath expr="//group[@name='managers']" position="attributes">
<attribute name="invisible">0</attribute>
</xpath>
</field>
</record>
@@ -153,4 +162,27 @@
</field>
</record>
<record id="view_employee_tree_inherit_leave" model="ir.ui.view">
<field name="name">hr.employee.tree.leave</field>
<field name="model">hr.employee</field>
<field name="inherit_id" ref="hr.view_employee_tree"/>
<field name="arch" type="xml">
<xpath expr="//field[@name='work_location_id']" position="after">
<field name="attendance_manager_id" optional="hide" string="Attendance" widget="many2one_avatar_user"/>
</xpath>
</field>
</record>
<record id="hr_attendance_employee_simple_tree_view" model="ir.ui.view">
<field name="name">hr.attendance.tree</field>
<field name="model">hr.attendance</field>
<field name="arch" type="xml">
<tree sample="1">
<field name="check_in"/>
<field name="check_out"/>
<field name="worked_hours" string="Work Hours" widget="float_time"/>
</tree>
</field>
</record>
</odoo>
@@ -8,20 +8,32 @@
<field name="arch" type="xml">
<xpath expr="//form" position="inside">
<app data-string="Attendances" string="Attendances" name="hr_attendance" groups="hr_attendance.group_hr_attendance_manager">
<block title="Check-In/Out in Kiosk Mode" name="kiosk_mode_setting_container">
<setting company_dependent="1" help="Define the way the user will be identified by the application.">
<block title="Modes" name="kiosk_mode_setting_container">
<setting string="Kiosk Mode" company_dependent="1" help="Define the way the user will be identified by the application.">
<field name="attendance_kiosk_mode" required="1" class="w-75"/>
</setting>
<setting string="Attendances from Backend" company_dependent="1" help="Allow Users to Check in/out from Odoo.">
<field name="attendance_from_systray" required="1"/>
</setting>
</block>
<block title="Kiosk Settings">
<setting invisible="attendance_kiosk_mode == 'manual'" company_dependent="1" help="Define the camera used for the barcode scan.">
<field name="attendance_barcode_source" required="1"/>
</setting>
<setting string="Display Time" company_dependent="1" help="Choose how long the greeting message will be displayed.">
<field name="attendance_kiosk_delay" required="1" class="text-center" style="width: 10%; min-width: 4rem;"/><span> seconds</span>
</setting>
</block>
<block name="pincode_setting_container">
<setting invisible="attendance_kiosk_mode == 'manual'" company_dependent="1" help="Define the camera used for the barcode scan.">
<field name="attendance_barcode_source" required="1"/>
</setting>
<setting title="Set PIN codes in the employee detail form (in HR Settings tab)." invisible="attendance_kiosk_mode == 'barcode'" help="Use PIN codes (defined on the Employee's profile) to check-in.">
<field name="group_attendance_use_pin"/>
<field name="attendance_kiosk_use_pin"/>
</setting>
<setting title="Kiosk Mode Adress" help="Use this url to access your kiosk mode from any device. Warning, anybody with the link can access your kiosk.">
<field name="attendance_kiosk_url" class="o_hr_kiosk_url_media w-100" style="width:100% !important;" widget="CopyClipboardURL"/>
<br/>
<span>
If your address is compromised, you can refresh it to generate a new one.
</span>
<br/>
<button name="regenerate_kiosk_key" type="object" string="Generate a new Kiosk Mode URL" class="btn-link" icon="fa-refresh"/>
</setting>
</block>
<block title="Extra Hours" name="overtime_settings">
@@ -53,6 +65,9 @@
required="hr_attendance_overtime" style="width: 10%; min-width: 4rem;"/><span> Minutes</span>
</div>
</setting>
<setting title="Display Extra Hours." string="Display Extra Hours" invisible="not hr_attendance_overtime" company_dependent="1" help="Display Extra Hours in Kiosk mode and on User profile.">
<field name="hr_attendance_display_overtime"/>
</setting>
</block>
</app>
</xpath>
@@ -11,7 +11,6 @@ Convert employee's extra hours to leave allocations.
'depends': ['hr_attendance', 'hr_holidays'],
'auto_install': True,
'data': [
'security/hr_holidays_attendance_security.xml',
'views/hr_leave_allocation_views.xml',
'views/hr_leave_type_views.xml',
'views/hr_leave_views.xml',
@@ -12,7 +12,7 @@ from odoo.tools import float_round
class HRLeave(models.Model):
_inherit = 'hr.leave'
overtime_id = fields.Many2one('hr.attendance.overtime', string='Extra Hours', groups='hr_holidays.group_hr_holidays_user')
overtime_id = fields.Many2one('hr.attendance.overtime', string='Extra Hours')
employee_overtime = fields.Float(related='employee_id.total_overtime')
overtime_deductible = fields.Boolean(compute='_compute_overtime_deductible')
@@ -107,7 +107,7 @@ class HRLeave(models.Model):
for d in range((leave.date_to - leave.date_from).days + 1):
employee_dates[leave.employee_id].add(self.env['hr.attendance']._get_day_start_and_day(leave.employee_id, leave.date_from + timedelta(days=d)))
if employee_dates:
self.env['hr.attendance']._update_overtime(employee_dates)
self.env['hr.attendance'].sudo()._update_overtime(employee_dates)
def unlink(self):
# TODO master change to ondelete
@@ -75,7 +75,7 @@ class HolidaysAllocation(models.Model):
datetime_min_time = datetime.min.time()
start_dt = datetime.combine(start_date, datetime_min_time)
end_dt = datetime.combine(end_date, datetime_min_time)
attendances = self.env['hr.attendance'].search([
attendances = self.env['hr.attendance'].sudo().search([
('employee_id', '=', self.employee_id.id),
('check_in', '>=', start_dt),
('check_out', '<=', end_dt),
@@ -1,6 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<odoo>
<record id="hr_holidays.group_hr_holidays_user" model="res.groups">
<field name="implied_ids" eval="[(4, ref('hr_attendance.group_hr_attendance_user'))]"/>
</record>
</odoo>
@@ -21,8 +21,8 @@ class TestHolidaysOvertime(TransactionCase):
'hr_attendance_overtime': True,
'overtime_start_date': datetime(2021, 1, 1),
})
cls.user = new_test_user(cls.env, login='user', groups='base.group_user,hr_attendance.group_hr_attendance', company_id=cls.company.id).with_company(cls.company)
cls.user_manager = new_test_user(cls.env, login='manager', groups='base.group_user,hr_holidays.group_hr_holidays_user', company_id=cls.company.id).with_company(cls.company)
cls.user = new_test_user(cls.env, login='user', groups='base.group_user', company_id=cls.company.id).with_company(cls.company)
cls.user_manager = new_test_user(cls.env, login='manager', groups='base.group_user,hr_holidays.group_hr_holidays_user,hr_attendance.group_hr_attendance_manager', company_id=cls.company.id).with_company(cls.company)
cls.manager = cls.env['hr.employee'].create({
'name': 'Dominique',
@@ -52,7 +52,7 @@ class TestHolidaysOvertime(TransactionCase):
})
def new_attendance(self, check_in, check_out=False):
return self.env['hr.attendance'].create({
return self.env['hr.attendance'].sudo().create({
'employee_id': self.employee.id,
'check_in': check_in,
'check_out': check_out,
@@ -8,6 +8,7 @@
<field name="priority" eval="120"/>
<field name="arch" type="xml">
<xpath expr="//header" position="inside">
<field name="total_overtime" invisible="1"/>
<button
name="%(hr_leave_allocation_overtime_manager_action)d"
string="Deduct Extra Hours"
@@ -9,6 +9,6 @@ class IrUiMenu(models.Model):
def _load_menus_blacklist(self):
res = super()._load_menus_blacklist()
if not (self.env.user.has_group('hr_attendance.group_hr_attendance_user') and self.env.user.has_group('hr_timesheet.group_hr_timesheet_user')):
if not (self.env.user.has_group('hr_timesheet.group_hr_timesheet_user')):
res.append(self.env.ref('hr_timesheet_attendance.menu_hr_timesheet_attendance_report').id)
return res