diff --git a/endpoint_cache/__init__.py b/endpoint_cache/__init__.py new file mode 100644 index 00000000..0650744f --- /dev/null +++ b/endpoint_cache/__init__.py @@ -0,0 +1 @@ +from . import models diff --git a/endpoint_cache/__manifest__.py b/endpoint_cache/__manifest__.py new file mode 100644 index 00000000..46e332dd --- /dev/null +++ b/endpoint_cache/__manifest__.py @@ -0,0 +1,15 @@ +# Copyright 2024 Camptocamp SA +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +{ + "name": "Endpoint cache", + "summary": """Provide basic caching utils for endpoints""", + "version": "14.0.1.0.0", + "license": "LGPL-3", + "development_status": "Alpha", + "author": "Camptocamp, Odoo Community Association (OCA)", + "maintainers": ["simahawk"], + "website": "https://github.com/OCA/web-api", + "depends": ["endpoint"], + "data": ["views/endpoint_view.xml"], +} diff --git a/endpoint_cache/models/__init__.py b/endpoint_cache/models/__init__.py new file mode 100644 index 00000000..95c62dfe --- /dev/null +++ b/endpoint_cache/models/__init__.py @@ -0,0 +1 @@ +from . import endpoint_mixin diff --git a/endpoint_cache/models/endpoint_mixin.py b/endpoint_cache/models/endpoint_mixin.py new file mode 100644 index 00000000..7f815e3c --- /dev/null +++ b/endpoint_cache/models/endpoint_mixin.py @@ -0,0 +1,105 @@ +# Copyright 2024 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from odoo import _, api, exceptions, fields, models +from odoo.tools import date_utils + +from odoo.addons.http_routing.models.ir_http import slugify_one + + +class EndpointMixin(models.AbstractModel): + + _inherit = "endpoint.mixin" + + cache_policy = fields.Selection( + selection=[ + ("day", "Daily"), + ("week", "Weekly"), + ("month", "Monthly"), + ], + default="day", + ) + # cache_preheat = fields.Boolean() # TODO + + def _endpoint_cache_make_name(self, ext, suffix=None): + parts = [ + "endpoint_cache", + slugify_one(self.name).replace("-", "_"), + ] + if suffix: + parts.append(suffix) + if ext: + parts.append(ext) + return ".".join(parts) + + def _endpoint_cache_get(self, name): + att = ( + self.env["ir.attachment"] + .sudo() + .search(self._endpoint_cache_get_domain(name), limit=1) + ) + self._logger.debug("_endpoint_cache_get found att=%s", att.id) + return att.raw + + def _endpoint_cache_get_domain(self, cache_name): + now = fields.Datetime.now() + from_datetime = date_utils.start_of(now, self.cache_policy) + to_datetime = date_utils.end_of(now, self.cache_policy) + return [ + ("name", "=", cache_name), + ("res_model", "=", self._name), + ("res_id", "=", self.id), + ("create_date", ">=", from_datetime), + ("create_date", "<=", to_datetime), + ] + + def _endpoint_cache_store(self, name, raw_data, mimetype=None): + self._logger.debug("_endpoint_cache_store store att=%s", name) + if not name.startswith("endpoint_cache"): + raise exceptions.UserError(_("Cache name must start with 'endpoint_cache'")) + return ( + self.env["ir.attachment"] + .sudo() + .create( + { + "type": "binary", + "name": name, + "raw": raw_data, + "mimetype": mimetype, + "res_model": self._name, + "res_id": self.id, + } + ) + ) + + def _endpoint_cache_gc_domain(self, cache_name): + now = fields.Datetime.now() + gc_from = date_utils.subtract(now, days=30) + return [ + ("name", "like", "endpoint_cache%"), + ("res_model", "=", self._name), + ("res_id", "=", self.id), + ("create_date", "<=", gc_from), + ] + + @api.autovacuum + def _endpoint_cache_gc(self): + """Garbage collector for old caches""" + self.env["ir.attachment"].sudo().search( + self._endpoint_cache_gc_domain(self._name) + ).unlink() + + def action_view_cache_attachments(self): + """Action to view cache attachments""" + action = self.env["ir.actions.actions"]._for_xml_id("base.action_attachment") + action["domain"] = self._endpoint_view_cache_domain() + action["name"] = _("Cache results") + return action + + def _endpoint_view_cache_domain(self): + return [ + ("name", "like", "endpoint_cache%"), + ("res_model", "=", self._name), + ("res_id", "=", self.id), + ] diff --git a/endpoint_cache/readme/CONTRIBUTORS.rst b/endpoint_cache/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..f1c71bce --- /dev/null +++ b/endpoint_cache/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Simone Orsi diff --git a/endpoint_cache/readme/DESCRIPTION.rst b/endpoint_cache/readme/DESCRIPTION.rst new file mode 100644 index 00000000..0487ad69 --- /dev/null +++ b/endpoint_cache/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +Technical module provide basic caching configuration and utils for endpoints. diff --git a/endpoint_cache/readme/ROADMAP.rst b/endpoint_cache/readme/ROADMAP.rst new file mode 100644 index 00000000..3007460c --- /dev/null +++ b/endpoint_cache/readme/ROADMAP.rst @@ -0,0 +1 @@ +* Add cache pre-heating diff --git a/endpoint_cache/readme/USAGE.rst b/endpoint_cache/readme/USAGE.rst new file mode 100644 index 00000000..f20cf65f --- /dev/null +++ b/endpoint_cache/readme/USAGE.rst @@ -0,0 +1,15 @@ +Example of usage in an endpoint:: + + # get the name of the cache + cache_name = endpoint._endpoint_cache_make_name("json") + # check if the cache exists + cached = endpoint._endpoint_cache_get(cache_name) + if cached: + result = cached + else: + result = json.dumps(env["my.model"]._get_a_very_expensive_computed_result()) + # cache does not exist, create it + endpoint._endpoint_cache_store(cache_name, result) + + resp = Response(result, content_type="application/json", status=200) + result = dict(response=resp) diff --git a/endpoint_cache/tests/__init__.py b/endpoint_cache/tests/__init__.py new file mode 100644 index 00000000..c3f07a7d --- /dev/null +++ b/endpoint_cache/tests/__init__.py @@ -0,0 +1 @@ +from . import test_endpoint diff --git a/endpoint_cache/tests/test_endpoint.py b/endpoint_cache/tests/test_endpoint.py new file mode 100644 index 00000000..ac20eb1d --- /dev/null +++ b/endpoint_cache/tests/test_endpoint.py @@ -0,0 +1,80 @@ +# Copyright 2024 Camptocamp SA +# @author: Simone Orsi +# License LGPL-3.0 or later (http://www.gnu.org/licenses/lgpl). + +from freezegun import freeze_time + +from odoo import exceptions + +from odoo.addons.endpoint.tests.common import CommonEndpoint + + +class TestEndpoint(CommonEndpoint): + @classmethod + def _setup_records(cls): + super()._setup_records() + cls.endpoint1 = cls.env.ref("endpoint.endpoint_demo_1") + cls.endpoint2 = cls.env.ref("endpoint.endpoint_demo_2") + + def test_cache_name(self): + self.assertEqual( + self.endpoint1._endpoint_cache_make_name("json"), + "endpoint_cache.demo_endpoint_1.json", + ) + self.assertEqual( + self.endpoint2._endpoint_cache_make_name("json"), + "endpoint_cache.demo_endpoint_2.json", + ) + + def test_cache_store_bad_name(self): + with self.assertRaisesRegex( + exceptions.UserError, "Cache name must start with 'endpoint_cache'" + ): + self.endpoint1._endpoint_cache_store("test", b"test") + + def test_cache_store_and_get(self): + self.endpoint1._endpoint_cache_store("endpoint_cache.test", b"test") + data = self.endpoint1._endpoint_cache_get("endpoint_cache.test") + self.assertEqual(data, b"test") + + def test_cache_gc(self): + dt1 = "2024-07-01 00:00:00" + with freeze_time(dt1): + cache1 = self.endpoint1._endpoint_cache_store( + "endpoint_cache.test", b"test" + ) + cache1._write( + { + "create_date": dt1, + } + ) + dt2 = "2024-07-10 00:00:00" + with freeze_time(dt2): + cache2 = self.endpoint1._endpoint_cache_store( + "endpoint_cache.test2", b"test2" + ) + cache2._write( + { + "create_date": dt2, + } + ) + dt2 = "2024-07-20 00:00:00" + with freeze_time(dt2): + cache3 = self.endpoint1._endpoint_cache_store( + "endpoint_cache.test3", b"test3" + ) + cache3._write( + { + "create_date": dt2, + } + ) + with freeze_time("2024-08-01 00:00:00"): + self.endpoint1._endpoint_cache_gc() + self.assertFalse(cache1.exists()) + self.assertTrue(cache2.exists()) + self.assertTrue(cache3.exists()) + with freeze_time("2024-08-12 00:00:00"): + self.endpoint1._endpoint_cache_gc() + self.assertFalse(cache1.exists()) + self.assertFalse(cache2.exists()) + self.assertTrue(cache3.exists()) diff --git a/endpoint_cache/views/endpoint_view.xml b/endpoint_cache/views/endpoint_view.xml new file mode 100644 index 00000000..5ff03c67 --- /dev/null +++ b/endpoint_cache/views/endpoint_view.xml @@ -0,0 +1,41 @@ + + + + + + endpoint.endpoint + + + + + + +