From 12286f46b77bc27992818b4cc37a9dbd6782133e Mon Sep 17 00:00:00 2001 From: Nikos Tsirintanis Date: Wed, 7 Jan 2026 12:12:02 +0100 Subject: [PATCH 1/4] [ADD] calendar_shared_ics --- calendar_shared_ics/README.rst | 0 calendar_shared_ics/__init__.py | 2 + calendar_shared_ics/__manifest__.py | 21 +++ calendar_shared_ics/controllers/__init__.py | 1 + calendar_shared_ics/controllers/main.py | 34 +++++ calendar_shared_ics/models/__init__.py | 1 + .../models/calendar_shared_ics.py | 133 ++++++++++++++++ .../security/ir.model.access.csv | 3 + calendar_shared_ics/security/ir_rule.xml | 28 ++++ calendar_shared_ics/security/res_groups.xml | 6 + calendar_shared_ics/tests/__init__.py | 1 + calendar_shared_ics/tests/test_shared_ics.py | 144 ++++++++++++++++++ .../views/calendar_shared_ics_views.xml | 73 +++++++++ requirements.txt | 1 + .../odoo/addons/calendar_shared_ics | 1 + setup/calendar_shared_ics/setup.py | 6 + 16 files changed, 455 insertions(+) create mode 100644 calendar_shared_ics/README.rst create mode 100644 calendar_shared_ics/__init__.py create mode 100644 calendar_shared_ics/__manifest__.py create mode 100644 calendar_shared_ics/controllers/__init__.py create mode 100644 calendar_shared_ics/controllers/main.py create mode 100644 calendar_shared_ics/models/__init__.py create mode 100644 calendar_shared_ics/models/calendar_shared_ics.py create mode 100644 calendar_shared_ics/security/ir.model.access.csv create mode 100644 calendar_shared_ics/security/ir_rule.xml create mode 100644 calendar_shared_ics/security/res_groups.xml create mode 100644 calendar_shared_ics/tests/__init__.py create mode 100644 calendar_shared_ics/tests/test_shared_ics.py create mode 100644 calendar_shared_ics/views/calendar_shared_ics_views.xml create mode 120000 setup/calendar_shared_ics/odoo/addons/calendar_shared_ics create mode 100644 setup/calendar_shared_ics/setup.py diff --git a/calendar_shared_ics/README.rst b/calendar_shared_ics/README.rst new file mode 100644 index 00000000..e69de29b diff --git a/calendar_shared_ics/__init__.py b/calendar_shared_ics/__init__.py new file mode 100644 index 00000000..91c5580f --- /dev/null +++ b/calendar_shared_ics/__init__.py @@ -0,0 +1,2 @@ +from . import controllers +from . import models diff --git a/calendar_shared_ics/__manifest__.py b/calendar_shared_ics/__manifest__.py new file mode 100644 index 00000000..134fdd1b --- /dev/null +++ b/calendar_shared_ics/__manifest__.py @@ -0,0 +1,21 @@ +# Copyright 2025 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +{ + "name": "Shared ICS calendars (token-protected)", + "version": "16.0.1.0.0", + "category": "Calendar", + "summary": "Share readonly calendar feeds via ICS with access tokens", + "license": "AGPL-3", + "author": "Therp BV, Odoo Community Association (OCA)", + "website": "https://github.com/OCA/calendar", + "depends": ["calendar", "portal", "mail"], + "data": [ + "security/res_groups.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/calendar_shared_ics_views.xml", + ], + "installable": True, + "application": False, + "external_dependencies": {"python": ["vobject"]}, +} diff --git a/calendar_shared_ics/controllers/__init__.py b/calendar_shared_ics/controllers/__init__.py new file mode 100644 index 00000000..12a7e529 --- /dev/null +++ b/calendar_shared_ics/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/calendar_shared_ics/controllers/main.py b/calendar_shared_ics/controllers/main.py new file mode 100644 index 00000000..a31b7e49 --- /dev/null +++ b/calendar_shared_ics/controllers/main.py @@ -0,0 +1,34 @@ +# Copyright 2025 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from odoo.http import Controller, content_disposition, request, route + + +class CalendarSharedIcsController(Controller): + @route( + ["/calendar/shared//ics"], + type="http", + auth="public", + csrf=False, + sitemap=False, + ) + def calendar_shared_ics(self, shared, **kwargs): + # super minimal, all the actual work + # takes place in calendar.shared.ics + shared = shared.sudo() + if not shared.exists() or not shared.active: + return request.not_found() + if not shared._check_access_token(kwargs.get("access_token")): + return request.not_found() + try: + content = shared._render_ics_content() + except Exception: + return request.not_found() + filename = shared._get_ics_filename() + return request.make_response( + content, + [ + ("Content-Type", "text/calendar; charset=utf-8"), + ("Content-Disposition", content_disposition(filename)), + ("Cache-Control", "no-store"), + ], + ) diff --git a/calendar_shared_ics/models/__init__.py b/calendar_shared_ics/models/__init__.py new file mode 100644 index 00000000..7748c655 --- /dev/null +++ b/calendar_shared_ics/models/__init__.py @@ -0,0 +1 @@ +from . import calendar_shared_ics diff --git a/calendar_shared_ics/models/calendar_shared_ics.py b/calendar_shared_ics/models/calendar_shared_ics.py new file mode 100644 index 00000000..695850c4 --- /dev/null +++ b/calendar_shared_ics/models/calendar_shared_ics.py @@ -0,0 +1,133 @@ +# Copyright 2025 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +import vobject + +from odoo import api, fields, models +from odoo.tools.safe_eval import safe_eval + + +class CalendarSharedIcs(models.Model): + _name = "calendar.shared.ics" + _description = "Shared ICS calendar" + _inherit = ["portal.mixin", "mail.thread", "mail.activity.mixin"] + _order = "id desc" + + name = fields.Char(required=True, default="Shared calendar") + active = fields.Boolean(default=True) + partner_id = fields.Many2one("res.partner", index=True) + apply_partner_filter = fields.Boolean( + default=True, + help="If enabled, only events where this partner is an attendee are exported.", + ) + domain = fields.Char( + help="Optional extra domain (to safe_eval)", + ) + share_webcal_url = fields.Char(compute="_compute_share_urls", readonly=True) + share_url = fields.Char(compute="_compute_share_urls", readonly=True) + + def action_reset_access_token(self): + """Rotate token (invalidate old subscription URLs).""" + for cal in self.sudo(): + cal.access_token = False + cal._portal_ensure_token() + + def action_open_share_wizard(self): + """Open portal share wizard for this feed.""" + self.ensure_one() + self._portal_ensure_token() + ctx = dict(self.env.context) + ctx.update( + active_model=self._name, + active_id=self.id, + active_ids=[self.id], + ) + if self.partner_id: + ctx["default_partner_ids"] = [(6, 0, [self.partner_id.id])] + return { + "type": "ir.actions.act_window", + "name": "Share", + "res_model": "portal.share", + "view_mode": "form", + "target": "new", + "context": ctx, + } + + def _get_share_url(self, redirect=False, **kwargs): + """Direct user to their own calendar feed in backend""" + self.ensure_one() + return f"/web#id={self.id}&model={self._name}&view_type=form" + + def _compute_access_url(self): + """Generic access url""" + res = super()._compute_access_url() + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + for cal in self: + cal.access_url = f"{base_url}/calendar/shared/{cal.id}/ics" + return res + + @api.depends("access_url", "access_token") + def _compute_share_urls(self): + """Links for configuring mail clients""" + for cal in self: + cal._portal_ensure_token() + if not cal.access_url: + cal.share_url = False + cal.share_webcal_url = False + continue + url = f"{cal.access_url}?access_token={cal.access_token}" + cal.share_url = url + cal.share_webcal_url = url.replace("https://", "webcal://").replace( + "http://", "webcal://" + ) + + def _check_access_token(self, token): + self.ensure_one() + self._portal_ensure_token() + return bool(token) and token == self.access_token + + def _get_events_domain(self): + """Compute the calendar.event domain for this shared feed. + + Raises on invalid domain to allow fail-closed behavior at the controller layer. + """ + self.ensure_one() + domain = [] + if self.apply_partner_filter and self.partner_id: + domain.append(("partner_ids", "in", [self.partner_id.id])) + if self.domain: + extra = safe_eval(self.domain.strip(), {"uid": self.env.uid}) + if not isinstance(extra, (list, tuple)): + raise ValueError("Domain must be a list/tuple of terms") + domain += list(extra) + return domain + + def _get_events_for_export(self): + """Return calendar.event recordset to export.""" + self.ensure_one() + domain = self._get_events_domain() + return self.env["calendar.event"].sudo().search(domain, order="start asc") + + def _combine_ics_files(self, events, files_by_event_id): + """Combine individual VCALENDAR payloads into one VCALENDAR bytes.""" + combined = vobject.iCalendar() + for ev in events: + payload = files_by_event_id.get(ev.id) + if not payload: + continue + ics_text = payload.decode("utf-8", errors="replace") + cal = vobject.readOne(ics_text) + for vev in cal.vevent_list: + combined.add(vev) + return combined.serialize().encode("utf-8") + + def _render_ics_content(self): + """Return merged ICS content (bytes) for this feed.""" + self.ensure_one() + events = self._get_events_for_export() + files = events._get_ics_file() or {} + return self._combine_ics_files(events, files) + + def _get_ics_filename(self): + """Return download filename for this feed.""" + self.ensure_one() + return (self.name or "calendar").strip() + ".ics" diff --git a/calendar_shared_ics/security/ir.model.access.csv b/calendar_shared_ics/security/ir.model.access.csv new file mode 100644 index 00000000..2804d7cd --- /dev/null +++ b/calendar_shared_ics/security/ir.model.access.csv @@ -0,0 +1,3 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_calendar_shared_ics_manager,access.calendar.shared.ics.manager,model_calendar_shared_ics,calendar_shared_ics.group_calendar_shared_ics_manager,1,1,1,1 +access_calendar_shared_ics_user_read,access.calendar.shared.ics.user.read,model_calendar_shared_ics,base.group_user,1,0,0,0 diff --git a/calendar_shared_ics/security/ir_rule.xml b/calendar_shared_ics/security/ir_rule.xml new file mode 100644 index 00000000..a9c5df7e --- /dev/null +++ b/calendar_shared_ics/security/ir_rule.xml @@ -0,0 +1,28 @@ + + + + calendar.shared.ics: managers see all + + [(1, '=', 1)] + + + + + + + + + calendar.shared.ics: users can read own feed + + [('partner_id', '=', user.partner_id.id)] + + + + + + + + diff --git a/calendar_shared_ics/security/res_groups.xml b/calendar_shared_ics/security/res_groups.xml new file mode 100644 index 00000000..b6686ad8 --- /dev/null +++ b/calendar_shared_ics/security/res_groups.xml @@ -0,0 +1,6 @@ + + + Shared ICS Calendar Manager + + + diff --git a/calendar_shared_ics/tests/__init__.py b/calendar_shared_ics/tests/__init__.py new file mode 100644 index 00000000..8510f536 --- /dev/null +++ b/calendar_shared_ics/tests/__init__.py @@ -0,0 +1 @@ +from . import test_shared_ics diff --git a/calendar_shared_ics/tests/test_shared_ics.py b/calendar_shared_ics/tests/test_shared_ics.py new file mode 100644 index 00000000..7d13d5d5 --- /dev/null +++ b/calendar_shared_ics/tests/test_shared_ics.py @@ -0,0 +1,144 @@ +# Copyright 2025 Therp BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from unittest.mock import patch + +from odoo import Command, fields +from odoo.exceptions import AccessError +from odoo.tests.common import TransactionCase, tagged + + +@tagged("-at_install", "post_install") +class TestCalendarSharedIcsFullNoHttp(TransactionCase): + def setUp(self): + super().setUp() + self.Shared = self.env["calendar.shared.ics"] + self.Users = self.env["res.users"] + self.Partners = self.env["res.partner"] + self.Event = self.env["calendar.event"] + self.group_user = self.env.ref("base.group_user") + self.group_manager = self.env.ref( + "calendar_shared_ics.group_calendar_shared_ics_manager" + ) + self.marker = f"[ICS_NOHTTP_{self.__class__.__name__}]" + # Partners + self.partner_mgr = self.Partners.create({"name": f"{self.marker} Mgr Partner"}) + self.partner_u1 = self.Partners.create({"name": f"{self.marker} U1 Partner"}) + self.partner_u2 = self.Partners.create({"name": f"{self.marker} U2 Partner"}) + # Users + self.user_mgr = self.Users.create( + { + "name": f"{self.marker} Manager", + "login": "ics_nohttp_manager", + "password": "ics_nohttp_manager", + "partner_id": self.partner_mgr.id, + "groups_id": [Command.set([self.group_user.id, self.group_manager.id])], + } + ) + self.user_u1 = self.Users.create( + { + "name": f"{self.marker} User1", + "login": "ics_nohttp_user1", + "password": "ics_nohttp_user1", + "partner_id": self.partner_u1.id, + "groups_id": [Command.set([self.group_user.id])], + } + ) + self.user_u2 = self.Users.create( + { + "name": f"{self.marker} User2", + "login": "ics_nohttp_user2", + "password": "ics_nohttp_user2", + "partner_id": self.partner_u2.id, + "groups_id": [Command.set([self.group_user.id])], + } + ) + + # Feeds + self.feed_u1 = self.Shared.sudo().create( + { + "name": f"{self.marker} Feed U1", + "partner_id": self.partner_u1.id, + "active": True, + } + ) + self.feed_u2 = self.Shared.sudo().create( + { + "name": f"{self.marker} Feed U2", + "partner_id": self.partner_u2.id, + "active": True, + } + ) + self.feed_u1.sudo()._portal_ensure_token() + self.feed_u2.sudo()._portal_ensure_token() + + # Events (real records to test domain/search) + now = fields.Datetime.now() + self.event_u1 = self.Event.sudo().create( + { + "name": f"{self.marker} Event U1", + "start": now, + "stop": now, + "partner_ids": [(6, 0, [self.partner_u1.id])], + } + ) + self.event_u2 = self.Event.sudo().create( + { + "name": f"{self.marker} Event U2", + "start": now, + "stop": now, + "partner_ids": [(6, 0, [self.partner_u2.id])], + } + ) + + def test_manager_can_read_all_feeds(self): + feeds = self.Shared.with_user(self.user_mgr).search([]) + self.assertIn(self.feed_u1, feeds) + self.assertIn(self.feed_u2, feeds) + + def test_user_can_only_read_own_feed(self): + feeds_u1 = self.Shared.with_user(self.user_u1).search([]) + self.assertIn(self.feed_u1, feeds_u1) + self.assertNotIn(self.feed_u2, feeds_u1) + self.Shared.with_user(self.user_u1).browse(self.feed_u1.id).read(["name"]) + with self.assertRaises(AccessError): + self.Shared.with_user(self.user_u1).browse(self.feed_u2.id).read(["name"]) + + def test_domain_partner_filter(self): + dom = self.feed_u1.sudo()._get_events_domain() + self.assertIn(("partner_ids", "in", [self.partner_u1.id]), dom) + events = self.feed_u1.sudo()._get_events_for_export() + self.assertIn(self.event_u1, events) + self.assertNotIn(self.event_u2, events) + + def test_domain_extra_filter(self): + # exclude U1 event by name + self.feed_u1.sudo().domain = "[('name', '!=', '%s')]" % ( + f"{self.marker} Event U1" + ) + events = self.feed_u1.sudo()._get_events_for_export() + self.assertNotIn(self.event_u1, events) + + def test_domain_invalid_raises(self): + self.feed_u1.sudo().domain = "THIS_IS_NOT_A_DOMAIN" + with self.assertRaises(ValueError): + self.feed_u1.sudo()._get_events_domain() + + def test_render_ics_content_contains_expected(self): + events = self.feed_u1.sudo()._get_events_for_export() + # Make sure we’re only exporting U1 event in this feed + self.assertEqual(events, self.event_u1) + + ics_u1 = ( + b"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:%s\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" + % ((f"{self.marker} Event U1").encode("utf-8")) + ) + + # Patch _get_ics_file on the recordset model class + with patch.object( + type(events), "_get_ics_file", return_value={self.event_u1.id: ics_u1} + ): + out = self.feed_u1.sudo()._render_ics_content() + text = out.decode("utf-8", errors="replace") + self.assertIn("BEGIN:VCALENDAR", text) + self.assertIn(f"{self.marker} Event U1", text) + self.assertIn("END:VCALENDAR", text) diff --git a/calendar_shared_ics/views/calendar_shared_ics_views.xml b/calendar_shared_ics/views/calendar_shared_ics_views.xml new file mode 100644 index 00000000..1ee41f86 --- /dev/null +++ b/calendar_shared_ics/views/calendar_shared_ics_views.xml @@ -0,0 +1,73 @@ + + + calendar.shared.ics.tree + calendar.shared.ics + + + + + + + + + + + + calendar.shared.ics.form + calendar.shared.ics + +
+
+ +
+ + + + + + + + + + + + + + + + +
+ + + +
+
+
+
+ + + Shared ICS Calendars + calendar.shared.ics + tree,form + + + + +
diff --git a/requirements.txt b/requirements.txt index f61de956..810b2d78 100644 --- a/requirements.txt +++ b/requirements.txt @@ -1,2 +1,3 @@ # generated from manifests external_dependencies cssselect +vobject diff --git a/setup/calendar_shared_ics/odoo/addons/calendar_shared_ics b/setup/calendar_shared_ics/odoo/addons/calendar_shared_ics new file mode 120000 index 00000000..166f6d17 --- /dev/null +++ b/setup/calendar_shared_ics/odoo/addons/calendar_shared_ics @@ -0,0 +1 @@ +../../../../calendar_shared_ics \ No newline at end of file diff --git a/setup/calendar_shared_ics/setup.py b/setup/calendar_shared_ics/setup.py new file mode 100644 index 00000000..28c57bb6 --- /dev/null +++ b/setup/calendar_shared_ics/setup.py @@ -0,0 +1,6 @@ +import setuptools + +setuptools.setup( + setup_requires=['setuptools-odoo'], + odoo_addon=True, +) From 36d9214c21eff3b4d5feabe9f4f5a4b4e34c0319 Mon Sep 17 00:00:00 2001 From: Nikos Tsirintanis Date: Fri, 9 Jan 2026 11:49:08 +0100 Subject: [PATCH 2/4] [ADD] landing page, README, tests --- calendar_shared_ics/README.rst | 151 ++++++ calendar_shared_ics/__manifest__.py | 2 + calendar_shared_ics/controllers/main.py | 31 ++ .../models/calendar_shared_ics.py | 54 +- calendar_shared_ics/readme/CONTRIBUTORS.rst | 5 + calendar_shared_ics/readme/DESCRIPTION.rst | 29 + calendar_shared_ics/readme/ROADMAP.rst | 3 + calendar_shared_ics/readme/USAGE.rst | 24 + .../static/description/index.html | 494 ++++++++++++++++++ calendar_shared_ics/tests/test_shared_ics.py | 38 +- calendar_shared_ics/views/landing_page.xml | 72 +++ 11 files changed, 866 insertions(+), 37 deletions(-) create mode 100644 calendar_shared_ics/readme/CONTRIBUTORS.rst create mode 100644 calendar_shared_ics/readme/DESCRIPTION.rst create mode 100644 calendar_shared_ics/readme/ROADMAP.rst create mode 100644 calendar_shared_ics/readme/USAGE.rst create mode 100644 calendar_shared_ics/static/description/index.html create mode 100644 calendar_shared_ics/views/landing_page.xml diff --git a/calendar_shared_ics/README.rst b/calendar_shared_ics/README.rst index e69de29b..d97575bc 100644 --- a/calendar_shared_ics/README.rst +++ b/calendar_shared_ics/README.rst @@ -0,0 +1,151 @@ +====================================== +Shared ICS calendars (token-protected) +====================================== + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:490396883f2f27e9e987064ece49696f3e0dcc53e355b186debb2d5c642e61f3 + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Beta-yellow.png + :target: https://odoo-community.org/page/development-status + :alt: Beta +.. |badge2| image:: https://img.shields.io/badge/licence-AGPL--3-blue.png + :target: http://www.gnu.org/licenses/agpl-3.0-standalone.html + :alt: License: AGPL-3 +.. |badge3| image:: https://img.shields.io/badge/github-OCA%2Fcalendar-lightgray.png?logo=github + :target: https://github.com/OCA/calendar/tree/16.0/calendar_shared_ics + :alt: OCA/calendar +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/calendar-16-0/calendar-16-0-calendar_shared_ics + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/builds?repo=OCA/calendar&target_branch=16.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + + +This module allows sharing an Odoo calendar as a **read-only ICS feed** that can be +subscribed to in external calendar clients such as **Outlook**, **Google Calendar**, +and **Thunderbird**. + +The calendar is exposed via a public URL protected by an **access token** and can be +added as a network calendar (ICS / webcal). + +Odoo provides bidirectional calendar synchronisation with external providers such as +Outlook and Google Calendar. However, in practice this integration is not always mature +and can suffer from functional limitations or bugs, especially in complex setups. + +This module offers a **simple and robust alternative** for one-directional use cases: + +* Events are exported from Odoo as a **read-only ICS calendar** +* External calendar clients periodically refresh the feed +* No changes are ever written back into Odoo + +Typical use cases include: + +* Sharing planning or resource bookings with employees +* Giving customers or partners insight into scheduled events +* Avoiding conflicts or data corruption caused by two-way sync + +⚠ **Security note** +The calendar is protected by an access token embedded in the URL. +Anyone who obtains this URL can view the calendar. +Treat the link as a secret. +R + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +1. **Create a shared calendar feed** + * Go to *Calendar → Configuration → Shared ICS* + * Create a new *Shared ICS calendar* + * Select the partner whose events should be exported + * Optionally define extra domain filters + +2. **Generate a share link** + * Click *Share* + * Use the standard Odoo sharing wizard + * An email will be sent containing a link to a landing page + +3. **Subscribe from an external calendar** + * Open the link from the email + * Copy the **webcal://** or **https://** URL + * Add it as a network / public calendar in your client + + Examples: + * **Outlook**: *Add calendar → Subscribe from web* + * **Google Calendar**: *Settings → Add calendar → From URL* + * **Thunderbird**: *New Calendar → On the Network → iCalendar (ICS)* + +4. **Rotate access token (optional)** + * If the link is compromised, use *Rotate access token* + * Old subscription URLs will stop working immediately + +Known issues / Roadmap +====================== + +* Optional **IP restriction** for shared calendar feeds +* Optional **expiration date** for access tokens +* Per-feed refresh hints / cache control options + +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 to smash it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* Therp BV + +Contributors +~~~~~~~~~~~~ + +------------ +Contributors +------------ + +* Nikos Tsirintanis + +Maintainers +~~~~~~~~~~~ + +This module is maintained by the OCA. + +.. image:: https://odoo-community.org/logo.png + :alt: Odoo Community Association + :target: https://odoo-community.org + +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. + +.. |maintainer-ntsirintanis| image:: https://github.com/ntsirintanis.png?size=40px + :target: https://github.com/ntsirintanis + :alt: ntsirintanis + +Current `maintainer `__: + +|maintainer-ntsirintanis| + +This module is part of the `OCA/calendar `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/calendar_shared_ics/__manifest__.py b/calendar_shared_ics/__manifest__.py index 134fdd1b..2279325e 100644 --- a/calendar_shared_ics/__manifest__.py +++ b/calendar_shared_ics/__manifest__.py @@ -14,8 +14,10 @@ "security/ir.model.access.csv", "security/ir_rule.xml", "views/calendar_shared_ics_views.xml", + "views/landing_page.xml", ], "installable": True, "application": False, "external_dependencies": {"python": ["vobject"]}, + "maintainers": ["ntsirintanis"], } diff --git a/calendar_shared_ics/controllers/main.py b/calendar_shared_ics/controllers/main.py index a31b7e49..7e620a52 100644 --- a/calendar_shared_ics/controllers/main.py +++ b/calendar_shared_ics/controllers/main.py @@ -32,3 +32,34 @@ def calendar_shared_ics(self, shared, **kwargs): ("Cache-Control", "no-store"), ], ) + + @route( + ["/calendar/shared//landing"], + type="http", + auth="public", + csrf=False, + sitemap=False, + ) + def calendar_shared_ics_landing(self, shared, **kwargs): + # Public landing page: user can copy/paste the https/webcal subscription URL. + # and also login in the backend, if internal + shared = shared.sudo() + if not shared.exists() or not shared.active: + return request.not_found() + if not shared._check_access_token(kwargs.get("access_token")): + return request.not_found() + shared._portal_ensure_token() + backend_url = "%s/web#id=%s&model=%s&view_type=form" % ( + shared.get_base_url(), + shared.id, + shared._name, + ) + return request.render( + "calendar_shared_ics.shared_ics_landing_page", + { + "shared": shared, + "ics_url": shared.share_url, + "webcal_url": shared.share_webcal_url, + "backend_url": backend_url, + }, + ) diff --git a/calendar_shared_ics/models/calendar_shared_ics.py b/calendar_shared_ics/models/calendar_shared_ics.py index 695850c4..9b74be84 100644 --- a/calendar_shared_ics/models/calendar_shared_ics.py +++ b/calendar_shared_ics/models/calendar_shared_ics.py @@ -25,6 +25,26 @@ class CalendarSharedIcs(models.Model): share_webcal_url = fields.Char(compute="_compute_share_urls", readonly=True) share_url = fields.Char(compute="_compute_share_urls", readonly=True) + def _compute_access_url(self): + """Adjust to modufy access url""" + res = super()._compute_access_url() + base_url = self.get_base_url() + for cal in self: + cal.access_url = f"{base_url}/calendar/shared/{cal.id}/ics" + return res + + @api.depends("access_token") + def _compute_share_urls(self): + """Compute actual share urls (https://, webcall:)""" + base_url = self.get_base_url() + for cal in self: + cal._portal_ensure_token() + ics_url = f"{base_url}/calendar/shared/{cal.id}/ics?access_token={cal.access_token}" + cal.share_url = ics_url + cal.share_webcal_url = ics_url.replace("https://", "webcal://").replace( + "http://", "webcal://" + ) + def action_reset_access_token(self): """Rotate token (invalidate old subscription URLs).""" for cal in self.sudo(): @@ -53,32 +73,9 @@ def action_open_share_wizard(self): } def _get_share_url(self, redirect=False, **kwargs): - """Direct user to their own calendar feed in backend""" self.ensure_one() - return f"/web#id={self.id}&model={self._name}&view_type=form" - - def _compute_access_url(self): - """Generic access url""" - res = super()._compute_access_url() - base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") - for cal in self: - cal.access_url = f"{base_url}/calendar/shared/{cal.id}/ics" - return res - - @api.depends("access_url", "access_token") - def _compute_share_urls(self): - """Links for configuring mail clients""" - for cal in self: - cal._portal_ensure_token() - if not cal.access_url: - cal.share_url = False - cal.share_webcal_url = False - continue - url = f"{cal.access_url}?access_token={cal.access_token}" - cal.share_url = url - cal.share_webcal_url = url.replace("https://", "webcal://").replace( - "http://", "webcal://" - ) + self._portal_ensure_token() + return f"/calendar/shared/{self.id}/landing?access_token={self.access_token}" def _check_access_token(self, token): self.ensure_one() @@ -86,18 +83,13 @@ def _check_access_token(self, token): return bool(token) and token == self.access_token def _get_events_domain(self): - """Compute the calendar.event domain for this shared feed. - - Raises on invalid domain to allow fail-closed behavior at the controller layer. - """ + """Compute the calendar.event domain for this shared feed.""" self.ensure_one() domain = [] if self.apply_partner_filter and self.partner_id: domain.append(("partner_ids", "in", [self.partner_id.id])) if self.domain: extra = safe_eval(self.domain.strip(), {"uid": self.env.uid}) - if not isinstance(extra, (list, tuple)): - raise ValueError("Domain must be a list/tuple of terms") domain += list(extra) return domain diff --git a/calendar_shared_ics/readme/CONTRIBUTORS.rst b/calendar_shared_ics/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..9c77b3e0 --- /dev/null +++ b/calendar_shared_ics/readme/CONTRIBUTORS.rst @@ -0,0 +1,5 @@ +------------ +Contributors +------------ + +* Nikos Tsirintanis diff --git a/calendar_shared_ics/readme/DESCRIPTION.rst b/calendar_shared_ics/readme/DESCRIPTION.rst new file mode 100644 index 00000000..664377c3 --- /dev/null +++ b/calendar_shared_ics/readme/DESCRIPTION.rst @@ -0,0 +1,29 @@ + +This module allows sharing an Odoo calendar as a **read-only ICS feed** that can be +subscribed to in external calendar clients such as **Outlook**, **Google Calendar**, +and **Thunderbird**. + +The calendar is exposed via a public URL protected by an **access token** and can be +added as a network calendar (ICS / webcal). + +Odoo provides bidirectional calendar synchronisation with external providers such as +Outlook and Google Calendar. However, in practice this integration is not always mature +and can suffer from functional limitations or bugs, especially in complex setups. + +This module offers a **simple and robust alternative** for one-directional use cases: + +* Events are exported from Odoo as a **read-only ICS calendar** +* External calendar clients periodically refresh the feed +* No changes are ever written back into Odoo + +Typical use cases include: + +* Sharing planning or resource bookings with employees +* Giving customers or partners insight into scheduled events +* Avoiding conflicts or data corruption caused by two-way sync + +⚠ **Security note** +The calendar is protected by an access token embedded in the URL. +Anyone who obtains this URL can view the calendar. +Treat the link as a secret. +R \ No newline at end of file diff --git a/calendar_shared_ics/readme/ROADMAP.rst b/calendar_shared_ics/readme/ROADMAP.rst new file mode 100644 index 00000000..c988fb1e --- /dev/null +++ b/calendar_shared_ics/readme/ROADMAP.rst @@ -0,0 +1,3 @@ +* Optional **IP restriction** for shared calendar feeds +* Optional **expiration date** for access tokens +* Per-feed refresh hints / cache control options diff --git a/calendar_shared_ics/readme/USAGE.rst b/calendar_shared_ics/readme/USAGE.rst new file mode 100644 index 00000000..18f959cf --- /dev/null +++ b/calendar_shared_ics/readme/USAGE.rst @@ -0,0 +1,24 @@ +1. **Create a shared calendar feed** + * Go to *Calendar → Configuration → Shared ICS* + * Create a new *Shared ICS calendar* + * Select the partner whose events should be exported + * Optionally define extra domain filters + +2. **Generate a share link** + * Click *Share* + * Use the standard Odoo sharing wizard + * An email will be sent containing a link to a landing page + +3. **Subscribe from an external calendar** + * Open the link from the email + * Copy the **webcal://** or **https://** URL + * Add it as a network / public calendar in your client + + Examples: + * **Outlook**: *Add calendar → Subscribe from web* + * **Google Calendar**: *Settings → Add calendar → From URL* + * **Thunderbird**: *New Calendar → On the Network → iCalendar (ICS)* + +4. **Rotate access token (optional)** + * If the link is compromised, use *Rotate access token* + * Old subscription URLs will stop working immediately diff --git a/calendar_shared_ics/static/description/index.html b/calendar_shared_ics/static/description/index.html new file mode 100644 index 00000000..5c823bac --- /dev/null +++ b/calendar_shared_ics/static/description/index.html @@ -0,0 +1,494 @@ + + + + + +Shared ICS calendars (token-protected) + + + +
+

