diff --git a/setup/shift_change/odoo/addons/shift_change b/setup/shift_change/odoo/addons/shift_change new file mode 120000 index 000000000..a0b6a9a45 --- /dev/null +++ b/setup/shift_change/odoo/addons/shift_change @@ -0,0 +1 @@ +../../../../shift_change \ No newline at end of file diff --git a/setup/shift_change/setup.py b/setup/shift_change/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/shift_change/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/setup/shift_change_portal/odoo/addons/shift_change_portal b/setup/shift_change_portal/odoo/addons/shift_change_portal new file mode 120000 index 000000000..ed572697c --- /dev/null +++ b/setup/shift_change_portal/odoo/addons/shift_change_portal @@ -0,0 +1 @@ +../../../../shift_change_portal \ No newline at end of file diff --git a/setup/shift_change_portal/setup.py b/setup/shift_change_portal/setup.py new file mode 100644 index 000000000..28c57bb64 --- /dev/null +++ b/setup/shift_change_portal/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) diff --git a/shift/models/cooperative_status.py b/shift/models/cooperative_status.py index 6de6fc6cd..7dc124807 100644 --- a/shift/models/cooperative_status.py +++ b/shift/models/cooperative_status.py @@ -25,7 +25,7 @@ class HistoryStatus(models.Model): _name = "cooperative.status.history" _description = "cooperative.status.history" - _order = "create_date desc" + _order = "create_date desc, id" status_id = fields.Many2one("cooperative.status") cooperator_id = fields.Many2one("res.partner") diff --git a/shift/models/planning.py b/shift/models/planning.py index eaef8a383..9627fc05b 100644 --- a/shift/models/planning.py +++ b/shift/models/planning.py @@ -40,7 +40,7 @@ class ShiftDayNumber(models.Model): _name = "shift.daynumber" _description = "shift.daynumber" - _order = "number asc" + _order = "number asc, id" name = fields.Char() number = fields.Integer( @@ -55,7 +55,7 @@ class ShiftDayNumber(models.Model): class ShiftPlanning(models.Model): _name = "shift.planning" _description = "shift.planning" - _order = "sequence asc" + _order = "sequence asc, id" sequence = fields.Integer() name = fields.Char() @@ -198,7 +198,7 @@ def get_future_shifts( class ShiftTemplate(models.Model): _name = "shift.template" _description = "shift.template" - _order = "start_time" + _order = "planning_id, task_type_id, start_time, id" name = fields.Char(required=True) planning_id = fields.Many2one("shift.planning", required=True) diff --git a/shift/models/shift_shift.py b/shift/models/shift_shift.py index 9b686f12b..b13fd531c 100644 --- a/shift/models/shift_shift.py +++ b/shift/models/shift_shift.py @@ -11,7 +11,7 @@ class ShiftShift(models.Model): _name = "shift.shift" _description = "shift.shift" _inherit = ["mail.thread"] - _order = "start_time asc" + _order = "start_time asc, task_template_id, task_type_id, worker_id, id" ################################## # Method to override # @@ -143,6 +143,41 @@ def _add_follower(self, vals): worker = self.env["res.partner"].browse(vals["worker_id"]) self.message_subscribe(partner_ids=worker.ids) + @api.model + def _aggregate_sibling_shifts(self, domain): + """Several shifts are siblings because they belong to the same + shfit template, the same day at the same our and for the same + task. + These represent the shift of all the poeple that will work + together. + This function aggregate shifts by task_template, start_time and + task_type for the given domain. + E.g. content of the list: + ((task_template_id, start_time, task_type_id), shifts) + """ + all_shifts = self.env["shift.shift"].search( + domain, + # ensure shift are ordered for groupby + order="task_template_id, start_time, task_type_id", + ) + all_shifts_groupby = itertools.groupby( + all_shifts, + lambda s: (s.task_template_id, s.start_time, s.task_type_id), + ) + aggregated_shifts = [] + for keys, grouped_shifts in all_shifts_groupby: + # Get back a shift recordset + shifts = self + for shift in grouped_shifts: + shifts |= shift + aggregated_shifts.append( + ( + keys, + shifts, + ) + ) + return aggregated_shifts + # TODO button to replace someone @api.model def unsubscribe_from_today( diff --git a/shift_beneficiary/controllers/main.py b/shift_beneficiary/controllers/main.py index 4ac1caa16..ed5a54ca8 100644 --- a/shift_beneficiary/controllers/main.py +++ b/shift_beneficiary/controllers/main.py @@ -25,12 +25,10 @@ def get_selected_beneficiary(self): def available_shift_irregular_worker( self, - irregular_enable_sign_up=False, - nexturl="", + *args, + **kwargs, ): - res = super().available_shift_irregular_worker( - irregular_enable_sign_up, nexturl - ) + res = super().available_shift_irregular_worker(*args, **kwargs) beneficiary_list = ( request.env["res.partner"].sudo().search([("is_beneficiary", "=", True)]) ) diff --git a/shift_change/README.rst b/shift_change/README.rst new file mode 100644 index 000000000..9c93598b2 --- /dev/null +++ b/shift_change/README.rst @@ -0,0 +1,69 @@ +============ +Shift Change +============ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:3bc799017041fe0a23c0121f2311b7f7de77b1ed7d56d502433fc392b3edcfc2 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-beescoop%2FObeesdoo-lightgray.png?logo=github + :target: https://github.com/beescoop/Obeesdoo/tree/16.0/shift_change + :alt: beescoop/Obeesdoo + +|badge1| |badge2| |badge3| + +Let regular worker change their future shifts. + +Changing a shift means unsubscribe from a specific shift and register to +another one. + +Change does not affect subscription to a shift template. + +Changes can be done only between shifts already generated. + +**Table of contents** + +.. contents:: + :local: + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Coop IT Easy SC + +Contributors +~~~~~~~~~~~~ + +* `Coop IT Easy SC `_: + + * Rémy Taymans + +Maintainers +~~~~~~~~~~~ + +This module is part of the `beescoop/Obeesdoo `_ project on GitHub. + +You are welcome to contribute. diff --git a/shift_change/__init__.py b/shift_change/__init__.py new file mode 100644 index 000000000..244ed6933 --- /dev/null +++ b/shift_change/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from . import models +from . import wizard diff --git a/shift_change/__manifest__.py b/shift_change/__manifest__.py new file mode 100644 index 000000000..9e8001e0c --- /dev/null +++ b/shift_change/__manifest__.py @@ -0,0 +1,28 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +{ + "name": "Shift Change", + "summary": """ + Let regular workers change their shift. + """, + "author": "Coop IT Easy SC", + "website": "https://github.com/beescoop/Obeesdoo", + "category": "Cooperative Management", + "version": "16.0.1.0.0", + "depends": [ + "shift", + ], + "data": [ + "data/system_parameter.xml", + "security/ir.model.access.csv", + "views/shift_change.xml", + "views/shift_change_menu.xml", + "views/res_config_setting_view.xml", + "views/res_partner.xml", + "wizard/shift_change_create_wizard.xml", + ], + "demo": [], + "license": "AGPL-3", +} diff --git a/shift_change/data/system_parameter.xml b/shift_change/data/system_parameter.xml new file mode 100644 index 000000000..67ea63936 --- /dev/null +++ b/shift_change/data/system_parameter.xml @@ -0,0 +1,20 @@ + + + + + shift_change.old_shift_hour_limit_change + 24 + + + shift_change.new_shift_hour_limit_change + 24 + + + shift_change.same_shift_change_max + 3 + + diff --git a/shift_change/demo/demo.xml b/shift_change/demo/demo.xml new file mode 100644 index 000000000..05cf75893 --- /dev/null +++ b/shift_change/demo/demo.xml @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + A_TUE-10:00-12:00 + + + + 10.0 + 12.0 + 2 + 5 + + 5 + + + + A_WED-10:00-12:00 + + + + 10.0 + 12.0 + 2 + 5 + + 4 + + + + A_THU-10:00-12:00 + + + + 10.0 + 12.0 + 2 + 1 + + 0 + + + diff --git a/shift_change/migrations/16.0.1.0.0/post-migrate.py b/shift_change/migrations/16.0.1.0.0/post-migrate.py new file mode 100644 index 000000000..5268e60e9 --- /dev/null +++ b/shift_change/migrations/16.0.1.0.0/post-migrate.py @@ -0,0 +1,7 @@ +from openupgradelib import openupgrade + + +@openupgrade.migrate() +def migrate(env, version): + # TODO: + pass diff --git a/shift_change/models/__init__.py b/shift_change/models/__init__.py new file mode 100644 index 000000000..574137d63 --- /dev/null +++ b/shift_change/models/__init__.py @@ -0,0 +1,7 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from . import shift_change +from . import res_config_settings +from . import res_partner diff --git a/shift_change/models/res_config_settings.py b/shift_change/models/res_config_settings.py new file mode 100644 index 000000000..45af5acfc --- /dev/null +++ b/shift_change/models/res_config_settings.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from odoo import fields, models + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + old_shift_hour_limit_change = fields.Integer( + config_parameter="shift_change.hour_limit_change", + ) + new_shift_hour_limit_change = fields.Integer( + config_parameter="shift_change.hour_limit_change", + ) + same_shift_change_max = fields.Integer( + config_parameter="shift_change.same_shift_change_max", + ) diff --git a/shift_change/models/res_partner.py b/shift_change/models/res_partner.py new file mode 100644 index 000000000..0f328ea19 --- /dev/null +++ b/shift_change/models/res_partner.py @@ -0,0 +1,19 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from odoo import _, models + + +class ResPartner(models.Model): + _inherit = "res.partner" + + def button_change_shift(self): + return { + "name": _("Change a shift"), + "type": "ir.actions.act_window", + "view_type": "form", + "view_mode": "form", + "res_model": "shift.change.create.wizard", + "target": "new", + } diff --git a/shift_change/models/shift_change.py b/shift_change/models/shift_change.py new file mode 100644 index 000000000..f8aeefaed --- /dev/null +++ b/shift_change/models/shift_change.py @@ -0,0 +1,209 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import datetime, timedelta + +from odoo import _, api, fields, models +from odoo.exceptions import ValidationError + + +class ShiftChange(models.Model): + _name = "shift.change" + _description = "A model to track a change of a shift" + _order = "create_date desc" + + worker_id = fields.Many2one( + "res.partner", + string="Worker", + domain=[ + ("is_worker", "=", True), + ("working_mode", "in", ("regular", "irregular")), + ("state", "not in", ("unsubscribed", "resigning")), + ], + required=True, + ) + old_shift_id = fields.Many2one("shift.shift", string="Old shift", required=True) + new_shift_id = fields.Many2one( + "shift.shift", + string="New shift", + required=True, + ) + + def name_get(self): + res = [] + for rec in self: + name = "{} - {} -> {}".format( + rec.worker_id.name, + rec.old_shift_id.start_time, + rec.new_shift_id.start_time, + ) + res.append((rec.id, name)) + return res + + @api.model + def _get_old_shift_hour_limit_change(self): + """Return value for old_shift_hour_limit_change parameter""" + try: + old_shift_hour_limit_change = int( + self.env["ir.config_parameter"].get_param( + "shift_change.old_shift_hour_limit_change" + ) + ) + except ValueError: + # fall back to a default value + old_shift_hour_limit_change = 0 + return old_shift_hour_limit_change + + @api.model + def _get_new_shift_hour_limit_change(self): + """Return value for new_shift_hour_limit_change parameter""" + try: + new_shift_hour_limit_change = int( + self.env["ir.config_parameter"].get_param( + "shift_change.new_shift_hour_limit_change" + ) + ) + except ValueError: + # fall back to a default value + new_shift_hour_limit_change = 0 + return new_shift_hour_limit_change + + @api.model + def _get_new_shift_domain(self): + new_shift_hour_limit_change = self._get_new_shift_hour_limit_change() + return [ + ( + "start_time", + ">=", + datetime.now() + timedelta(hours=new_shift_hour_limit_change), + ), + ("state", "=", "open"), + ] + + @api.model + def _get_available_new_shift_ids(self, worker_id): + """List new shifts available for the given worker""" + aggregated_shifts = self.env["shift.shift"]._aggregate_sibling_shifts( + self._get_new_shift_domain(), + ) + available_new_shifts = self.env["shift.shift"] + for _keys, shifts in aggregated_shifts: + is_subscribed = bool( + shifts.filtered(lambda rec: rec.worker_id == worker_id) + ) + if not is_subscribed: + for shift in shifts: + if not shift.worker_id: + # Add first empty shift and exit + available_new_shifts |= shift + break + return available_new_shifts + + @api.model_create_multi + def create(self, vals_list): + """ + Override create() method to unsubscribe the worker + to the old shift and subscribe him/her to the new one + """ + res = super().create(vals_list) + res._subscribe_new_shift() + res._unsubscribe_old_shift() + return res + + def _unsubscribe_old_shift(self): + """ + Unsubscribe self.worker_id from old_shift + Raise error if not possible. + """ + self._check_old_shift(self.old_shift_id, self.worker_id) + self.old_shift_id.write( + { + "worker_id": False, + "is_regular": False, + "is_compensation": False, + } + ) + + def _subscribe_new_shift(self): + """ + Subscribe self.worker_id to the new shift + Raise error if not possible. + """ + self._check_new_shift(self.new_shift_id) + self.new_shift_id.write( + { + "worker_id": self.worker_id.id, + "is_regular": self.old_shift_id.is_regular, + "is_compensation": self.old_shift_id.is_compensation, + } + ) + + @api.model + def _check_old_shift(self, old_shift_id, worker_id): + """Check if old shift can be changed""" + old_shift_hour_limit_change = self._get_old_shift_hour_limit_change() + if not old_shift_id.worker_id or old_shift_id.worker_id != worker_id: + raise ValidationError(_("You can't change shift that your are not worker.")) + if old_shift_id.start_time <= datetime.now(): + raise ValidationError(_("You can't change shift that is in the past.")) + if old_shift_id.start_time <= datetime.now() + timedelta( + hours=old_shift_hour_limit_change + ): + raise ValidationError(_("You can't change a shift so close in the futur.")) + try: + same_shift_change_max = int( + self.env["ir.config_parameter"].get_param( + "shift_change.same_shift_change_max" + ) + ) + except ValueError: + # fall back to a default value + same_shift_change_max = 0 + if same_shift_change_max: + same_shift_change_nb = 0 + tmp_old_shift_id = old_shift_id + previous_changes = self.env["shift.change"] + while tmp_old_shift_id and same_shift_change_nb <= same_shift_change_max: + change = self.search( + [ + ("new_shift_id", "=", tmp_old_shift_id.id), + ("worker_id", "=", worker_id.id), + ("id", "not in", previous_changes.ids), + ], + limit=1, + ) + if change: + same_shift_change_nb += 1 + tmp_old_shift_id = change.old_shift_id + previous_changes |= change + else: + tmp_old_shift_id = None + if same_shift_change_nb >= same_shift_change_max: + raise ValidationError( + _( + "You can't change the same shift more than " + f"{same_shift_change_max} times." + ) + ) + + @api.model + def _check_new_shift(self, new_shift_id): + """Check if shift can be changed or not""" + old_shift_hour_limit_change = self._get_old_shift_hour_limit_change() + if new_shift_id.worker_id: + raise ValidationError( + _("You can't subscribe to a shift assigned to someone else.") + ) + if new_shift_id.start_time <= datetime.now(): + raise ValidationError(_("You can't subscribe to a shift in the past.")) + if new_shift_id.start_time <= datetime.now() + timedelta( + hours=old_shift_hour_limit_change + ): + raise ValidationError( + _("You can't subscribe to a shift so close in the futur.") + ) + if new_shift_id not in self._get_available_new_shift_ids(self.worker_id): + raise ValidationError( + _("You can't subscribe to this shift, it’s not available for you.") + ) diff --git a/shift_change/readme/CONTRIBUTORS.rst b/shift_change/readme/CONTRIBUTORS.rst new file mode 100644 index 000000000..31498d266 --- /dev/null +++ b/shift_change/readme/CONTRIBUTORS.rst @@ -0,0 +1,3 @@ +* `Coop IT Easy SC `_: + + * Rémy Taymans diff --git a/shift_change/readme/DESCRIPTION.rst b/shift_change/readme/DESCRIPTION.rst new file mode 100644 index 000000000..9fef69d51 --- /dev/null +++ b/shift_change/readme/DESCRIPTION.rst @@ -0,0 +1,8 @@ +Let regular worker change their future shifts. + +Changing a shift means unsubscribe from a specific shift and register to +another one. + +Change does not affect subscription to a shift template. + +Changes can be done only between shifts already generated. diff --git a/shift_change/security/ir.model.access.csv b/shift_change/security/ir.model.access.csv new file mode 100644 index 000000000..59fe2548a --- /dev/null +++ b/shift_change/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +user_shift_change,shift_change_user,model_shift_change,shift.group_shift_attendance,1,0,0,0 +manager_shift_change,shift_change_manager,model_shift_change,shift.group_shift_management,1,0,1,0 +user_shift_change_create_wizard,shift_change_create_wizard_user,model_shift_change_create_wizard,shift.group_shift_attendance,1,0,0,0 +manager_shift_change_create_wizard,shift_change_create_wizard_manager,model_shift_change_create_wizard,shift.group_shift_management,1,0,1,0 diff --git a/shift_change/static/description/index.html b/shift_change/static/description/index.html new file mode 100644 index 000000000..8fa47d04b --- /dev/null +++ b/shift_change/static/description/index.html @@ -0,0 +1,423 @@ + + + + + +Shift Change + + + +
+

Shift Change

+ + +

Beta License: AGPL-3 beescoop/Obeesdoo

+

Let regular worker change their future shifts.

+

Changing a shift means unsubscribe from a specific shift and register to +another one.

+

Change does not affect subscription to a shift template.

+

Changes can be done only between shifts already generated.

+

Table of contents

+ +
+

Bug Tracker

+

Bugs are tracked on GitHub Issues. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Coop IT Easy SC
  • +
+
+
+

Contributors

+ +
+
+

Maintainers

+

This module is part of the beescoop/Obeesdoo project on GitHub.

+

You are welcome to contribute.

+
+
+
+ + diff --git a/shift_change/tests/__init__.py b/shift_change/tests/__init__.py new file mode 100644 index 000000000..7d2b2d0d7 --- /dev/null +++ b/shift_change/tests/__init__.py @@ -0,0 +1,6 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from . import test_shift_change_common +from . import test_shift_change diff --git a/shift_change/tests/test_shift_change.py b/shift_change/tests/test_shift_change.py new file mode 100644 index 000000000..c3cc6bdb1 --- /dev/null +++ b/shift_change/tests/test_shift_change.py @@ -0,0 +1,230 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import timedelta + +from psycopg2.errors import IntegrityError + +from odoo.exceptions import AccessError, ValidationError +from odoo.tools import mute_logger + +from .test_shift_change_common import TestShiftChangeCommon + + +class TestShiftChange(TestShiftChangeCommon): + def test_get_available_new_shift_ids(self): + """Test available new shift""" + available_shifts = self.shift_change_model._get_available_new_shift_ids( + self.worker_regular_1 + ) + expected_shifts = self.shift3_d4_nw_t2 | self.shift5_d6_nw_t2 + self.assertEqual( + available_shifts, + expected_shifts, + ) + self.shift1_d2_w1_t1.write({"worker_id": False, "is_regular": False}) + available_shifts = self.shift_change_model._get_available_new_shift_ids( + self.worker_regular_1 + ) + expected_shifts = ( + self.shift1_d2_w1_t1 | self.shift3_d4_nw_t2 | self.shift5_d6_nw_t2 + ) + self.assertEqual( + available_shifts, + expected_shifts, + ) + + def test_shift_change(self): + """Test change a shift""" + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift3_d4_nw_t2.id, + } + ) + self.assertFalse(self.shift1_d2_w1_t1.worker_id) + self.assertEqual(self.shift3_d4_nw_t2.worker_id, self.worker_regular_1) + + def test_shift_change_max(self): + """Test change a shift several times""" + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) + self.assertFalse(self.shift5_d6_nw_t2.worker_id) + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift3_d4_nw_t2.id, + } + ) + self.assertFalse(self.shift1_d2_w1_t1.worker_id) + self.assertEqual(self.shift3_d4_nw_t2.worker_id, self.worker_regular_1) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift3_d4_nw_t2.id, + "new_shift_id": self.shift5_d6_nw_t2.id, + } + ) + + def test_shift_change_not_empty(self): + """Test changing a shift to a non empty shift""" + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertEqual(self.shift2_d2_w2_t2.worker_id, self.worker_regular_2) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift2_d2_w2_t2.id, + } + ) + self.assertFalse(self.shift_change_model.search([])) + + def test_shift_change_in_past(self): + """Test changing for a shift in the past""" + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift4_past_nw_t2.worker_id) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift4_past_nw_t2.id, + } + ) + + def test_shift_change_wrong_worker(self): + """Test creating a change with worker that don't match the shift + worker + """ + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_2.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift4_past_nw_t2.id, + } + ) + + def test_shift_change_missing_required_fields(self): + """Test creating a shift with missing fields""" + with self.assertRaises(IntegrityError): + with mute_logger("odoo.sql_db"): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + } + ) + + def test_shift_change_writing(self): + """Test that writing to a shift fails""" + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) + shift_change = self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift3_d4_nw_t2.id, + } + ) + with self.assertRaises(AccessError): + shift_change.write( + { + "new_shift_id": self.shift4_past_nw_t2.id, + } + ) + + def test_shift_change_to_close(self): + """Test changing to a shift to close""" + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift6_h1_nw_t2.worker_id) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift6_h1_nw_t2.id, + } + ) + + def test_shift_origin_empty(self): + """Test that fails if old_shift is empty""" + self.assertFalse(self.shift4_past_nw_t2.worker_id) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift4_past_nw_t2.id, + "new_shift_id": self.shift3_d4_nw_t2.id, + } + ) + + def test_new_shift_not_available(self): + """Test that fails if worker has already subscribed to a sibling + of new_shift""" + # create siblings + self.shift_7 = self.shift_model.create( + { + "name": "shift_7", + "task_template_id": self.task_template_1.id, + "start_time": self.now + timedelta(days=4), + "end_time": self.now + timedelta(days=4), + "is_regular": True, + "worker_id": self.worker_regular_1.id, + } + ) + self.shift_7_bis = self.shift_model.create( + { + "name": "shift_7_bis", + "task_template_id": self.task_template_1.id, + "start_time": self.now + timedelta(days=4), + "end_time": self.now + timedelta(days=4), + "worker_id": False, + } + ) + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift_7_bis.worker_id) + with self.assertRaises(ValidationError): + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift_7_bis.id, + } + ) + + def test_shift_change_loop(self): + """Test change a shift""" + # Set maximum change shift for testing + self.env["ir.config_parameter"].set_param( + "shift_change.same_shift_change_max", 3 + ) + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift1_d2_w1_t1.id, + "new_shift_id": self.shift3_d4_nw_t2.id, + } + ) + self.assertFalse(self.shift1_d2_w1_t1.worker_id) + self.assertEqual(self.shift3_d4_nw_t2.worker_id, self.worker_regular_1) + self.shift_change_model.create( + { + "worker_id": self.worker_regular_1.id, + "old_shift_id": self.shift3_d4_nw_t2.id, + "new_shift_id": self.shift1_d2_w1_t1.id, + } + ) + self.assertEqual(self.shift1_d2_w1_t1.worker_id, self.worker_regular_1) + self.assertFalse(self.shift3_d4_nw_t2.worker_id) diff --git a/shift_change/tests/test_shift_change_common.py b/shift_change/tests/test_shift_change_common.py new file mode 100644 index 000000000..2ba2a3b11 --- /dev/null +++ b/shift_change/tests/test_shift_change_common.py @@ -0,0 +1,102 @@ +# SPDX-FileCopyrightText: 2026 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import date, datetime, timedelta + +from odoo.tests.common import TransactionCase + + +class TestShiftChangeCommon(TransactionCase): + def setUp(self): + super().setUp() + + # Force all operations to run as admin + self.env = self.env(user=self.env.ref("base.user_admin")) + + self.shift_model = self.env["shift.shift"] + self.shift_template_model = self.env["shift.template"] + self.shift_change_model = self.env["shift.change"] + + self.now = datetime.now() + + self.worker_regular_1 = self.env.ref("shift.res_partner_worker_1_demo") + self.worker_regular_2 = self.env.ref("shift.res_partner_worker_3_demo") + self.worker_irregular_1 = self.env.ref("shift.res_partner_worker_2_demo") + + self.task_template_1 = self.env.ref("shift.task_template_1_demo") + self.task_template_2 = self.env.ref("shift.task_template_2_demo") + self.task_template_3 = self.env.ref("shift.task_template_3_demo") + + self.shift1_d2_w1_t1 = self.shift_model.create( + { + "name": "shift1_d2_w1_t1", + "task_template_id": self.task_template_1.id, + "start_time": self.now + timedelta(days=2), + "end_time": self.now + timedelta(days=2), + "is_regular": True, + "worker_id": self.worker_regular_1.id, + } + ) + self.shift2_d2_w2_t2 = self.shift_model.create( + { + "name": "shift2_d2_w2_t2", + "task_template_id": self.task_template_2.id, + "start_time": self.now + timedelta(days=2), + "end_time": self.now + timedelta(days=2), + "is_regular": True, + "worker_id": self.worker_regular_2.id, + } + ) + self.shift3_d4_nw_t2 = self.shift_model.create( + { + "name": "shift3_d4_nw_t2", + "task_template_id": self.task_template_2.id, + "start_time": self.now + timedelta(days=4), + "end_time": self.now + timedelta(days=4), + "worker_id": False, + } + ) + self.shift4_past_nw_t2 = self.shift_model.create( + { + "name": "shift4_past_nw_t2", + "task_template_id": self.task_template_2.id, + "start_time": self.now - timedelta(days=4), + "end_time": self.now, + "worker_id": False, + } + ) + self.shift5_d6_nw_t2 = self.shift_model.create( + { + "name": "shift5_d6_nw_t2", + "task_template_id": self.task_template_2.id, + "start_time": self.now + timedelta(days=6), + "end_time": self.now, + "worker_id": False, + } + ) + self.shift6_h1_nw_t2 = self.shift_model.create( + { + "name": "shift6_h1_nw_t2", + "task_template_id": self.task_template_2.id, + "start_time": self.now + timedelta(hours=1), + "end_time": self.now, + "worker_id": False, + } + ) + + # Set context to avoid shift generation in the past + self.env.context = dict(self.env.context, visualize_date=date.today()) + + # Set maximum change shift for testing + self.env["ir.config_parameter"].set_param( + "shift_change.same_shift_change_max", 1 + ) + # Old shift hour limit for testing + self.env["ir.config_parameter"].set_param( + "shift_change.old_shift_hour_limit_change", 24 + ) + # New shift hour limit for testing + self.env["ir.config_parameter"].set_param( + "shift_change.new_shift_hour_limit_change", 24 + ) diff --git a/shift_change/views/res_config_setting_view.xml b/shift_change/views/res_config_setting_view.xml new file mode 100644 index 000000000..0c4bae875 --- /dev/null +++ b/shift_change/views/res_config_setting_view.xml @@ -0,0 +1,97 @@ + + + + + + Shift Changes Settings + res.config.settings + + + + +
+

Shift Changes

+
+ +
+
+ + Time limit to change a shift + +
+ Number of hours above which a shift cannot be changed +
+
+
+
+
+ + + Time limit to take a new a shift + +
+ Number of hours above which a new shift cannot be taken +
+
+
+
+
+
+
+ +
+
+ + Maximum changes for the same shift + +
+ Number of time the same shift can be changed +
+
+
+
+
+
+
+ +
+
+ +
+
+ +
diff --git a/shift_change/views/res_partner.xml b/shift_change/views/res_partner.xml new file mode 100644 index 000000000..b5f1d764a --- /dev/null +++ b/shift_change/views/res_partner.xml @@ -0,0 +1,33 @@ + + + + + + Contact: Change Shift Button + res.partner + + + +