Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

[13.0][ADD] base_time_window #1798

Merged
merged 11 commits into from
Apr 15, 2020
1 change: 1 addition & 0 deletions base_time_window/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
14 changes: 14 additions & 0 deletions base_time_window/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
{
"name": "Base Time Window",
"summary": "Base model to handle time windows",
"version": "13.0.1.0.0",
"category": "Technical Settings",
"author": "ACSONE SA/NV, Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://github.com/OCA/server-tools",
"depends": ["base"],
"data": ["data/time_weekday.xml", "security/ir.model.access.xml"],
"installable": True,
}
26 changes: 26 additions & 0 deletions base_time_window/data/time_weekday.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2020 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo noupdate="1">
<record model="time.weekday" id="time_weekday_monday">
<field name="name">0</field>
</record>
<record model="time.weekday" id="time_weekday_tuesday">
<field name="name">1</field>
</record>
<record model="time.weekday" id="time_weekday_wednesday">
<field name="name">2</field>
</record>
<record model="time.weekday" id="time_weekday_thursday">
<field name="name">3</field>
</record>
<record model="time.weekday" id="time_weekday_friday">
<field name="name">4</field>
</record>
<record model="time.weekday" id="time_weekday_saturday">
<field name="name">5</field>
</record>
<record model="time.weekday" id="time_weekday_sunday">
<field name="name">6</field>
</record>
</odoo>
2 changes: 2 additions & 0 deletions base_time_window/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import time_weekday
from . import time_window_mixin
62 changes: 62 additions & 0 deletions base_time_window/models/time_weekday.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from odoo import _, api, fields, models, tools


class TimeWeekday(models.Model):

_name = "time.weekday"
_description = "Time Week Day"

name = fields.Selection(
selection=[
("0", "Monday"),
("1", "Tuesday"),
("2", "Wednesday"),
("3", "Thursday"),
("4", "Friday"),
("5", "Saturday"),
("6", "Sunday"),
],
required=True,
)
_sql_constraints = [("name_uniq", "UNIQUE(name)", _("Name must be unique"))]

@api.depends("name")
def _compute_display_name(self):
"""
WORKAROUND since Odoo doesn't handle properly records where name is
a selection
"""
translated_values = dict(self._fields["name"]._description_selection(self.env))
for record in self:
record.display_name = translated_values[record.name]

def name_get(self):
"""
WORKAROUND since Odoo doesn't handle properly records where name is
a selection
"""
return [(r.id, r.display_name) for r in self]

@api.model
@tools.ormcache("name")
def _get_id_by_name(self, name):
return self.search([("name", "=", name)], limit=1).id

@api.model
def create(self, vals):
result = super().create(vals)
self._get_id_by_name.clear_cache(self)
return result

def write(self, vals):
result = super().write(vals)
self._get_id_by_name.clear_cache(self)
return result

def unlink(self):
result = super().unlink()
self._get_id_by_name.clear_cache(self)
return result
115 changes: 115 additions & 0 deletions base_time_window/models/time_window_mixin.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,115 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

import math
from datetime import time

from psycopg2.extensions import AsIs

from odoo import _, api, fields, models
from odoo.exceptions import ValidationError
from odoo.tools.misc import format_time


class TimeWindowMixin(models.AbstractModel):

_name = "time.window.mixin"
_description = "Time Window"
_order = "time_window_start"

# TODO patch api.constrains with field here?
_time_window_overlap_check_field = False

time_window_start = fields.Float("From", required=True)
time_window_end = fields.Float("To", required=True)
time_window_weekday_ids = fields.Many2many(
comodel_name="time.weekday", required=True
)