Shared ICS calendars (token-protected)

+ + +

Beta License: AGPL-3 OCA/calendar Translate me on Weblate Try me on Runboat

+

This module allows sharing an Odoo calendar as a read-only ICS feed that can be +subscribed to in external calendar clients such as Outlook, Google Calendar, +and Thunderbird.

+

The calendar is exposed via a public URL protected by an access token and can be +added as a network calendar (ICS / webcal).

+

Odoo provides bidirectional calendar synchronisation with external providers such as +Outlook and Google Calendar. However, in practice this integration is not always mature +and can suffer from functional limitations or bugs, especially in complex setups.

+

This module offers a simple and robust alternative for one-directional use cases:

+
    +
  • Events are exported from Odoo as a read-only ICS calendar
  • +
  • External calendar clients periodically refresh the feed
  • +
  • No changes are ever written back into Odoo
  • +
+

Typical use cases include:

+
    +
  • Sharing planning or resource bookings with employees
  • +
  • Giving customers or partners insight into scheduled events
  • +
  • Avoiding conflicts or data corruption caused by two-way sync
  • +
+

Security note +The calendar is protected by an access token embedded in the URL. +Anyone who obtains this URL can view the calendar. +Treat the link as a secret. +R

+

Table of contents

+ +
+

Usage

