diff --git a/volunteer/__init__.py b/volunteer/__init__.py index a6c9053e3..11a4be221 100644 --- a/volunteer/__init__.py +++ b/volunteer/__init__.py @@ -3,3 +3,4 @@ # SPDX-License-Identifier: AGPL-3.0-or-later from . import models +from . import wizards diff --git a/volunteer/__manifest__.py b/volunteer/__manifest__.py index dcd3129bb..f05676b13 100644 --- a/volunteer/__manifest__.py +++ b/volunteer/__manifest__.py @@ -21,14 +21,17 @@ "views/volunteer_volunteer_views.xml", "views/volunteer_volunteer_kanban_views.xml", "views/volunteer_shift_views.xml", + "views/volunteer_shift_generator_views.xml", "views/volunteer_shift_kanban_views.xml", "views/volunteer_shift_participation_views.xml", + "views/volunteer_shift_subscription_views.xml", "views/volunteer_shift_category_views.xml", "views/volunteer_shift_type_views.xml", "views/volunteer_shift_tag_views.xml", "views/volunteer_skill_view.xml", "views/volunteer_skill_category_view.xml", "views/volunteer_menu.xml", + "views/res_config_settings_views.xml", ], "demo": [ "demo/volunteer_shift_category_demo.xml", diff --git a/volunteer/models/__init__.py b/volunteer/models/__init__.py index 8248ecc68..fb50690e8 100644 --- a/volunteer/models/__init__.py +++ b/volunteer/models/__init__.py @@ -2,6 +2,7 @@ # # SPDX-License-Identifier: AGPL-3.0-or-later +from . import volunteer_shift_mixin from . import volunteer_shift from . import volunteer_shift_category from . import volunteer_shift_type @@ -12,3 +13,6 @@ from . import volunteer_skill from . import volunteer_skill_category from . import res_partner +from . import volunteer_shift_recurrent_generator +from . import volunteer_shift_recurrent_subscription +from . import res_company diff --git a/volunteer/models/res_company.py b/volunteer/models/res_company.py new file mode 100644 index 000000000..10caf2a6c --- /dev/null +++ b/volunteer/models/res_company.py @@ -0,0 +1,22 @@ +# SPDX-FileCopyrightText: 2025 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from odoo import fields, models + + +class ResCompany(models.Model): + _inherit = ["res.company"] + + shift_nb_occurrence = fields.Integer( + string="Number of shift occurrences", + default=10, + ) + + _sql_constraints = [ + ( + "nb_occurrence_is_pos", + "check (shift_nb_occurrence > 0)", + "The number of occurrence cannot be null or negative.", + ), + ] diff --git a/volunteer/models/volunteer_shift.py b/volunteer/models/volunteer_shift.py index 30633d00c..d61cb29dd 100644 --- a/volunteer/models/volunteer_shift.py +++ b/volunteer/models/volunteer_shift.py @@ -4,25 +4,19 @@ from odoo import api, fields, models from odoo.exceptions import AccessError, ValidationError -from odoo.tools import format_datetime from odoo.tools.translate import _ -from odoo.addons.base.models.res_partner import _tz_get - class VolunteerShift(models.Model): _name = "volunteer.shift" _description = "Shift" - _inherit = ["mail.thread", "mail.activity.mixin"] + _inherit = ["volunteer.shift.mixin", "mail.thread", "mail.activity.mixin"] # General fields - name = fields.Char(required=True, tracking=True) - max_volunteer_nb = fields.Integer( - string="Max Volunteer", required=True, tracking=True + remaining_slots = fields.Integer( + compute="_compute_remaining_slots", store=True, tracking=True ) - remaining_slots = fields.Integer(compute="_compute_remaining_slots", tracking=True) - is_one_day = fields.Boolean(compute="_compute_is_one_day") # Stage fields @@ -39,55 +33,17 @@ def _default_stage_id(self): ) state = fields.Selection(related="stage_id.state", store=True) - # Date fields - - tz = fields.Selection( - selection=_tz_get, - string="Timezone", - default=lambda self: self.env.user.tz or "UTC", - required=True, - tracking=True, - ) - start_time = fields.Datetime( - default=fields.Datetime.now(), required=True, tracking=True - ) - start_time_located = fields.Char(compute="_compute_start_time_located") - end_time = fields.Datetime( - default=fields.Datetime.now(), required=True, tracking=True - ) - end_time_located = fields.Char(compute="_compute_end_time_located") - - # Classification fields - - type_id = fields.Many2one( - comodel_name="volunteer.shift.type", string="Type", required=True, tracking=True - ) - category_id = fields.Many2one( - comodel_name="volunteer.shift.category", string="Category", tracking=True - ) - tag_ids = fields.Many2many( - comodel_name="volunteer.shift.tag", string="Tags", tracking=True - ) - # Relational fields - company_id = fields.Many2one( - comodel_name="res.company", - string="Company", - default=lambda self: self.env.user.company_id, - required=True, - tracking=True, - ) volunteer_participation_ids = fields.One2many( comodel_name="volunteer.shift.participation", inverse_name="shift_id", string="Participation", tracking=True, ) - coordinator_id = fields.Many2one( - comodel_name="res.partner", - domain="[('is_company', '=', False)]", - string="Coordinator", + generator_id = fields.Many2one( + comodel_name="volunteer.shift.recurrent.generator", + string="Shift Generator", tracking=True, ) volunteer_ids = fields.Many2many( @@ -97,19 +53,19 @@ def _default_stage_id(self): store=True, ) - # SQL constraints - _sql_constraints = [ ( - "max_volunteer_nb_is_positive", + "shift_max_vol_nb_is_pos", "check (max_volunteer_nb > 0)", - "The maximum of volunteers per shift cannot be null or negative.", + "The maximum of volunteers cannot be null or negative.", ), ] # Compute methods - @api.depends("volunteer_participation_ids") + @api.depends( + "volunteer_participation_ids", "volunteer_participation_ids.registration_state" + ) def _compute_remaining_slots(self): for shift in self: nb_confirmed_participation = shift.get_booking_status()[ @@ -117,41 +73,6 @@ def _compute_remaining_slots(self): ] shift.remaining_slots = shift.max_volunteer_nb - nb_confirmed_participation - @api.depends("start_time", "end_time", "tz") - def _compute_is_one_day(self): - for shift in self: - shift = shift._set_tz_context() - if shift.start_time and shift.end_time: - start_date = fields.Datetime.context_timestamp( - shift, shift.start_time - ).date() - end_date = fields.Datetime.context_timestamp( - shift, shift.end_time - ).date() - shift.is_one_day = start_date == end_date - else: - shift.is_one_day = False - - @api.depends("tz", "start_time") - def _compute_start_time_located(self): - for shift in self: - if shift.start_time: - shift.start_time_located = format_datetime( - self.env, shift.start_time, shift.tz, dt_format="medium" - ) - else: - shift.start_time_located = False - - @api.depends("tz", "end_time") - def _compute_end_time_located(self): - for shift in self: - if shift.end_time: - shift.end_time_located = format_datetime( - self.env, shift.end_time, shift.tz, dt_format="medium" - ) - else: - shift.end_time_located = False - @api.depends("volunteer_participation_ids") def _compute_volunteer_ids(self): for shift in self: @@ -201,18 +122,19 @@ def _check_can_accept_new_participation(self): # Override methods def write(self, vals): - state_requested = vals.get("state") + old_states = {shift.id: shift.state for shift in self} # Restrict stage change to admins only if ( "stage_id" in vals and not self.env.context.get("install_mode") and not self.env.user.has_group("volunteer.volunteer_group_admin") ): - raise AccessError(_("Only admins can change the stage of a shift")) + raise AccessError(_("Only admins can change the state of a shift")) res = super().write(vals) for shift in self: + previous_state = old_states[shift.id] # Auto-cancel confirmed participation if the shift is canceled - if state_requested != "canceled" and shift.state == "canceled": + if previous_state != "canceled" and shift.state == "canceled": confirmed_participation = shift.get_booking_status()[ "confirmed_participation" ] @@ -225,11 +147,6 @@ def write(self, vals): def _group_expand_stage_id(self, stages, domain, order): return stages.search([], order=order) - def _set_tz_context(self): - """Set the timezone context for the shift.""" - self.ensure_one() - return self.with_context(tz=self.tz) - def get_booking_status(self): """Get the shift booking status by returning a dictionary of : - can_accept_participation: boolean diff --git a/volunteer/models/volunteer_shift_mixin.py b/volunteer/models/volunteer_shift_mixin.py new file mode 100644 index 000000000..3a7d1b77e --- /dev/null +++ b/volunteer/models/volunteer_shift_mixin.py @@ -0,0 +1,119 @@ +# SPDX-FileCopyrightText: 2025 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import timedelta + +from odoo import api, fields, models +from odoo.tools import format_datetime + +from odoo.addons.base.models.res_partner import _tz_get + + +class VolunteerShiftMixin(models.AbstractModel): + _name = "volunteer.shift.mixin" + _description = "Mixin class for shift setup" + _abstract = True + + # General fields + + name = fields.Char(required=True, tracking=True) + max_volunteer_nb = fields.Integer( + string="Max Volunteer", required=True, tracking=True + ) + is_one_day = fields.Boolean(compute="_compute_is_one_day") + + # Date fields + + tz = fields.Selection( + selection=_tz_get, + string="Timezone", + default=lambda self: self.env.user.tz or "UTC", + required=True, + tracking=True, + help="Timezone of the shift.", + ) + start_time = fields.Datetime( + default=lambda self: fields.Datetime.now(), required=True, tracking=True + ) + + def _get_default_end_time(self): + return fields.Datetime.now() + timedelta(hours=1) + + end_time = fields.Datetime( + default=_get_default_end_time, required=True, tracking=True + ) + start_time_located = fields.Char(compute="_compute_start_time_located") + end_time_located = fields.Char(compute="_compute_end_time_located") + + # Classification fields + + type_id = fields.Many2one( + comodel_name="volunteer.shift.type", string="Type", required=True, tracking=True + ) + category_id = fields.Many2one( + comodel_name="volunteer.shift.category", string="Category", tracking=True + ) + tag_ids = fields.Many2many( + comodel_name="volunteer.shift.tag", string="Tags", tracking=True + ) + + # Relational fields + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + default=lambda self: self.env.user.company_id, + required=True, + tracking=True, + ) + coordinator_id = fields.Many2one( + comodel_name="res.partner", + domain="[('is_company', '=', False)]", + string="Coordinator", + tracking=True, + ) + + # Compute methods + + @api.depends("start_time", "end_time", "tz") + def _compute_is_one_day(self): + for shift in self: + shift = shift._set_tz_context() + if shift.start_time and shift.end_time: + start_date = fields.Datetime.context_timestamp( + shift, shift.start_time + ).date() + end_date = fields.Datetime.context_timestamp( + shift, shift.end_time + ).date() + shift.is_one_day = start_date == end_date + else: + shift.is_one_day = False + + @api.depends("tz", "start_time") + def _compute_start_time_located(self): + for shift in self: + if shift.start_time: + shift.start_time_located = format_datetime( + self.env, shift.start_time, shift.tz, dt_format="medium" + ) + else: + shift.start_time_located = False + + @api.depends("tz", "end_time") + def _compute_end_time_located(self): + for shift in self: + if shift.end_time: + shift.end_time_located = format_datetime( + self.env, shift.end_time, shift.tz, dt_format="medium" + ) + else: + shift.end_time_located = False + + # Methods + + def _set_tz_context(self): + """Set the timezone context for the shift.""" + self.ensure_one() + return self.with_context(tz=self.tz) diff --git a/volunteer/models/volunteer_shift_participation.py b/volunteer/models/volunteer_shift_participation.py index 8ae7c8949..ef5cc0a65 100644 --- a/volunteer/models/volunteer_shift_participation.py +++ b/volunteer/models/volunteer_shift_participation.py @@ -9,7 +9,7 @@ class VolunteerShiftParticipation(models.Model): _name = "volunteer.shift.participation" - _description = "Shift participation" + _description = "Shift Participation" _inherit = ["mail.thread", "mail.activity.mixin"] # State fields @@ -72,8 +72,9 @@ class VolunteerShiftParticipation(models.Model): @api.constrains("shift_id", "registration_state") def _check_remaining_slots(self): - for participation in self: - booking_status = self.shift_id.get_booking_status() + shifts_to_check = self.mapped("shift_id") + for shift in shifts_to_check: + booking_status = shift.get_booking_status() if not booking_status["can_accept_participation"]: nb_confirmed_participation = booking_status[ "nb_confirmed_participation" @@ -82,7 +83,8 @@ def _check_remaining_slots(self): _( f"It is not possible to register" f" {nb_confirmed_participation} volunteers in this shift." - f" The maximum capacity is {participation.shift_id.max_volunteer_nb}." + f" The maximum capacity is " + f"{shift.max_volunteer_nb}." ) ) diff --git a/volunteer/models/volunteer_shift_recurrent_generator.py b/volunteer/models/volunteer_shift_recurrent_generator.py new file mode 100644 index 000000000..995d8e096 --- /dev/null +++ b/volunteer/models/volunteer_shift_recurrent_generator.py @@ -0,0 +1,95 @@ +# SPDX-FileCopyrightText: 2025 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from datetime import timedelta + +from dateutil.relativedelta import relativedelta + +from odoo import fields, models +from odoo.exceptions import UserError +from odoo.tools.translate import _ + + +class VolunteerShiftRecurrentGenerator(models.Model): + _name = "volunteer.shift.recurrent.generator" + _description = "Recurrent Shift Generator" + _inherit = ["volunteer.shift.mixin", "mail.thread", "mail.activity.mixin"] + + # State fields + + state = fields.Selection( + selection=[ + ("draft", "Draft"), + ("confirmed", "Confirmed"), + ("canceled", "Canceled"), + ], + default="draft", + required=True, + tracking=True, + ) + + # Period fields + + until_date = fields.Date(required=False, tracking=True) + interval_type = fields.Selection( + selection=[ + ("days", "Days"), + ("weeks", "Weeks"), + ("months", "Months"), + ("years", "Years"), + ], + required=True, + tracking=True, + ) + interval = fields.Integer( + required=True, + default=1, + tracking=True, + ) + + # Relational fields + + volunteer_shift_ids = fields.One2many( + comodel_name="volunteer.shift", + inverse_name="generator_id", + string="Recurrent Shifts", + tracking=True, + ) + volunteer_subscription_ids = fields.One2many( + comodel_name="volunteer.shift.recurrent.subscription", + inverse_name="generator_id", + string="Subscriptions", + tracking=True, + ) + + # SQL constraints + + _sql_constraints = [ + ( + "gen_max_vol_nb_is_pos", + "check (max_volunteer_nb > 0)", + "The maximum of volunteers cannot be null or negative.", + ), + ( + "interval_is_pos", + "check (interval > 0)", + "The interval cannot be null or negative.", + ), + ] + + # Methods + + def _get_interval_delta(self): + """Get the interval delta for the generator""" + self.ensure_one() + if self.interval_type == "days": + return timedelta(days=self.interval) + elif self.interval_type == "weeks": + return timedelta(weeks=self.interval) + elif self.interval_type == "months": + return relativedelta(months=self.interval) + elif self.interval_type == "years": + return relativedelta(years=self.interval) + else: + raise UserError(_("The interval type is not valid.")) diff --git a/volunteer/models/volunteer_shift_recurrent_subscription.py b/volunteer/models/volunteer_shift_recurrent_subscription.py new file mode 100644 index 000000000..71e1025b4 --- /dev/null +++ b/volunteer/models/volunteer_shift_recurrent_subscription.py @@ -0,0 +1,45 @@ +# SPDX-FileCopyrightText: 2025 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from odoo import fields, models + + +class VolunteerShiftRecurrentSubscription(models.Model): + _name = "volunteer.shift.recurrent.subscription" + _description = "Shift Recurrent Subscription" + _order = "start_date" + _inherit = [ + "mail.thread", + "mail.activity.mixin", + ] + + # Date fields + + start_date = fields.Date( + required=True, tracking=True, default=lambda self: fields.Date.today() + ) + end_date = fields.Date(tracking=True) + + # Relational fields + + volunteer_id = fields.Many2one( + comodel_name="volunteer.volunteer", + string="Volunteer", + required=True, + tracking=True, + ) + generator_id = fields.Many2one( + comodel_name="volunteer.shift.recurrent.generator", + string="Shift Generator", + required=True, + tracking=True, + ) + + company_id = fields.Many2one( + comodel_name="res.company", + string="Company", + related="generator_id.company_id", + store=True, + readonly=True, + ) diff --git a/volunteer/models/volunteer_volunteer.py b/volunteer/models/volunteer_volunteer.py index 405eef110..053205d97 100644 --- a/volunteer/models/volunteer_volunteer.py +++ b/volunteer/models/volunteer_volunteer.py @@ -41,6 +41,12 @@ class VolunteerVolunteer(models.Model): string="Participation", tracking=True, ) + shift_recurrent_subscription_ids = fields.One2many( + comodel_name="volunteer.shift.recurrent.subscription", + inverse_name="volunteer_id", + string="Subscriptions", + tracking=True, + ) # Computed fields diff --git a/volunteer/security/ir.model.access.csv b/volunteer/security/ir.model.access.csv index ac5223b6a..fe704e30e 100644 --- a/volunteer/security/ir.model.access.csv +++ b/volunteer/security/ir.model.access.csv @@ -26,3 +26,9 @@ access_volunteer_skill_admin,VolunteerSkillAdmin,model_volunteer_skill,volunteer access_volunteer_skill_category_user,VolunteerSkillCategoryUser,model_volunteer_skill_category,volunteer_group_user,1,0,0,0 access_volunteer_skill_category_manager,VolunteerSkillCategoryManager,model_volunteer_skill_category,volunteer_group_manager,1,1,1,0 access_volunteer_skill_category_admin,VolunteerSkillCategoryAdmin,model_volunteer_skill_category,volunteer_group_admin,1,1,1,1 +access_shift_generator_user,ShiftGeneratorUser,model_volunteer_shift_recurrent_generator,volunteer_group_user,1,0,0,0 +access_shift_generator_manager,ShiftGeneratorManager,model_volunteer_shift_recurrent_generator,volunteer_group_manager,1,1,0,0 +access_shift_generator_admin,ShiftGeneratorAdmin,model_volunteer_shift_recurrent_generator,volunteer_group_admin,1,1,1,0 +access_shift_subscription_user,ShiftSubscriptionUser,model_volunteer_shift_recurrent_subscription,volunteer_group_user,1,0,0,0 +access_shift_subscription_manager,ShiftSubscriptionManager,model_volunteer_shift_recurrent_subscription,volunteer_group_manager,1,1,1,0 +access_shift_subscription_admin,ShiftSubscriptionAdmin,model_volunteer_shift_recurrent_subscription,volunteer_group_admin,1,1,1,0 diff --git a/volunteer/static/description/index.html b/volunteer/static/description/index.html index a975b916c..c6b8ac7c0 100644 --- a/volunteer/static/description/index.html +++ b/volunteer/static/description/index.html @@ -367,7 +367,7 @@