@api.constrains("time_window_start", "time_window_end", "time_window_weekday_ids")
def check_window_no_overlaps(self):
weekdays_field = self._fields["time_window_weekday_ids"]
for record in self:
if record.time_window_start > record.time_window_end:
raise ValidationError(
_("%s must be > %s")
% (
self.float_to_time_repr(record.time_window_end),
self.float_to_time_repr(record.time_window_start),
)
)
if not record.time_window_weekday_ids:
raise ValidationError(_("At least one time.weekday is required"))
# here we use a plain SQL query to benefit of the numrange
# function available in PostgresSQL
# (http://www.postgresql.org/docs/current/static/rangetypes.html)
SQL = """
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'd split this to _get_time_window_sql which returns (sql, sql_params)

SELECT
id
FROM
%(table)s w
join %(relation)s as d
on d.%(relation_window_fkey)s = w.id
WHERE
NUMRANGE(w.time_window_start::numeric,
w.time_window_end::numeric) &&
NUMRANGE(%(start)s::numeric, %(end)s::numeric)
AND w.id != %(window_id)s
AND d.%(relation_week_day_fkey)s in %(weekday_ids)s
AND w.%(check_field)s = %(check_field_id)s;"""
self.env.cr.execute(
SQL,
dict(
table=AsIs(self._table),
relation=AsIs(weekdays_field.relation),
relation_window_fkey=AsIs(weekdays_field.column1),
relation_week_day_fkey=AsIs(weekdays_field.column2),
start=record.time_window_start,
end=record.time_window_end,
window_id=record.id,
weekday_ids=tuple(record.time_window_weekday_ids.ids),
check_field=AsIs(self._time_window_overlap_check_field),
check_field_id=record[self._time_window_overlap_check_field].id,
),
)
res = self.env.cr.fetchall()
if res:
other = self.browse(res[0][0])
raise ValidationError(
_("%s overlaps %s") % (record.display_name, other.display_name)
)

@api.depends("time_window_start", "time_window_end", "time_window_weekday_ids")
def _compute_display_name(self):
for record in self:
record.display_name = _("{days}: From {start} to {end}").format(
grindtildeath marked this conversation as resolved.
Show resolved Hide resolved
days=", ".join(record.time_window_weekday_ids.mapped("display_name")),
start=format_time(self.env, record.get_time_window_start_time()),
end=format_time(self.env, record.get_time_window_end_time()),
)

@api.model
def _get_hour_min_from_value(self, value):
hour = math.floor(value)
minute = round((value % 1) * 60)
if minute == 60:
minute = 0
hour += 1
return hour, minute

@api.model
def float_to_time_repr(self, value):
pattern = "%02d:%02d"
hour, minute = self._get_hour_min_from_value(value)
return pattern % (hour, minute)

@api.model
def float_to_time(self, value):
hour, minute = self._get_hour_min_from_value(value)
return time(hour=hour, minute=minute)

def get_time_window_start_time(self):
return self.float_to_time(self.time_window_start)

def get_time_window_end_time(self):
return self.float_to_time(self.time_window_end)
2 changes: 2 additions & 0 deletions base_time_window/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Laurent Mignon <laurent.mignon@acsone.eu>
* Akim Juillerat <akim.juillerat@camptocamp.com>
2 changes: 2 additions & 0 deletions base_time_window/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
This module provides base classes and models to manage time windows through
`time.window.mixin`.
11 changes: 11 additions & 0 deletions base_time_window/readme/ROADMAP.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
* Storing times using `float_time` widget requires extra processing to ensure
computations are done in the right timezone, because the value is not stored
as UTC in the database, and must therefore be related to a `tz` field.

`float_time` in this sense should only be used for durations and not for a
"point in time" as this is always needs a Date for a timezone conversion to
be done properly. (Because a conversion from UTC to e.g. Europe/Brussels won't
give the same result in winter or summer because of Daylight Saving Time).

Therefore the right move would be to use a `resource.calendar` to define time
windows using Datetime with recurrences.
30 changes: 30 additions & 0 deletions base_time_window/readme/USAGE.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,30 @@
Example implementation for the mixin can be found in module `test_base_time_window`.

As a time window will always be linked to a related model thourgh a M2o relation,
when defining the new model inheriting the mixin, one should pay attention to the
following points in order to have the overlapping check work properly:

- Define class property `_overlap_check_field`: This must state the M2o field to
use for the to check of overlapping time window records linked to a specific
record of the related model.

- Add the M2o field to the related model in the `api.constrains`:


For example:

.. code-block:: python