+
    +
  1. Create a shared calendar feed +* Go to Calendar → Configuration → Shared ICS +* Create a new Shared ICS calendar +* Select the partner whose events should be exported +* Optionally define extra domain filters

    +
  2. +
  3. Generate a share link +* Click Share +* Use the standard Odoo sharing wizard +* An email will be sent containing a link to a landing page

    +
  4. +
  5. Subscribe from an external calendar +* Open the link from the email +* Copy the webcal:// or https:// URL +* Add it as a network / public calendar in your client

    +

    Examples: +* Outlook: Add calendar → Subscribe from web +* Google Calendar: Settings → Add calendar → From URL +* Thunderbird: New Calendar → On the Network → iCalendar (ICS)

    +
  6. +
  7. Rotate access token (optional) +* If the link is compromised, use Rotate access token +* Old subscription URLs will stop working immediately

    +
  8. +
+
+
+

Known issues / Roadmap

+
    +
  • Optional IP restriction for shared calendar feeds
  • +
  • Optional expiration date for access tokens
  • +
  • Per-feed refresh hints / cache control options
  • +
+
+
+

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 to smash it by providing a detailed and welcomed +feedback.

+

Do not contact contributors directly about support or help with technical issues.

+
+
+

Credits

+
+

Authors

+
    +
  • Therp BV
  • +
