diff --git a/datetime_formatter/README.rst b/datetime_formatter/README.rst
new file mode 100644
index 000000000..46674323f
--- /dev/null
+++ b/datetime_formatter/README.rst
@@ -0,0 +1,76 @@
+.. image:: https://img.shields.io/badge/licence-AGPL--3-blue.svg
+ :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html
+ :alt: License: AGPL-3
+
+=====================
+Date & Time Formatter
+=====================
+
+This module was written to extend the functionality of Odoo language engine to
+support formatting `Date`, `Time` and `Datetime` fields easily and allow you to
+print them in the best format for the user.
+
+Usage
+=====
+
+This module adds a technical programming feature, and it should be used by
+addon developers, not by end users. This means that you must not expect to see
+any changes if you are a user and install this, but if you find you have it
+already installed, it's probably because you have another modules that depend
+on this one.
+
+If you are a developer, to use this module, you need to:
+
+* Call anywhere in your code::
+
+ formatted_string = self.env["res.lang"].datetime_formatter(datetime_value)
+
+* If you use Qweb::
+
+
+
+* If you call it from a record that has a `lang` field::
+
+ formatted_string = record.lang.datetime_formatter(record.datetime_field)
+
+* ``models.ResLang.datetime_formatter`` docstring explains its usage.
+
+.. image:: https://odoo-community.org/website/image/ir.attachment/5784_f2813bd/datas
+ :alt: Try me on Runbot
+ :target: https://runbot.odoo-community.org/runbot/149/8.0
+
+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 smashing it by providing a detailed and welcomed `feedback
+`_.
+
+
+Credits
+=======
+
+Contributors
+------------
+
+* Jairo Llopis
+
+Maintainer
+----------
+
+.. image:: https://odoo-community.org/logo.png
+ :alt: Odoo Community Association
+ :target: https://odoo-community.org
+
+This module is maintained by the OCA.
+
+OCA, or the Odoo Community Association, is a nonprofit organization whose
+mission is to support the collaborative development of Odoo features and
+promote its widespread use.
+
+To contribute to this module, please visit http://odoo-community.org.
diff --git a/datetime_formatter/__init__.py b/datetime_formatter/__init__.py
new file mode 100644
index 000000000..f2586f5d4
--- /dev/null
+++ b/datetime_formatter/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from . import models
diff --git a/datetime_formatter/__openerp__.py b/datetime_formatter/__openerp__.py
new file mode 100644
index 000000000..8e277f5f9
--- /dev/null
+++ b/datetime_formatter/__openerp__.py
@@ -0,0 +1,18 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+
+{
+ "name": "Date & Time Formatter",
+ "summary": "Helper functions to give correct format to date[time] fields",
+ "version": "8.0.1.0.0",
+ "category": "Tools",
+ "website": "https://grupoesoc.es",
+ "author": "Grupo ESOC Ingeniería de Servicios, "
+ "Odoo Community Association (OCA)",
+ "license": "AGPL-3",
+ "installable": True,
+ "depends": [
+ "base",
+ ],
+}
diff --git a/datetime_formatter/exceptions.py b/datetime_formatter/exceptions.py
new file mode 100644
index 000000000..9c6c35063
--- /dev/null
+++ b/datetime_formatter/exceptions.py
@@ -0,0 +1,11 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from openerp import _, exceptions
+
+
+class BestMatchedLanguageNotFoundError(exceptions.MissingError):
+ def __init__(self, lang):
+ msg = (_("Best matched language (%s) not found.") % lang)
+ super(BestMatchedLanguageNotFoundError, self).__init__(msg)
+ self.lang = lang
diff --git a/datetime_formatter/models.py b/datetime_formatter/models.py
new file mode 100644
index 000000000..6baa92b41
--- /dev/null
+++ b/datetime_formatter/models.py
@@ -0,0 +1,120 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from datetime import datetime, timedelta
+from openerp import api, exceptions, fields, models
+from openerp.tools import (DEFAULT_SERVER_DATE_FORMAT,
+ DEFAULT_SERVER_TIME_FORMAT)
+from . import exceptions as ex
+
+# Available modes for :param:`.ResLang.datetime_formatter.template`
+MODE_DATETIME = "MODE_DATETIME"
+MODE_DATE = "MODE_DATE"
+MODE_TIME = "MODE_TIME"
+
+
+class ResLang(models.Model):
+ _inherit = "res.lang"
+
+ @api.model
+ @api.returns('self')
+ def best_match(self, lang=None, failure_safe=True):
+ """Get best match of current default lang.
+
+ :param str lang:
+ If a language in the form of "en_US" is supplied, it will have the
+ highest priority.
+
+ :param bool failure_safe:
+ If ``False`` and the best matched language is not found installed,
+ an exception will be raised. Otherwise, the first installed
+ language found in the DB will be returned.
+ """
+ # Find some installed language, as fallback
+ first_installed = self.search([("active", "=", True)], limit=1)
+
+ if not lang:
+ lang = (
+ # Object's language, if called like
+ # ``record.lang.datetime_formatter(datetime_obj)``
+ (self.ids and self[0].code) or
+
+ # Context language
+ self.env.context.get("lang") or
+
+ # User's language
+ self.env.user.lang or
+
+ # First installed language found
+ first_installed.code)
+
+ # Get DB lang record
+ record = self.search([("code", "=", lang)])
+
+ try:
+ record.ensure_one()
+ except exceptions.except_orm:
+ if not failure_safe:
+ raise ex.BestMatchedLanguageNotFoundError(lang)
+ else:
+ record = first_installed
+
+ return record
+
+ @api.model
+ def datetime_formatter(self, value, lang=None, template=MODE_DATETIME,
+ separator=" ", failure_safe=True):
+ """Convert a datetime field to lang's default format.
+
+ :type value: `str`, `float` or `datetime.datetime`
+ :param value:
+ Datetime that will be formatted to the user's preferred format.
+
+ :param str lang:
+ See :param:`lang` from :meth:`~.best_match`.
+
+ :param bool failure_safe:
+ See :param:`failure_safe` from :meth:`~.best_match`.
+
+ :param str template:
+ Will be used to format :param:`value`. If it is one of the special
+ constants :const:`MODE_DATETIME`, :const:`MODE_DATE` or
+ :const:`MODE_TIME`, it will use the :param:`lang`'s default
+ template for that mode.
+
+ :param str separator:
+ Only used when :param:`template` is :const:`MODE_DATETIME`, as the
+ separator between the date and time parts.
+ """
+ # Get the correct lang
+ lang = self.best_match(lang)
+
+ # Get the template
+ if template in {MODE_DATETIME, MODE_DATE, MODE_TIME}:
+ defaults = []
+ if "DATE" in template:
+ defaults.append(lang.date_format or
+ DEFAULT_SERVER_DATE_FORMAT)
+ if "TIME" in template:
+ defaults.append(lang.time_format or
+ DEFAULT_SERVER_TIME_FORMAT)
+ template = separator.join(defaults)
+
+ # Convert str to datetime objects
+ if isinstance(value, (str, unicode)):
+ try:
+ value = fields.Datetime.from_string(value)
+ except ValueError:
+ # Probably failed due to value being only time
+ value = datetime.strptime(value, DEFAULT_SERVER_TIME_FORMAT)
+
+ # Time-only fields are floats for Odoo
+ elif isinstance(value, float):
+ # Patch values >= 24 hours
+ if value >= 24:
+ template = template.replace("%H", "%d" % value)
+
+ # Convert to time
+ value = (datetime.min + timedelta(hours=value)).time()
+
+ return value.strftime(template)
diff --git a/datetime_formatter/static/description/icon.png b/datetime_formatter/static/description/icon.png
new file mode 100644
index 000000000..3a0328b51
Binary files /dev/null and b/datetime_formatter/static/description/icon.png differ
diff --git a/datetime_formatter/tests/__init__.py b/datetime_formatter/tests/__init__.py
new file mode 100644
index 000000000..ead2fd4e0
--- /dev/null
+++ b/datetime_formatter/tests/__init__.py
@@ -0,0 +1,4 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from . import test_best_matcher, test_formatter
diff --git a/datetime_formatter/tests/test_best_matcher.py b/datetime_formatter/tests/test_best_matcher.py
new file mode 100644
index 000000000..e15bd9757
--- /dev/null
+++ b/datetime_formatter/tests/test_best_matcher.py
@@ -0,0 +1,75 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+from openerp.tests.common import TransactionCase
+from .. import exceptions
+
+
+class BasicCase(TransactionCase):
+ def setUp(self):
+ super(BasicCase, self).setUp()
+ self.langs = ("en_US", "es_ES", "it_IT", "pt_PT", "zh_CN")
+ self.rl = self.env["res.lang"]
+ for lang in self.langs:
+ if not self.rl.search([("code", "=", lang)]):
+ self.rl.load_lang(lang)
+
+ def test_explicit(self):
+ """When an explicit lang is used."""
+ for lang in self.langs:
+ self.assertEqual(self.rl.best_match(lang).code, lang)
+
+ def test_record(self):
+ """When called from a ``res.lang`` record."""
+ rl = self.rl.with_context(lang="it_IT")
+ rl.env.user.lang = "pt_PT"
+
+ for lang in self.langs:
+ self.assertEqual(
+ rl.search([("code", "=", lang)]).best_match().code,
+ lang)
+
+ def test_context(self):
+ """When called with a lang in context."""
+ self.env.user.lang = "pt_PT"
+
+ for lang in self.langs:
+ self.assertEqual(
+ self.rl.with_context(lang=lang).best_match().code,
+ lang)
+
+ def test_user(self):
+ """When lang not specified in context."""
+ for lang in self.langs:
+ self.env.user.lang = lang
+
+ # Lang is False in context
+ self.assertEqual(
+ self.rl.with_context(lang=False).best_match().code,
+ lang)
+
+ # Lang not found in context
+ self.assertEqual(
+ self.rl.with_context(dict()).best_match().code,
+ lang)
+
+ def test_first_installed(self):
+ """When falling back to first installed language."""
+ first = self.rl.search([("active", "=", True)], limit=1)
+ self.env.user.lang = False
+ self.assertEqual(
+ self.rl.with_context(lang=False).best_match().code,
+ first.code)
+
+ def test_unavailable(self):
+ """When matches to an unavailable language."""
+ self.env.user.lang = False
+ self.rl = self.rl.with_context(lang=False)
+ first = self.rl.search([("active", "=", True)], limit=1)
+
+ # Safe mode
+ self.assertEqual(self.rl.best_match("fake_LANG").code, first.code)
+
+ # Unsafe mode
+ with self.assertRaises(exceptions.BestMatchedLanguageNotFoundError):
+ self.rl.best_match("fake_LANG", failure_safe=False)
diff --git a/datetime_formatter/tests/test_formatter.py b/datetime_formatter/tests/test_formatter.py
new file mode 100644
index 000000000..51a4a0fe0
--- /dev/null
+++ b/datetime_formatter/tests/test_formatter.py
@@ -0,0 +1,92 @@
+# -*- coding: utf-8 -*-
+# © 2015 Grupo ESOC Ingeniería de Servicios, S.L.U. - Jairo Llopis
+# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html).
+import datetime
+from random import random
+from openerp.tests.common import TransactionCase
+from openerp.tools import (DEFAULT_SERVER_DATE_FORMAT,
+ DEFAULT_SERVER_TIME_FORMAT,
+ DEFAULT_SERVER_DATETIME_FORMAT)
+from ..models import MODE_DATE, MODE_TIME, MODE_DATETIME
+
+
+class FormatterCase(TransactionCase):
+ def setUp(self):
+ super(FormatterCase, self).setUp()
+ self.rl = self.env["res.lang"]
+ self.bm = self.rl.best_match()
+ self.dt = datetime.datetime.now()
+ self.d_fmt = self.bm.date_format or DEFAULT_SERVER_DATE_FORMAT
+ self.t_fmt = self.bm.time_format or DEFAULT_SERVER_TIME_FORMAT
+ self.kwargs = dict()
+
+ def tearDown(self):
+ # This should be returned
+ self.expected = self.dt.strftime(self.format)
+
+ # Pass a datetime object
+ self.assertEqual(
+ self.expected,
+ self.rl.datetime_formatter(
+ self.dt,
+ **self.kwargs))
+
+ # When the date comes as a string
+ if isinstance(self.dt, datetime.datetime):
+ self.dt_str = self.dt.strftime(DEFAULT_SERVER_DATETIME_FORMAT)
+ elif isinstance(self.dt, datetime.date):
+ self.dt_str = self.dt.strftime(DEFAULT_SERVER_DATE_FORMAT)
+ elif isinstance(self.dt, datetime.time):
+ self.dt_str = self.dt.strftime(DEFAULT_SERVER_TIME_FORMAT)
+
+ # Pass a string
+ self.assertEqual(
+ self.expected,
+ self.rl.datetime_formatter(
+ self.dt_str,
+ **self.kwargs))
+
+ # Pass a unicode
+ self.assertEqual(
+ self.expected,
+ self.rl.datetime_formatter(
+ unicode(self.dt_str),
+ **self.kwargs))
+
+ super(FormatterCase, self).tearDown()
+
+ def test_datetime(self):
+ """Format a datetime."""
+ self.format = "%s %s" % (self.d_fmt, self.t_fmt)
+ self.kwargs = {"template": MODE_DATETIME}
+
+ def test_date(self):
+ """Format a date."""
+ self.format = self.d_fmt
+ self.kwargs = {"template": MODE_DATE}
+ self.dt = self.dt.date()
+
+ def test_time(self):
+ """Format times, including float ones."""
+ self.format = self.t_fmt
+ self.kwargs = {"template": MODE_TIME}
+ self.dt = self.dt.time()
+
+ # Test float times
+ for n in range(50):
+ n = n + random()
+
+ # Patch values with >= 24 hours
+ fmt = self.format.replace("%H", "%02d" % n)
+
+ time = (datetime.datetime.min +
+ datetime.timedelta(hours=n)).time()
+ self.assertEqual(
+ time.strftime(fmt),
+ self.rl.datetime_formatter(n, **self.kwargs))
+
+ def test_custom_separator(self):
+ """Format a datetime with a custom separator."""
+ sep = "T"
+ self.format = "%s%s%s" % (self.d_fmt, sep, self.t_fmt)
+ self.kwargs = {"template": MODE_DATETIME, "separator": sep}