summaryrefslogtreecommitdiff
path: root/addons/hr_work_entry/models
diff options
context:
space:
mode:
Diffstat (limited to 'addons/hr_work_entry/models')
-rw-r--r--addons/hr_work_entry/models/__init__.py5
-rw-r--r--addons/hr_work_entry/models/hr_work_entry.py213
-rw-r--r--addons/hr_work_entry/models/resource.py29
3 files changed, 247 insertions, 0 deletions
diff --git a/addons/hr_work_entry/models/__init__.py b/addons/hr_work_entry/models/__init__.py
new file mode 100644
index 00000000..7fe98a79
--- /dev/null
+++ b/addons/hr_work_entry/models/__init__.py
@@ -0,0 +1,5 @@
+# -*- coding:utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from . import hr_work_entry
+from . import resource
diff --git a/addons/hr_work_entry/models/hr_work_entry.py b/addons/hr_work_entry/models/hr_work_entry.py
new file mode 100644
index 00000000..9db54e70
--- /dev/null
+++ b/addons/hr_work_entry/models/hr_work_entry.py
@@ -0,0 +1,213 @@
+# -*- coding: utf-8 -*-
+# Part of Odoo. See LICENSE file for full copyright and licensing details.
+
+from contextlib import contextmanager
+from dateutil.relativedelta import relativedelta
+from psycopg2 import OperationalError
+
+from odoo import api, fields, models
+
+
+class HrWorkEntry(models.Model):
+ _name = 'hr.work.entry'
+ _description = 'HR Work Entry'
+ _order = 'conflict desc,state,date_start'
+
+ name = fields.Char(required=True)
+ active = fields.Boolean(default=True)
+ employee_id = fields.Many2one('hr.employee', required=True, domain="['|', ('company_id', '=', False), ('company_id', '=', company_id)]")
+ 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="Period")
+ work_entry_type_id = fields.Many2one('hr.work.entry.type', index=True)
+ 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
+
+ _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.')
+ ]
+
+ @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):
+ for work_entry in self:
+ work_entry.duration = work_entry._get_duration(work_entry.date_start, work_entry.date_stop)
+
+ @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(self, date_start, date_stop):
+ if not date_start or not date_stop:
+ return 0
+ dt = date_stop - date_start
+ return dt.days * 24 + dt.seconds / 3600 # Number of hours
+
+ 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
+
+ @api.model
+ def _mark_conflicting_work_entries(self, start, stop):
+ """
+ Set `state` to `conflict` for overlapping work entries
+ between two dates.
+ 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(['date_start', 'date_stop', 'employee_id', 'active'])
+ query = """
+ SELECT b1.id
+ FROM hr_work_entry b1
+ WHERE
+ b1.date_start <= %s
+ AND b1.date_stop >= %s
+ AND active = TRUE
+ AND EXISTS (
+ SELECT 1
+ FROM hr_work_entry b2
+ WHERE
+ b2.date_start <= %s
+ AND b2.date_stop >= %s
+ AND active = TRUE
+ AND tsrange(b1.date_start, b1.date_stop, '()') && tsrange(b2.date_start, b2.date_stop, '()')
+ AND b1.id <> b2.id
+ AND b1.employee_id = b2.employee_id
+ );
+ """
+ self.env.cr.execute(query, (stop, start, stop, start))
+ conflicts = [res.get('id') for res in self.env.cr.dictfetchall()]
+ self.browse(conflicts).write({
+ 'state': 'conflict',
+ })
+ return bool(conflicts)
+
+ @api.model_create_multi
+ def create(self, vals_list):
+ 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'
+
+ with self._error_checking(skip=skip_check):
+ return super(HrWorkEntry, self).write(vals)
+
+ def unlink(self):
+ with self._error_checking():
+ 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):
+ """
+ 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:
+ work_entries = self.sudo().with_context(hr_work_entry_no_check=True).search([
+ ('date_start', '<', stop),
+ ('date_stop', '>', start),
+ ('state', 'not in', ('validated', 'cancelled')),
+ ])
+ 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)
+ 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.')
+ ]
diff --git a/addons/hr_work_entry/models/resource.py b/addons/hr_work_entry/models/resource.py
new file mode 100644
index 00000000..ac7fa6c9
--- /dev/null
+++ b/addons/hr_work_entry/models/resource.py
@@ -0,0 +1,29 @@
+# -*- 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)
+
+ 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')
+
+ def _copy_leave_vals(self):
+ res = super()._copy_leave_vals()
+ res['work_entry_type_id'] = self.work_entry_type_id.id
+ return res