+
+
+

Contributors

+
+

Contributors

+ +
+
+
+

Maintainers

+

This module is maintained by the OCA.

+ +Odoo Community Association + +

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.

+

Current maintainer:

+

ntsirintanis

+

This module is part of the OCA/calendar project on GitHub.

+

You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute.

+
+
+
+ + diff --git a/calendar_shared_ics/tests/test_shared_ics.py b/calendar_shared_ics/tests/test_shared_ics.py index 7d13d5d5..83777740 100644 --- a/calendar_shared_ics/tests/test_shared_ics.py +++ b/calendar_shared_ics/tests/test_shared_ics.py @@ -19,7 +19,7 @@ def setUp(self): self.group_manager = self.env.ref( "calendar_shared_ics.group_calendar_shared_ics_manager" ) - self.marker = f"[ICS_NOHTTP_{self.__class__.__name__}]" + self.marker = f"[ICS{self.__class__.__name__}]" # Partners self.partner_mgr = self.Partners.create({"name": f"{self.marker} Mgr Partner"}) self.partner_u1 = self.Partners.create({"name": f"{self.marker} U1 Partner"}) @@ -52,7 +52,6 @@ def setUp(self): "groups_id": [Command.set([self.group_user.id])], } ) - # Feeds self.feed_u1 = self.Shared.sudo().create( { @@ -70,8 +69,7 @@ def setUp(self): ) self.feed_u1.sudo()._portal_ensure_token() self.feed_u2.sudo()._portal_ensure_token() - - # Events (real records to test domain/search) + # Events now = fields.Datetime.now() self.event_u1 = self.Event.sudo().create( { @@ -99,10 +97,40 @@ def test_user_can_only_read_own_feed(self): feeds_u1 = self.Shared.with_user(self.user_u1).search([]) self.assertIn(self.feed_u1, feeds_u1) self.assertNotIn(self.feed_u2, feeds_u1) + self.Shared.with_user(self.user_u1).browse(self.feed_u1.id).read(["name"]) with self.assertRaises(AccessError): self.Shared.with_user(self.user_u1).browse(self.feed_u2.id).read(["name"]) + def test_check_access_token(self): + token = self.feed_u1.sudo().access_token + self.assertTrue(self.feed_u1.sudo()._check_access_token(token)) + self.assertFalse(self.feed_u1.sudo()._check_access_token("WRONG")) + self.assertFalse(self.feed_u1.sudo()._check_access_token(False)) + + def test_get_share_url_points_to_landing(self): + # _get_share_url should return a relative URL to the public landing page + self.feed_u1.sudo()._portal_ensure_token() + token = self.feed_u1.sudo().access_token + url = self.feed_u1.sudo()._get_share_url() + self.assertIn(f"/calendar/shared/{self.feed_u1.id}/landing", url) + self.assertIn("access_token=", url) + self.assertIn(token, url) + + def test_computed_subscription_urls(self): + # These should be absolute and contain /ics + access_token + self.feed_u1.sudo()._portal_ensure_token() + token = self.feed_u1.sudo().access_token + share_url = self.feed_u1.sudo().share_url + webcal_url = self.feed_u1.sudo().share_webcal_url + self.assertTrue(share_url) + self.assertTrue(webcal_url) + self.assertIn(f"/calendar/shared/{self.feed_u1.id}/ics", share_url) + self.assertIn(f"access_token={token}", share_url) + self.assertIn(f"/calendar/shared/{self.feed_u1.id}/ics", webcal_url) + self.assertIn(f"access_token={token}", webcal_url) + self.assertTrue(webcal_url.startswith("webcal://")) + def test_domain_partner_filter(self): dom = self.feed_u1.sudo()._get_events_domain() self.assertIn(("partner_ids", "in", [self.partner_u1.id]), dom) @@ -132,8 +160,6 @@ def test_render_ics_content_contains_expected(self): b"BEGIN:VCALENDAR\r\nBEGIN:VEVENT\r\nSUMMARY:%s\r\nEND:VEVENT\r\nEND:VCALENDAR\r\n" % ((f"{self.marker} Event U1").encode("utf-8")) ) - - # Patch _get_ics_file on the recordset model class with patch.object( type(events), "_get_ics_file", return_value={self.event_u1.id: ics_u1} ): diff --git a/calendar_shared_ics/views/landing_page.xml b/calendar_shared_ics/views/landing_page.xml new file mode 100644 index 00000000..dd6f67e4 --- /dev/null +++ b/calendar_shared_ics/views/landing_page.xml @@ -0,0 +1,72 @@ + + + + From 2e128531de18e83421829d2e61b40d3145657b40 Mon Sep 17 00:00:00 2001 From: Nikos Tsirintanis Date: Wed, 14 Jan 2026 11:38:12 +0100 Subject: [PATCH 3/4] [ADD] more tests, more event filtering, more security, readme --- calendar_shared_ics/README.rst | 136 ++++++++++++------ .../models/calendar_shared_ics.py | 116 +++++++++++++-- calendar_shared_ics/readme/CONTRIBUTORS.rst | 4 - calendar_shared_ics/readme/DESCRIPTION.rst | 55 ++++--- calendar_shared_ics/readme/USAGE.rst | 85 +++++++---- .../static/description/index.html | 134 ++++++++++------- calendar_shared_ics/tests/test_shared_ics.py | 121 +++++++++++++++- .../views/calendar_shared_ics_views.xml | 20 ++- 8 files changed, 520 insertions(+), 151 deletions(-) diff --git a/calendar_shared_ics/README.rst b/calendar_shared_ics/README.rst index d97575bc..f6f5487d 100644 --- a/calendar_shared_ics/README.rst +++ b/calendar_shared_ics/README.rst @@ -28,35 +28,52 @@ Shared ICS calendars (token-protected) |badge1| |badge2| |badge3| |badge4| |badge5| +This module allows sharing an Odoo calendar as a read-only ICS feed that can be +subscribed to in external calendar clients such as Outlook, Google Calendar, +and Thunderbird. -This module allows sharing an Odoo calendar as a **read-only ICS feed** that can be -subscribed to in external calendar clients such as **Outlook**, **Google Calendar**, -and **Thunderbird**. +The calendar is exposed via a public URL protected by an access token and can be +added as a network calendar (ICS / ``webcal://``). External calendar clients periodically +refresh the feed, providing near-real-time visibility of Odoo events without any +write-back capability. -The calendar is exposed via a public URL protected by an **access token** and can be -added as a network calendar (ICS / webcal). +Odoo includes bidirectional calendar synchronisation with external providers such as +Outlook and Google Calendar. In practice, however, this integration is not always +sufficiently robust and can suffer from functional limitations, data inconsistencies, +or bugs—especially in environments with complex recurrence rules, shared resources, +or high event volumes. -Odoo provides bidirectional calendar synchronisation with external providers such as -Outlook and Google Calendar. However, in practice this integration is not always mature -and can suffer from functional limitations or bugs, especially in complex setups. - -This module offers a **simple and robust alternative** for one-directional use cases: +This module provides a simple, reliable, and intentionally one-directional +alternative: * Events are exported from Odoo as a **read-only ICS calendar** -* External calendar clients periodically refresh the feed +* External clients periodically fetch the feed * No changes are ever written back into Odoo +* Old events can be excluded by default to improve synchronisation performance Typical use cases include: -* Sharing planning or resource bookings with employees -* Giving customers or partners insight into scheduled events -* Avoiding conflicts or data corruption caused by two-way sync +* Sharing planning or resource bookings with internal employees +* Providing customers or partners with insight into scheduled events +* Publishing operational calendars without exposing the Odoo backend +* Avoiding conflicts or data corruption caused by two-way synchronisation + +Each shared calendar feed can be configured individually, including: + +* Restricting events to a specific attendee (partner) +* Applying additional custom event filters +* Limiting exports to recent and future events only +* Generating and rotating access tokens +* Sharing the feed via the standard Odoo portal share wizard -⚠ **Security note** -The calendar is protected by an access token embedded in the URL. -Anyone who obtains this URL can view the calendar. -Treat the link as a secret. -R +**Security notice** + +The calendar feed is protected solely by an **access token embedded in the URL**. +Anyone in possession of this URL can view the calendar contents. + +The token grants **read-only access** and does **not** allow modification of data or +configuration of the feed. Nevertheless, the URL should be treated as confidential and +shared only with trusted recipients. **Table of contents** @@ -66,30 +83,67 @@ R Usage ===== -1. **Create a shared calendar feed** - * Go to *Calendar → Configuration → Shared ICS* - * Create a new *Shared ICS calendar* - * Select the partner whose events should be exported - * Optionally define extra domain filters -2. **Generate a share link** - * Click *Share* - * Use the standard Odoo sharing wizard - * An email will be sent containing a link to a landing page +Only users with the *Shared ICS manager* role can create or modify +shared calendar feeds. + +#. Go to *Calendar → Configuration → Shared ICS* +#. Create a new *Shared ICS calendar* +#. Configure the feed: + + * Select the **partner** whose events should be exported + * Optionally restrict events further using **Additional Filtering** + * Optionally limit the feed to **recent and future events only** + * Adjust user-related filters to easily select internal or portal users + +Each record represents one independent, read-only calendar feed. + +Regular users can only **view their own feed** (if any) but cannot +create, modify, or delete shared calendars. + +Generate a share link + +#. Open the shared calendar feed record +#. Click **Share** +#. The standard Odoo *Share* wizard will open +#. Select one or more recipients and send the email + +The email contains a link to a landing page where the recipient can +copy the actual subscription URL. -3. **Subscribe from an external calendar** - * Open the link from the email - * Copy the **webcal://** or **https://** URL - * Add it as a network / public calendar in your client +Subscribe from an external calendar - Examples: - * **Outlook**: *Add calendar → Subscribe from web* - * **Google Calendar**: *Settings → Add calendar → From URL* - * **Thunderbird**: *New Calendar → On the Network → iCalendar (ICS)* +#. Open the link from the email +#. Copy either the **``webcal://``** or **``https://``** URL shown on the page +#. Add it as a network / public calendar in your calendar client -4. **Rotate access token (optional)** - * If the link is compromised, use *Rotate access token* - * Old subscription URLs will stop working immediately +Examples: + +* **Outlook** + + *Add calendar → Subscribe from web* + +* **Google Calendar** + + *Settings → Add calendar → From URL* + +* **Thunderbird** + + *New Calendar → On the Network → iCalendar (ICS)* + +The external calendar will periodically refresh the feed. +All events are **read-only** and cannot be modified from the external client. + +Rotate access token (optional) + +If a subscription URL is compromised: + +#. Open the shared calendar feed +#. Click **Rotate access token** +#. A new token is generated immediately + +All previously issued URLs stop working at once, while the new URL +continues to function without further configuration. Known issues / Roadmap ====================== @@ -119,10 +173,6 @@ Authors Contributors ~~~~~~~~~~~~ ------------- -Contributors ------------- - * Nikos Tsirintanis Maintainers diff --git a/calendar_shared_ics/models/calendar_shared_ics.py b/calendar_shared_ics/models/calendar_shared_ics.py index 9b74be84..4c2e1f02 100644 --- a/calendar_shared_ics/models/calendar_shared_ics.py +++ b/calendar_shared_ics/models/calendar_shared_ics.py @@ -1,5 +1,8 @@ # Copyright 2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +import re +from datetime import timedelta + import vobject from odoo import api, fields, models @@ -15,39 +18,88 @@ class CalendarSharedIcs(models.Model): name = fields.Char(required=True, default="Shared calendar") active = fields.Boolean(default=True) partner_id = fields.Many2one("res.partner", index=True) + apply_user_filter = fields.Boolean( + default=True, + help="If enabled, restrict partner selection to partners linked to users.", + ) + include_internal_users = fields.Boolean( + default=True, + help="If enabled, include internal users in partner selection.", + ) + include_portal_users = fields.Boolean( + default=False, + help="If enabled, include portal users in partner selection.", + ) + apply_partner_filter = fields.Boolean( default=True, help="If enabled, only events where this partner is an attendee are exported.", ) domain = fields.Char( help="Optional extra domain (to safe_eval)", + string="Additional Filtering", ) share_webcal_url = fields.Char(compute="_compute_share_urls", readonly=True) share_url = fields.Char(compute="_compute_share_urls", readonly=True) + only_recent_events = fields.Boolean( + default=True, + help="If enabled, only export events that ended recently or are in the future ", + ) + recent_days = fields.Integer( + default=7, + help="How many days back to include for recent events", + ) def _compute_access_url(self): - """Adjust to modufy access url""" + """Adjust to modify access url""" res = super()._compute_access_url() base_url = self.get_base_url() for cal in self: cal.access_url = f"{base_url}/calendar/shared/{cal.id}/ics" return res - @api.depends("access_token") + @api.depends("access_url", "access_token") def _compute_share_urls(self): - """Compute actual share urls (https://, webcall:)""" - base_url = self.get_base_url() for cal in self: cal._portal_ensure_token() - ics_url = f"{base_url}/calendar/shared/{cal.id}/ics?access_token={cal.access_token}" - cal.share_url = ics_url - cal.share_webcal_url = ics_url.replace("https://", "webcal://").replace( + if not cal.access_url: + cal.share_url = False + cal.share_webcal_url = False + continue + url = f"{cal.access_url}?access_token={cal.access_token}" + cal.share_url = url + cal.share_webcal_url = url.replace("https://", "webcal://").replace( "http://", "webcal://" ) + @api.onchange("apply_user_filter", "include_internal_users", "include_portal_users") + def _onchange_partner_id_domain(self): + """Restrict partner_id selection to partners that are linked to users.""" + if not self.apply_user_filter: + return {"domain": {"partner_id": []}} + if not self.include_internal_users and not self.include_portal_users: + return {"domain": {"partner_id": [("id", "=", 0)]}} + user_domain = [] + if self.include_internal_users: + user_domain.append(("groups_id", "in", self.env.ref("base.group_user").id)) + if self.include_portal_users: + user_domain.append( + ("groups_id", "in", self.env.ref("base.group_portal").id) + ) + # if both at ticked, and an OR + if len(user_domain) == 1: + users = self.env["res.users"].search(user_domain) + else: + users = self.env["res.users"].search(["|"] + user_domain) + partner_ids = users.mapped("partner_id").ids + return {"domain": {"partner_id": [("id", "in", partner_ids)]}} + def action_reset_access_token(self): """Rotate token (invalidate old subscription URLs).""" - for cal in self.sudo(): + # Enforce security + self.check_access_rights("write") + self.check_access_rule("write") + for cal in self: cal.access_token = False cal._portal_ensure_token() @@ -86,6 +138,10 @@ def _get_events_domain(self): """Compute the calendar.event domain for this shared feed.""" self.ensure_one() domain = [] + # limit events to recent_days in the past + if self.only_recent_events: + cutoff = fields.Datetime.now() - timedelta(days=self.recent_days or 0) + domain.append(("stop", ">=", cutoff)) if self.apply_partner_filter and self.partner_id: domain.append(("partner_ids", "in", [self.partner_id.id])) if self.domain: @@ -99,6 +155,49 @@ def _get_events_for_export(self): domain = self._get_events_domain() return self.env["calendar.event"].sudo().search(domain, order="start asc") + def _fix_recurrence_lines(self, ics_text): + """ + Fix malformed recurrence lines seen in some _get_ics_file() outputs. + Wrong: RRULE:DTSTART:20220425T063000 + Right: DTSTART:20220425T063000 + """ + if "RRULE:DTSTART:" not in ics_text: + return ics_text + # If the VEVENT already has a proper DTSTART, drop the malformed line. + # Otherwise, rewrite the malformed line into DTSTART. + fixed_blocks = [] + in_vevent = False + vevent_lines = [] + for line in ics_text.splitlines(True): + if line.startswith("BEGIN:VEVENT"): + in_vevent = True + vevent_lines = [line] + continue + if in_vevent: + vevent_lines.append(line) + if line.startswith("END:VEVENT"): + block = "".join(vevent_lines) + if "RRULE:DTSTART:" in block: + if "DTSTART:" in block: + block = re.sub( + r"^RRULE:DTSTART:.*\r?\n", + "", + block, + flags=re.MULTILINE, + ) + else: + block = re.sub( + r"^RRULE:DTSTART:(.+)$", + r"DTSTART:\1", + block, + flags=re.MULTILINE, + ) + fixed_blocks.append(block) + in_vevent = False + continue + fixed_blocks.append(line) + return "".join(fixed_blocks) + def _combine_ics_files(self, events, files_by_event_id): """Combine individual VCALENDAR payloads into one VCALENDAR bytes.""" combined = vobject.iCalendar() @@ -107,6 +206,7 @@ def _combine_ics_files(self, events, files_by_event_id): if not payload: continue ics_text = payload.decode("utf-8", errors="replace") + ics_text = self._fix_recurrence_lines(ics_text) cal = vobject.readOne(ics_text) for vev in cal.vevent_list: combined.add(vev) diff --git a/calendar_shared_ics/readme/CONTRIBUTORS.rst b/calendar_shared_ics/readme/CONTRIBUTORS.rst index 9c77b3e0..f2715168 100644 --- a/calendar_shared_ics/readme/CONTRIBUTORS.rst +++ b/calendar_shared_ics/readme/CONTRIBUTORS.rst @@ -1,5 +1 @@ ------------- -Contributors ------------- - * Nikos Tsirintanis diff --git a/calendar_shared_ics/readme/DESCRIPTION.rst b/calendar_shared_ics/readme/DESCRIPTION.rst index 664377c3..9352124e 100644 --- a/calendar_shared_ics/readme/DESCRIPTION.rst +++ b/calendar_shared_ics/readme/DESCRIPTION.rst @@ -1,29 +1,46 @@ +This module allows sharing an Odoo calendar as a read-only ICS feed that can be +subscribed to in external calendar clients such as Outlook, Google Calendar, +and Thunderbird. -This module allows sharing an Odoo calendar as a **read-only ICS feed** that can be -subscribed to in external calendar clients such as **Outlook**, **Google Calendar**, -and **Thunderbird**. +The calendar is exposed via a public URL protected by an access token and can be +added as a network calendar (ICS / ``webcal://``). External calendar clients periodically +refresh the feed, providing near-real-time visibility of Odoo events without any +write-back capability. -The calendar is exposed via a public URL protected by an **access token** and can be -added as a network calendar (ICS / webcal). +Odoo includes bidirectional calendar synchronisation with external providers such as +Outlook and Google Calendar. In practice, however, this integration is not always +sufficiently robust and can suffer from functional limitations, data inconsistencies, +or bugs—especially in environments with complex recurrence rules, shared resources, +or high event volumes. -Odoo provides bidirectional calendar synchronisation with external providers such as -Outlook and Google Calendar. However, in practice this integration is not always mature -and can suffer from functional limitations or bugs, especially in complex setups. - -This module offers a **simple and robust alternative** for one-directional use cases: +This module provides a simple, reliable, and intentionally one-directional +alternative: * Events are exported from Odoo as a **read-only ICS calendar** -* External calendar clients periodically refresh the feed +* External clients periodically fetch the feed * No changes are ever written back into Odoo +* Old events can be excluded by default to improve synchronisation performance Typical use cases include: -* Sharing planning or resource bookings with employees -* Giving customers or partners insight into scheduled events -* Avoiding conflicts or data corruption caused by two-way sync +* Sharing planning or resource bookings with internal employees +* Providing customers or partners with insight into scheduled events +* Publishing operational calendars without exposing the Odoo backend +* Avoiding conflicts or data corruption caused by two-way synchronisation + +Each shared calendar feed can be configured individually, including: + +* Restricting events to a specific attendee (partner) +* Applying additional custom event filters +* Limiting exports to recent and future events only +* Generating and rotating access tokens +* Sharing the feed via the standard Odoo portal share wizard + +**Security notice** + +The calendar feed is protected solely by an **access token embedded in the URL**. +Anyone in possession of this URL can view the calendar contents. -⚠ **Security note** -The calendar is protected by an access token embedded in the URL. -Anyone who obtains this URL can view the calendar. -Treat the link as a secret. -R \ No newline at end of file +The token grants **read-only access** and does **not** allow modification of data or +configuration of the feed. Nevertheless, the URL should be treated as confidential and +shared only with trusted recipients. diff --git a/calendar_shared_ics/readme/USAGE.rst b/calendar_shared_ics/readme/USAGE.rst index 18f959cf..1ac2dfbc 100644 --- a/calendar_shared_ics/readme/USAGE.rst +++ b/calendar_shared_ics/readme/USAGE.rst @@ -1,24 +1,61 @@ -1. **Create a shared calendar feed** - * Go to *Calendar → Configuration → Shared ICS* - * Create a new *Shared ICS calendar* - * Select the partner whose events should be exported - * Optionally define extra domain filters - -2. **Generate a share link** - * Click *Share* - * Use the standard Odoo sharing wizard - * An email will be sent containing a link to a landing page - -3. **Subscribe from an external calendar** - * Open the link from the email - * Copy the **webcal://** or **https://** URL - * Add it as a network / public calendar in your client - - Examples: - * **Outlook**: *Add calendar → Subscribe from web* - * **Google Calendar**: *Settings → Add calendar → From URL* - * **Thunderbird**: *New Calendar → On the Network → iCalendar (ICS)* - -4. **Rotate access token (optional)** - * If the link is compromised, use *Rotate access token* - * Old subscription URLs will stop working immediately + +Only users with the *Shared ICS manager* role can create or modify +shared calendar feeds. + +#. Go to *Calendar → Configuration → Shared ICS* +#. Create a new *Shared ICS calendar* +#. Configure the feed: + + * Select the **partner** whose events should be exported + * Optionally restrict events further using **Additional Filtering** + * Optionally limit the feed to **recent and future events only** + * Adjust user-related filters to easily select internal or portal users + +Each record represents one independent, read-only calendar feed. + +Regular users can only **view their own feed** (if any) but cannot +create, modify, or delete shared calendars. + +Generate a share link + +#. Open the shared calendar feed record +#. Click **Share** +#. The standard Odoo *Share* wizard will open +#. Select one or more recipients and send the email + +The email contains a link to a landing page where the recipient can +copy the actual subscription URL. + +Subscribe from an external calendar + +#. Open the link from the email +#. Copy either the **``webcal://``** or **``https://``** URL shown on the page +#. Add it as a network / public calendar in your calendar client + +Examples: + +* **Outlook** + + *Add calendar → Subscribe from web* + +* **Google Calendar** + + *Settings → Add calendar → From URL* + +* **Thunderbird** + + *New Calendar → On the Network → iCalendar (ICS)* + +The external calendar will periodically refresh the feed. +All events are **read-only** and cannot be modified from the external client. + +Rotate access token (optional) + +If a subscription URL is compromised: + +#. Open the shared calendar feed +#. Click **Rotate access token** +#. A new token is generated immediately + +All previously issued URLs stop working at once, while the new URL +continues to function without further configuration. diff --git a/calendar_shared_ics/static/description/index.html b/calendar_shared_ics/static/description/index.html index 5c823bac..6ef4216c 100644 --- a/calendar_shared_ics/static/description/index.html +++ b/calendar_shared_ics/static/description/index.html @@ -370,31 +370,47 @@

