mirror of
https://github.com/bringout/oca-ocb-hr.git
synced 2026-04-25 09:12:01 +02:00
Initial commit: Hr packages
This commit is contained in:
commit
62531cd146
2820 changed files with 1432848 additions and 0 deletions
|
|
@ -0,0 +1,6 @@
|
|||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from . import hr_work_entry
|
||||
from . import resource
|
||||
from . import hr_employee
|
||||
|
|
@ -0,0 +1,21 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, _
|
||||
|
||||
class HrEmployee(models.Model):
|
||||
_inherit = 'hr.employee'
|
||||
|
||||
def action_open_work_entries(self, initial_date=False):
|
||||
self.ensure_one()
|
||||
ctx = {'default_employee_id': self.id}
|
||||
if initial_date:
|
||||
ctx['initial_date'] = initial_date
|
||||
return {
|
||||
'type': 'ir.actions.act_window',
|
||||
'name': _('%s work entries', self.display_name),
|
||||
'view_mode': 'calendar,tree,form',
|
||||
'res_model': 'hr.work.entry',
|
||||
'context': ctx,
|
||||
'domain': [('employee_id', '=', self.id)],
|
||||
}
|
||||
|
|
@ -0,0 +1,276 @@
|
|||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
import itertools
|
||||
from collections import defaultdict
|
||||
from contextlib import contextmanager
|
||||
|
||||
from dateutil.relativedelta import relativedelta
|
||||
from psycopg2 import OperationalError
|
||||
|
||||
from odoo import _, api, fields, models, tools
|
||||
from odoo.osv import expression
|
||||
|
||||
|
||||
class HrWorkEntry(models.Model):
|
||||
_name = 'hr.work.entry'
|
||||
_description = 'HR Work Entry'
|
||||
_order = 'conflict desc,state,date_start'
|
||||
|
||||
name = fields.Char(required=True, compute='_compute_name', store=True, readonly=False)
|
||||
active = fields.Boolean(default=True)
|
||||
employee_id = fields.Many2one('hr.employee', required=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]", index=True)
|
||||
date_start = fields.Datetime(required=True, string='From')
|
||||
date_stop = fields.Datetime(compute='_compute_date_stop', store=True, readonly=False, string='To')
|
||||
duration = fields.Float(compute='_compute_duration', store=True, string="Duration", readonly=False)
|
||||
work_entry_type_id = fields.Many2one('hr.work.entry.type', index=True, default=lambda self: self.env['hr.work.entry.type'].search([], limit=1))
|
||||
color = fields.Integer(related='work_entry_type_id.color', readonly=True)
|
||||
state = fields.Selection([
|
||||
('draft', 'Draft'),
|
||||
('validated', 'Validated'),
|
||||
('conflict', 'Conflict'),
|
||||
('cancelled', 'Cancelled')
|
||||
], default='draft')
|
||||
company_id = fields.Many2one('res.company', string='Company', readonly=True, required=True,
|
||||
default=lambda self: self.env.company)
|
||||
conflict = fields.Boolean('Conflicts', compute='_compute_conflict', store=True) # Used to show conflicting work entries first
|
||||
department_id = fields.Many2one('hr.department', related='employee_id.department_id', store=True)
|
||||
|
||||
# There is no way for _error_checking() to detect conflicts in work
|
||||
# entries that have been introduced in concurrent transactions, because of the transaction
|
||||
# isolation.
|
||||
# So if 2 transactions create work entries in parallel it is possible to create a conflict
|
||||
# that will not be visible by either transaction. There is no way to detect conflicts
|
||||
# between different records in a safe manner unless a SQL constraint is used, e.g. via
|
||||
# an EXCLUSION constraint [1]. This (obscure) type of constraint allows comparing 2 rows
|
||||
# using special operator classes and it also supports partial WHERE clauses. Similarly to
|
||||
# CHECK constraints, it's backed by an index.
|
||||
# 1: https://www.postgresql.org/docs/9.6/sql-createtable.html#SQL-CREATETABLE-EXCLUDE
|
||||
_sql_constraints = [
|
||||
('_work_entry_has_end', 'check (date_stop IS NOT NULL)', 'Work entry must end. Please define an end date or a duration.'),
|
||||
('_work_entry_start_before_end', 'check (date_stop > date_start)', 'Starting time should be before end time.'),
|
||||
(
|
||||
'_work_entries_no_validated_conflict',
|
||||
"""
|
||||
EXCLUDE USING GIST (
|
||||
tsrange(date_start, date_stop, '()') WITH &&,
|
||||
int4range(employee_id, employee_id, '[]') WITH =
|
||||
)
|
||||
WHERE (state = 'validated' AND active = TRUE)
|
||||
""",
|
||||
'Validated work entries cannot overlap'
|
||||
),
|
||||
]
|
||||
|
||||
def init(self):
|
||||
tools.create_index(self._cr, "hr_work_entry_date_start_date_stop_index", self._table, ["date_start", "date_stop"])
|
||||
|
||||
@api.depends('work_entry_type_id', 'employee_id')
|
||||
def _compute_name(self):
|
||||
for work_entry in self:
|
||||
if not work_entry.employee_id:
|
||||
work_entry.name = _('Undefined')
|
||||
else:
|
||||
work_entry.name = "%s: %s" % (work_entry.work_entry_type_id.name or _('Undefined Type'), work_entry.employee_id.name)
|
||||
|
||||
@api.depends('state')
|
||||
def _compute_conflict(self):
|
||||
for rec in self:
|
||||
rec.conflict = rec.state == 'conflict'
|
||||
|
||||
@api.depends('date_stop', 'date_start')
|
||||
def _compute_duration(self):
|
||||
durations = self._get_duration_batch()
|
||||
for work_entry in self:
|
||||
work_entry.duration = durations[work_entry.id]
|
||||
|
||||
@api.depends('date_start', 'duration')
|
||||
def _compute_date_stop(self):
|
||||
for work_entry in self.filtered(lambda w: w.date_start and w.duration):
|
||||
work_entry.date_stop = work_entry.date_start + relativedelta(hours=work_entry.duration)
|
||||
|
||||
def _get_duration_batch(self):
|
||||
result = {}
|
||||
cached_periods = defaultdict(float)
|
||||
for work_entry in self:
|
||||
date_start = work_entry.date_start
|
||||
date_stop = work_entry.date_stop
|
||||
if not date_start or not date_stop:
|
||||
result[work_entry.id] = 0.0
|
||||
continue
|
||||
if (date_start, date_stop) in cached_periods:
|
||||
result[work_entry.id] = cached_periods[(date_start, date_stop)]
|
||||
else:
|
||||
dt = date_stop - date_start
|
||||
duration = dt.days * 24 + dt.seconds / 3600 # Number of hours
|
||||
cached_periods[(date_start, date_stop)] = duration
|
||||
result[work_entry.id] = duration
|
||||
return result
|
||||
|
||||
# YTI TODO: Remove me in master: Deprecated, use _get_duration_batch instead
|
||||
def _get_duration(self, date_start, date_stop):
|
||||
return self._get_duration_batch()[self.id]
|
||||
|
||||
def action_validate(self):
|
||||
"""
|
||||
Try to validate work entries.
|
||||
If some errors are found, set `state` to conflict for conflicting work entries
|
||||
and validation fails.
|
||||
:return: True if validation succeded
|
||||
"""
|
||||
work_entries = self.filtered(lambda work_entry: work_entry.state != 'validated')
|
||||
if not work_entries._check_if_error():
|
||||
work_entries.write({'state': 'validated'})
|
||||
return True
|
||||
return False
|
||||
|
||||
def _check_if_error(self):
|
||||
if not self:
|
||||
return False
|
||||
undefined_type = self.filtered(lambda b: not b.work_entry_type_id)
|
||||
undefined_type.write({'state': 'conflict'})
|
||||
conflict = self._mark_conflicting_work_entries(min(self.mapped('date_start')), max(self.mapped('date_stop')))
|
||||
return undefined_type or conflict
|
||||
|
||||
def _mark_conflicting_work_entries(self, start, stop):
|
||||
"""
|
||||
Set `state` to `conflict` for overlapping work entries
|
||||
between two dates.
|
||||
If `self.ids` is truthy then check conflicts with the corresponding work entries.
|
||||
Return True if overlapping work entries were detected.
|
||||
"""
|
||||
# Use the postgresql range type `tsrange` which is a range of timestamp
|
||||
# It supports the intersection operator (&&) useful to detect overlap.
|
||||
# use '()' to exlude the lower and upper bounds of the range.
|
||||
# Filter on date_start and date_stop (both indexed) in the EXISTS clause to
|
||||
# limit the resulting set size and fasten the query.
|
||||
self.flush_model(['date_start', 'date_stop', 'employee_id', 'active'])
|
||||
query = """
|
||||
SELECT b1.id,
|
||||
b2.id
|
||||
FROM hr_work_entry b1
|
||||
JOIN hr_work_entry b2
|
||||
ON b1.employee_id = b2.employee_id
|
||||
AND b1.id <> b2.id
|
||||
WHERE b1.date_start <= %(stop)s
|
||||
AND b1.date_stop >= %(start)s
|
||||
AND b1.active = TRUE
|
||||
AND b2.active = TRUE
|
||||
AND tsrange(b1.date_start, b1.date_stop, '()') && tsrange(b2.date_start, b2.date_stop, '()')
|
||||
AND {}
|
||||
""".format("b2.id IN %(ids)s" if self.ids else "b2.date_start <= %(stop)s AND b2.date_stop >= %(start)s")
|
||||
self.env.cr.execute(query, {"stop": stop, "start": start, "ids": tuple(self.ids)})
|
||||
conflicts = set(itertools.chain.from_iterable(self.env.cr.fetchall()))
|
||||
self.browse(conflicts).write({
|
||||
'state': 'conflict',
|
||||
})
|
||||
return bool(conflicts)
|
||||
|
||||
@api.model_create_multi
|
||||
def create(self, vals_list):
|
||||
company_by_employee_id = {}
|
||||
for vals in vals_list:
|
||||
if vals.get('company_id'):
|
||||
continue
|
||||
if vals['employee_id'] not in company_by_employee_id:
|
||||
employee = self.env['hr.employee'].browse(vals['employee_id'])
|
||||
company_by_employee_id[employee.id] = employee.company_id.id
|
||||
vals['company_id'] = company_by_employee_id[vals['employee_id']]
|
||||
work_entries = super().create(vals_list)
|
||||
work_entries._check_if_error()
|
||||
return work_entries
|
||||
|
||||
def write(self, vals):
|
||||
skip_check = not bool({'date_start', 'date_stop', 'employee_id', 'work_entry_type_id', 'active'} & vals.keys())
|
||||
if 'state' in vals:
|
||||
if vals['state'] == 'draft':
|
||||
vals['active'] = True
|
||||
elif vals['state'] == 'cancelled':
|
||||
vals['active'] = False
|
||||
skip_check &= all(self.mapped(lambda w: w.state != 'conflict'))
|
||||
|
||||
if 'active' in vals:
|
||||
vals['state'] = 'draft' if vals['active'] else 'cancelled'
|
||||
|
||||
employee_ids = self.employee_id.ids
|
||||
if 'employee_id' in vals and vals['employee_id']:
|
||||
employee_ids += [vals['employee_id']]
|
||||
with self._error_checking(skip=skip_check, employee_ids=employee_ids):
|
||||
return super(HrWorkEntry, self).write(vals)
|
||||
|
||||
def unlink(self):
|
||||
employee_ids = self.employee_id.ids
|
||||
with self._error_checking(employee_ids=employee_ids):
|
||||
return super().unlink()
|
||||
|
||||
def _reset_conflicting_state(self):
|
||||
self.filtered(lambda w: w.state == 'conflict').write({'state': 'draft'})
|
||||
|
||||
@contextmanager
|
||||
def _error_checking(self, start=None, stop=None, skip=False, employee_ids=False):
|
||||
"""
|
||||
Context manager used for conflicts checking.
|
||||
When exiting the context manager, conflicts are checked
|
||||
for all work entries within a date range. By default, the start and end dates are
|
||||
computed according to `self` (min and max respectively) but it can be overwritten by providing
|
||||
other values as parameter.
|
||||
:param start: datetime to overwrite the default behaviour
|
||||
:param stop: datetime to overwrite the default behaviour
|
||||
:param skip: If True, no error checking is done
|
||||
"""
|
||||
try:
|
||||
skip = skip or self.env.context.get('hr_work_entry_no_check', False)
|
||||
start = start or min(self.mapped('date_start'), default=False)
|
||||
stop = stop or max(self.mapped('date_stop'), default=False)
|
||||
if not skip and start and stop:
|
||||
domain = [
|
||||
('date_start', '<', stop),
|
||||
('date_stop', '>', start),
|
||||
('state', 'not in', ('validated', 'cancelled')),
|
||||
]
|
||||
if employee_ids:
|
||||
domain = expression.AND([domain, [('employee_id', 'in', list(employee_ids))]])
|
||||
work_entries = self.sudo().with_context(hr_work_entry_no_check=True).search(domain)
|
||||
work_entries._reset_conflicting_state()
|
||||
yield
|
||||
except OperationalError:
|
||||
# the cursor is dead, do not attempt to use it or we will shadow the root exception
|
||||
# with a "psycopg2.InternalError: current transaction is aborted, ..."
|
||||
skip = True
|
||||
raise
|
||||
finally:
|
||||
if not skip and start and stop:
|
||||
# New work entries are handled in the create method,
|
||||
# no need to reload work entries.
|
||||
work_entries.exists()._check_if_error()
|
||||
|
||||
|
||||
class HrWorkEntryType(models.Model):
|
||||
_name = 'hr.work.entry.type'
|
||||
_description = 'HR Work Entry Type'
|
||||
|
||||
name = fields.Char(required=True, translate=True)
|
||||
code = fields.Char(required=True, help="Careful, the Code is used in many references, changing it could lead to unwanted changes.")
|
||||
color = fields.Integer(default=0)
|
||||
sequence = fields.Integer(default=25)
|
||||
active = fields.Boolean(
|
||||
'Active', default=True,
|
||||
help="If the active field is set to false, it will allow you to hide the work entry type without removing it.")
|
||||
|
||||
_sql_constraints = [
|
||||
('unique_work_entry_code', 'UNIQUE(code)', 'The same code cannot be associated to multiple work entry types.'),
|
||||
]
|
||||
|
||||
|
||||
class Contacts(models.Model):
|
||||
""" Personnal calendar filter """
|
||||
|
||||
_name = 'hr.user.work.entry.employee'
|
||||
_description = 'Work Entries Employees'
|
||||
|
||||
user_id = fields.Many2one('res.users', 'Me', required=True, default=lambda self: self.env.user)
|
||||
employee_id = fields.Many2one('hr.employee', 'Employee', required=True)
|
||||
active = fields.Boolean('Active', default=True)
|
||||
|
||||
_sql_constraints = [
|
||||
('user_id_employee_id_unique', 'UNIQUE(user_id,employee_id)', 'You cannot have the same employee twice.')
|
||||
]
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
# -*- coding:utf-8 -*-
|
||||
# Part of Odoo. See LICENSE file for full copyright and licensing details.
|
||||
|
||||
from odoo import models, fields
|
||||
|
||||
|
||||
class ResourceCalendarAttendance(models.Model):
|
||||
_inherit = 'resource.calendar.attendance'
|
||||
|
||||
def _default_work_entry_type_id(self):
|
||||
return self.env.ref('hr_work_entry.work_entry_type_attendance', raise_if_not_found=False)
|
||||
|
||||
work_entry_type_id = fields.Many2one(
|
||||
'hr.work.entry.type', 'Work Entry Type', default=_default_work_entry_type_id,
|
||||
groups="hr.group_hr_user")
|
||||
|
||||
def _copy_attendance_vals(self):
|
||||
res = super()._copy_attendance_vals()
|
||||
res['work_entry_type_id'] = self.work_entry_type_id.id
|
||||
return res
|
||||
|
||||
|
||||
class ResourceCalendarLeave(models.Model):
|
||||
_inherit = 'resource.calendar.leaves'
|
||||
|
||||
work_entry_type_id = fields.Many2one(
|
||||
'hr.work.entry.type', 'Work Entry Type',
|
||||
groups="hr.group_hr_user")
|
||||
|
||||
def _copy_leave_vals(self):
|
||||
res = super()._copy_leave_vals()
|
||||
res['work_entry_type_id'] = self.work_entry_type_id.id
|
||||
return res
|
||||
Loading…
Add table
Add a link
Reference in a new issue