class PartnerTimeWindow(models.Model):
_name = 'partner.time.window'
_inherit = 'time.window.mixin'

partner_id = fields.Many2one(
res.partner', required=True, index=True, ondelete='cascade'
)

_overlap_check_field = 'partner_id'

@api.constrains('partner_id')
def check_window_no_overlaps(self):
return super().check_window_no_overlaps()
14 changes: 14 additions & 0 deletions base_time_window/security/ir.model.access.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
<?xml version="1.0" encoding="utf-8" ?>
<!-- Copyright 2020 ACSONE SA/NV
License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl). -->
<odoo>
<record model="ir.model.access" id="time_weekday_access_read">
<field name="name">time.weekday access read</field>
<field name="model_id" ref="model_time_weekday" />
<field name="group_id" ref="base.group_user" />
<field name="perm_read" eval="1" />
<field name="perm_create" eval="0" />
<field name="perm_write" eval="0" />
<field name="perm_unlink" eval="0" />
</record>
</odoo>
1 change: 1 addition & 0 deletions setup/base_time_window/odoo/addons/base_time_window
6 changes: 6 additions & 0 deletions setup/base_time_window/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
6 changes: 6 additions & 0 deletions setup/test_base_time_window/setup.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
import setuptools

setuptools.setup(
setup_requires=['setuptools-odoo'],
odoo_addon=True,
)
1 change: 1 addition & 0 deletions test_base_time_window/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
from . import models
14 changes: 14 additions & 0 deletions test_base_time_window/__manifest__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
# Copyright 2020 Camptocamp SA
# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl)
{
"name": "Test Base Time Window",
"summary": "Test Base model to handle time windows",
"version": "13.0.1.0.0",
"category": "Technical Settings",
"author": "ACSONE SA/NV, Camptocamp, Odoo Community Association (OCA)",
"license": "AGPL-3",
"website": "https://github.com/OCA/server-tools",
"depends": ["base_time_window"],
"data": ["security/ir.model.access.xml"],
"installable": True,
}
2 changes: 2 additions & 0 deletions test_base_time_window/models/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
from . import partner_time_window
from . import res_partner
20 changes: 20 additions & 0 deletions test_base_time_window/models/partner_time_window.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).
from odoo import api, fields, models


class TestPartnerTimeWindow(models.Model):

_name = "test.partner.time.window"
_inherit = "time.window.mixin"
_description = "Test partner time Window"

_time_window_overlap_check_field = "partner_id"

partner_id = fields.Many2one(
"res.partner", required=True, index=True, ondelete="cascade"
)

@api.constrains("partner_id")
def check_window_no_overlaps(self):
return super().check_window_no_overlaps()
36 changes: 36 additions & 0 deletions test_base_time_window/models/res_partner.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,36 @@
# Copyright 2020 ACSONE SA/NV
# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl).

from collections import defaultdict

from odoo import fields, models


class ResPartner(models.Model):

_inherit = "res.partner"

time_window_ids = fields.One2many(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

this field and the method IMO could stay in another mixin time.window.consumer.mixin

comodel_name="test.partner.time.window",
inverse_name="partner_id",
string="Time windows",
)

def get_delivery_windows(self, day_name):
"""
Return the list of delivery windows by partner id for the given day

:param day: The day name (see time.weekday, ex: 0,1,2,...)
:return: dict partner_id:[time_window_ids, ]
"""
weekday_id = self.env["time.weekday"]._get_id_by_name(day_name)
res = defaultdict(list)
windows = self.env["test.partner.time.window"].search(
[
("partner_id", "in", self.ids),
("time_window_weekday_ids", "in", weekday_id),
]
)
for window in windows:
res[window.partner_id.id].append(window)
return res
2 changes: 2 additions & 0 deletions test_base_time_window/readme/CONTRIBUTORS.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
* Laurent Mignon <laurent.mignon@acsone.eu>
* Akim Juillerat <akim.juillerat@camptocamp.com>
1 change: 1 addition & 0 deletions test_base_time_window/readme/DESCRIPTION.rst
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
This module provides unittests for module `base_time_window`.
Loading