Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions volunteer/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,3 +3,4 @@
# SPDX-License-Identifier: AGPL-3.0-or-later

from . import models
from . import wizards
3 changes: 3 additions & 0 deletions volunteer/__manifest__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 4 additions & 0 deletions volunteer/models/__init__.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
22 changes: 22 additions & 0 deletions volunteer/models/res_company.py
Original file line number Diff line number Diff line change
@@ -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.",
),
]
113 changes: 15 additions & 98 deletions volunteer/models/volunteer_shift.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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(
Expand All @@ -97,61 +53,26 @@ 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()[
"nb_confirmed_participation"
]
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:
Expand Down Expand Up @@ -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"
]
Expand All @@ -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
Expand Down
119 changes: 119 additions & 0 deletions volunteer/models/volunteer_shift_mixin.py
Original file line number Diff line number Diff line change
@@ -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)
Loading