Volunteer

!! This file is generated by oca-gen-addon-readme !! !! changes will be overwritten. !! !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -!! source digest: sha256:228bc97783dc8fb16e6e78095a1636d3b88ec1f6e74d6f16915413068b9d7333 +!! source digest: sha256:644d57b6084cf2959aed8fd69ff0e156702dfc36c73dbef48a2c45715ed54f32 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 beescoop/Obeesdoo

Generate and manage shifts for volunteers.

diff --git a/volunteer/tests/test_volunteer_shift.py b/volunteer/tests/test_volunteer_shift.py index 54856ee5e..94b6b4b6d 100644 --- a/volunteer/tests/test_volunteer_shift.py +++ b/volunteer/tests/test_volunteer_shift.py @@ -8,6 +8,7 @@ from odoo import Command from odoo.exceptions import AccessError, ValidationError +from odoo.tools import mute_logger from .test_volunteer_common import TestVolunteerCommon @@ -105,14 +106,16 @@ def test_compute_time_located(self): # expected end_time_located = 2027-06-07 02:00:01 self.assertTrue(self.shift_utc_plus_2.is_one_day) - def test_reduce_max_volunteer_equals_zero(self): + def test_reduce_max_volunteer_equals_zero_not_allowed(self): """Test that it is not possible to reduce max_volunteer_nb to 0""" - with self.assertRaises(CheckViolation): - self.shift_utc_plus_2.write( - { - "max_volunteer_nb": 0, - } - ) + with mute_logger("odoo.sql_db"): + with self.assertRaises(CheckViolation): + with self.cr.savepoint(): + self.shift_utc_plus_2.write( + { + "max_volunteer_nb": 0, + } + ) def test_reduce_max_volunteer_under_confirmed_participation(self): """Test that it is not possible to reduce max_volunteer_nb diff --git a/volunteer/views/res_config_settings_views.xml b/volunteer/views/res_config_settings_views.xml new file mode 100644 index 000000000..7853e9ac2 --- /dev/null +++ b/volunteer/views/res_config_settings_views.xml @@ -0,0 +1,54 @@ + + + + + Volunteer Settings + res.config.settings + + + + +
+