Shared ICS calendars (token-protected)

!! source digest: sha256:490396883f2f27e9e987064ece49696f3e0dcc53e355b186debb2d5c642e61f3 !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! -->

Beta License: AGPL-3 OCA/calendar Translate me on Weblate Try me on Runboat

-

This module allows sharing an Odoo calendar as a read-only ICS feed that can be -subscribed to in external calendar clients such as Outlook, Google Calendar, -and Thunderbird.

-

The calendar is exposed via a public URL protected by an access token and can be -added as a network calendar (ICS / webcal).

-

Odoo provides bidirectional calendar synchronisation with external providers such as -Outlook and Google Calendar. However, in practice this integration is not always mature -and can suffer from functional limitations or bugs, especially in complex setups.

-

This module offers a simple and robust alternative for one-directional use cases:

+

This module allows sharing an Odoo calendar as a read-only ICS feed that can be +subscribed to in external calendar clients such as Outlook, Google Calendar, +and Thunderbird.

+

The calendar is exposed via a public URL protected by an access token and can be +added as a network calendar (ICS / webcal://). External calendar clients periodically +refresh the feed, providing near-real-time visibility of Odoo events without any +write-back capability.

+

Odoo includes bidirectional calendar synchronisation with external providers such as +Outlook and Google Calendar. In practice, however, this integration is not always +sufficiently robust and can suffer from functional limitations, data inconsistencies, +or bugs—especially in environments with complex recurrence rules, shared resources, +or high event volumes.

+

This module provides a simple, reliable, and intentionally one-directional +alternative:

  • Events are exported from Odoo as a read-only ICS calendar
  • -
  • External calendar clients periodically refresh the feed
  • +
  • External clients periodically fetch the feed
  • No changes are ever written back into Odoo
  • +
  • Old events can be excluded by default to improve synchronisation performance

Typical use cases include:

    -
  • Sharing planning or resource bookings with employees
  • -
  • Giving customers or partners insight into scheduled events
  • -
  • Avoiding conflicts or data corruption caused by two-way sync
  • +
  • Sharing planning or resource bookings with internal employees
  • +
  • Providing customers or partners with insight into scheduled events
  • +
  • Publishing operational calendars without exposing the Odoo backend
  • +
  • Avoiding conflicts or data corruption caused by two-way synchronisation
-

Security note -The calendar is protected by an access token embedded in the URL. -Anyone who obtains this URL can view the calendar. -Treat the link as a secret. -R

+

Each shared calendar feed can be configured individually, including:

+
    +
  • Restricting events to a specific attendee (partner)
  • +
  • Applying additional custom event filters
  • +
  • Limiting exports to recent and future events only
  • +
  • Generating and rotating access tokens
  • +
  • Sharing the feed via the standard Odoo portal share wizard
  • +
+

Security notice

+

The calendar feed is protected solely by an access token embedded in the URL. +Anyone in possession of this URL can view the calendar contents.

+

The token grants read-only access and does not allow modification of data or +configuration of the feed. Nevertheless, the URL should be treated as confidential and +shared only with trusted recipients.

Table of contents

Usage

-
    -
  1. Create a shared calendar feed -* Go to Calendar → Configuration → Shared ICS -* Create a new Shared ICS calendar -* Select the partner whose events should be exported -* Optionally define extra domain filters

    +

    Only users with the Shared ICS manager role can create or modify +shared calendar feeds.

    +
      +
    1. Go to Calendar → Configuration → Shared ICS
    2. +
    3. Create a new Shared ICS calendar
    4. +
    5. Configure the feed:
        +
      • Select the partner whose events should be exported
      • +
      • Optionally restrict events further using Additional Filtering
      • +
      • Optionally limit the feed to recent and future events only
      • +
      • Adjust user-related filters to easily select internal or portal users
      • +
    6. -
    7. Generate a share link -* Click Share -* Use the standard Odoo sharing wizard -* An email will be sent containing a link to a landing page

      +
    +

    Each record represents one independent, read-only calendar feed.

    +

    Regular users can only view their own feed (if any) but cannot +create, modify, or delete shared calendars.

    +

    Generate a share link

    +
      +
    1. Open the shared calendar feed record
    2. +
    3. Click Share
    4. +
    5. The standard Odoo Share wizard will open
    6. +
    7. Select one or more recipients and send the email
    8. +
    +

    The email contains a link to a landing page where the recipient can +copy the actual subscription URL.

    +

    Subscribe from an external calendar

    +
      +
    1. Open the link from the email
    2. +
    3. Copy either the ``webcal://`` or ``https://`` URL shown on the page
    4. +
    5. Add it as a network / public calendar in your calendar client
    6. +
    +

    Examples:

    +
      +
    • Outlook

      +

      Add calendar → Subscribe from web

    • -
    • Subscribe from an external calendar -* Open the link from the email -* Copy the webcal:// or https:// URL -* Add it as a network / public calendar in your client

      -

      Examples: -* Outlook: Add calendar → Subscribe from web -* Google Calendar: Settings → Add calendar → From URL -* Thunderbird: New Calendar → On the Network → iCalendar (ICS)

      +
    • Google Calendar

      +

      Settings → Add calendar → From URL

    • -
    • Rotate access token (optional) -* If the link is compromised, use Rotate access token -* Old subscription URLs will stop working immediately

      +
    • Thunderbird

      +

      New Calendar → On the Network → iCalendar (ICS)

    • +
    +

    The external calendar will periodically refresh the feed. +All events are read-only and cannot be modified from the external client.

    +

    Rotate access token (optional)

    +

    If a subscription URL is compromised:

    +
      +
    1. Open the shared calendar feed
    2. +
    3. Click Rotate access token
    4. +
    5. A new token is generated immediately
    +

    All previously issued URLs stop working at once, while the new URL +continues to function without further configuration.

Known issues / Roadmap

@@ -467,15 +508,12 @@

Authors

-

Maintainers

+

Maintainers

This module is maintained by the OCA.

Odoo Community Association diff --git a/calendar_shared_ics/tests/test_shared_ics.py b/calendar_shared_ics/tests/test_shared_ics.py index 83777740..1c6ffa46 100644 --- a/calendar_shared_ics/tests/test_shared_ics.py +++ b/calendar_shared_ics/tests/test_shared_ics.py @@ -1,8 +1,11 @@ # Copyright 2025 Therp BV # License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl.html) +from datetime import timedelta from unittest.mock import patch -from odoo import Command, fields +import vobject + +from odoo import fields from odoo.exceptions import AccessError from odoo.tests.common import TransactionCase, tagged @@ -31,7 +34,7 @@ def setUp(self): "login": "ics_nohttp_manager", "password": "ics_nohttp_manager", "partner_id": self.partner_mgr.id, - "groups_id": [Command.set([self.group_user.id, self.group_manager.id])], + "groups_id": [(6, 0, [self.group_user.id, self.group_manager.id])], } ) self.user_u1 = self.Users.create( @@ -40,7 +43,7 @@ def setUp(self): "login": "ics_nohttp_user1", "password": "ics_nohttp_user1", "partner_id": self.partner_u1.id, - "groups_id": [Command.set([self.group_user.id])], + "groups_id": [(6, 0, [self.group_user.id])], } ) self.user_u2 = self.Users.create( @@ -49,7 +52,7 @@ def setUp(self): "login": "ics_nohttp_user2", "password": "ics_nohttp_user2", "partner_id": self.partner_u2.id, - "groups_id": [Command.set([self.group_user.id])], + "groups_id": [(6, 0, [self.group_user.id])], } ) # Feeds @@ -168,3 +171,113 @@ def test_render_ics_content_contains_expected(self): self.assertIn("BEGIN:VCALENDAR", text) self.assertIn(f"{self.marker} Event U1", text) self.assertIn("END:VCALENDAR", text) + + def test_rrule_dtstart(self): + events = self.feed_u1.sudo()._get_events_for_export() + self.assertEqual(events, self.event_u1) + + malformed = ( + b"BEGIN:VCALENDAR\r\n" + b"VERSION:2.0\r\n" + b"PRODID:-//TEST//EN\r\n" + b"BEGIN:VEVENT\r\n" + b"UID:test-uid-1\r\n" + b"RRULE:DTSTART:20220425T063000\r\n" + b"RRULE:FREQ=WEEKLY;UNTIL=20220704T235959;BYDAY=MO\r\n" + b"SUMMARY:Recurrent Test\r\n" + b"END:VEVENT\r\n" + b"END:VCALENDAR\r\n" + ) + with patch.object( + type(events), "_get_ics_file", return_value={self.event_u1.id: malformed} + ): + out = self.feed_u1.sudo()._render_ics_content() + text = out.decode("utf-8", errors="replace") + # The malformed line must be gone + self.assertNotIn("RRULE:DTSTART:", text) + # RRULE should remain present + self.assertIn("RRULE:FREQ=WEEKLY;UNTIL=20220704T235959;BYDAY=MO", text) + # Ensure the output is valid VCALENDAR and VEVENT has dtstart + rrule + cal = vobject.readOne(text) + self.assertTrue(getattr(cal, "vevent_list", None)) + ve = cal.vevent_list[0] + self.assertTrue(hasattr(ve, "rrule")) + self.assertIn("FREQ=WEEKLY", ve.rrule.value) + + def test_rrule_dtstart_dropped(self): + events = self.feed_u1.sudo()._get_events_for_export() + self.assertEqual(events, self.event_u1) + malformed_with_dtstart = ( + b"BEGIN:VCALENDAR\r\n" + b"VERSION:2.0\r\n" + b"PRODID:-//TEST//EN\r\n" + b"BEGIN:VEVENT\r\n" + b"UID:test-uid-2\r\n" + b"DTSTART:20220425T063000\r\n" + b"RRULE:DTSTART:20220425T063000\r\n" + b"RRULE:FREQ=WEEKLY;UNTIL=20220704T235959;BYDAY=MO\r\n" + b"SUMMARY:Recurrent Test 2\r\n" + b"END:VEVENT\r\n" + b"END:VCALENDAR\r\n" + ) + with patch.object( + type(events), + "_get_ics_file", + return_value={self.event_u1.id: malformed_with_dtstart}, + ): + out = self.feed_u1.sudo()._render_ics_content() + text = out.decode("utf-8", errors="replace") + # Malformed line removed + self.assertNotIn("RRULE:DTSTART:", text) + # DTSTART should remain, but only once (no duplicates) + self.assertEqual(text.count("DTSTART:20220425T063000"), 1) + + def test_only_recent_events(self): + # Create an old event (ended 10 days ago) + old_stop = fields.Datetime.now() - timedelta(days=10) + old_start = old_stop + old_event = self.Event.sudo().create( + { + "name": f"{self.marker} Old Event U1", + "start": old_start, + "stop": old_stop, + "partner_ids": [(6, 0, [self.partner_u1.id])], + } + ) + # Default is only_recent_events=True and recent_days=7 + self.feed_u1.sudo().only_recent_events = True + self.feed_u1.sudo().recent_days = 7 + events = self.feed_u1.sudo()._get_events_for_export() + self.assertIn(self.event_u1, events) + self.assertNotIn(old_event, events) + + def test_not_only_recent_events(self): + old_stop = fields.Datetime.now() - timedelta(days=10) + old_start = old_stop + old_event = self.Event.sudo().create( + { + "name": f"{self.marker} Old Event U1 2", + "start": old_start, + "stop": old_stop, + "partner_ids": [(6, 0, [self.partner_u1.id])], + } + ) + self.feed_u1.sudo().only_recent_events = False + events = self.feed_u1.sudo()._get_events_for_export() + self.assertIn(self.event_u1, events) + self.assertIn(old_event, events) + + def test_reset_access_token(self): + feed = self.feed_u1 + old_token = feed.sudo().access_token + self.assertTrue(old_token) + # Regular user must NOT be able to rotate token + with self.assertRaises(AccessError): + feed.with_user(self.user_u1).action_reset_access_token() + feed_refreshed = self.Shared.sudo().browse(feed.id) + self.assertEqual(feed_refreshed.access_token, old_token) + # Manager should be allowed to rotate + feed.with_user(self.user_mgr).action_reset_access_token() + feed_refreshed2 = self.Shared.sudo().browse(feed.id) + self.assertTrue(feed_refreshed2.access_token) + self.assertNotEqual(feed_refreshed2.access_token, old_token) diff --git a/calendar_shared_ics/views/calendar_shared_ics_views.xml b/calendar_shared_ics/views/calendar_shared_ics_views.xml index 1ee41f86..a6b495f6 100644 --- a/calendar_shared_ics/views/calendar_shared_ics_views.xml +++ b/calendar_shared_ics/views/calendar_shared_ics_views.xml @@ -38,9 +38,27 @@ + + + - + + + From ae434465332c7fa2b603107721411536d1972cf6 Mon Sep 17 00:00:00 2001 From: Nikos Tsirintanis Date: Thu, 15 Jan 2026 10:32:46 +0100 Subject: [PATCH 4/4] fixup! [ADD] more tests, more event filtering, more security, readme --- .../models/calendar_shared_ics.py | 50 +++++++++++-------- .../views/calendar_shared_ics_views.xml | 18 +++++-- 2 files changed, 44 insertions(+), 24 deletions(-) diff --git a/calendar_shared_ics/models/calendar_shared_ics.py b/calendar_shared_ics/models/calendar_shared_ics.py index 4c2e1f02..ae45641e 100644 --- a/calendar_shared_ics/models/calendar_shared_ics.py +++ b/calendar_shared_ics/models/calendar_shared_ics.py @@ -49,6 +49,12 @@ class CalendarSharedIcs(models.Model): default=7, help="How many days back to include for recent events", ) + allowed_partner_ids = fields.Many2many( + "res.partner", + compute="_compute_allowed_partner_ids", + compute_sudo=True, + help="Partners selectable in partner_id according to the user filter options.", + ) def _compute_access_url(self): """Adjust to modify access url""" @@ -72,27 +78,29 @@ def _compute_share_urls(self): "http://", "webcal://" ) - @api.onchange("apply_user_filter", "include_internal_users", "include_portal_users") - def _onchange_partner_id_domain(self): - """Restrict partner_id selection to partners that are linked to users.""" - if not self.apply_user_filter: - return {"domain": {"partner_id": []}} - if not self.include_internal_users and not self.include_portal_users: - return {"domain": {"partner_id": [("id", "=", 0)]}} - user_domain = [] - if self.include_internal_users: - user_domain.append(("groups_id", "in", self.env.ref("base.group_user").id)) - if self.include_portal_users: - user_domain.append( - ("groups_id", "in", self.env.ref("base.group_portal").id) - ) - # if both at ticked, and an OR - if len(user_domain) == 1: - users = self.env["res.users"].search(user_domain) - else: - users = self.env["res.users"].search(["|"] + user_domain) - partner_ids = users.mapped("partner_id").ids - return {"domain": {"partner_id": [("id", "in", partner_ids)]}} + @api.depends("apply_user_filter", "include_internal_users", "include_portal_users") + def _compute_allowed_partner_ids(self): + Users = self.env["res.users"].sudo() + for cal in self: + if not cal.apply_user_filter: + cal.allowed_partner_ids = False + continue + # If neither is selected allow none. + if not cal.include_internal_users and not cal.include_portal_users: + cal.allowed_partner_ids = [(6, 0, [])] + continue + domain = [] + parts = [] + if cal.include_internal_users: + parts.append([("share", "=", False)]) + if cal.include_portal_users: + parts.append([("share", "=", True)]) + if len(parts) == 1: + domain = parts[0] + else: + domain = ["|"] + parts[0] + parts[1] + users = Users.search(domain) + cal.allowed_partner_ids = [(6, 0, users.mapped("partner_id").ids)] def action_reset_access_token(self): """Rotate token (invalidate old subscription URLs).""" diff --git a/calendar_shared_ics/views/calendar_shared_ics_views.xml b/calendar_shared_ics/views/calendar_shared_ics_views.xml index a6b495f6..10c7160f 100644 --- a/calendar_shared_ics/views/calendar_shared_ics_views.xml +++ b/calendar_shared_ics/views/calendar_shared_ics_views.xml @@ -47,7 +47,11 @@ name="include_portal_users" attrs="{'invisible': [('apply_user_filter', '=', False)]}" /> - + + - - + +