Generator settings

+
+
+
+ + Shift number of occurrences + +
+ Number of shift occurrences to maintain automatically +
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/volunteer/views/volunteer_menu.xml b/volunteer/views/volunteer_menu.xml index f9b60d848..6db5abaa9 100644 --- a/volunteer/views/volunteer_menu.xml +++ b/volunteer/views/volunteer_menu.xml @@ -27,14 +27,39 @@ SPDX-License-Identifier: AGPL-3.0-or-later sequence="20" action="volunteer_shift_action_future" /> - + + + + + + + + + + + Shift Generator Form + volunteer.shift.recurrent.generator + +
+
+ + +
+ + +
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ +
+ + + +
+ +
+
+ + + Shift Generator List + volunteer.shift.recurrent.generator + + + + + + + + + + + + + + + + + + + + + + Shift Generator + volunteer.shift.recurrent.generator + tree,form + +
diff --git a/volunteer/views/volunteer_shift_subscription_views.xml b/volunteer/views/volunteer_shift_subscription_views.xml new file mode 100644 index 000000000..8cc561692 --- /dev/null +++ b/volunteer/views/volunteer_shift_subscription_views.xml @@ -0,0 +1,29 @@ + + + + + Subscription List + volunteer.shift.recurrent.subscription + + + + + + + + + + + + Subscription + volunteer.shift.recurrent.subscription + tree + + diff --git a/volunteer/wizards/__init__.py b/volunteer/wizards/__init__.py new file mode 100644 index 000000000..d742162a0 --- /dev/null +++ b/volunteer/wizards/__init__.py @@ -0,0 +1,5 @@ +# SPDX-FileCopyrightText: 2025 Coop IT Easy SC +# +# SPDX-License-Identifier: AGPL-3.0-or-later + +from . import res_config_settings diff --git a/volunteer/wizards/res_config_settings.py b/volunteer/wizards/res_config_settings.py new file mode 100644 index 000000000..c575fe85f --- /dev/null +++ b/volunteer/wizards/res_config_settings.py @@ -0,0 +1,14 @@ +# SPDX-FileCopyrightText: 2025 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" + + shift_nb_occurrence = fields.Integer( + related="company_id.shift_nb_occurrence", + readonly=False, + )