diff --git a/calendar_shared_ics/README.rst b/calendar_shared_ics/README.rst new file mode 100644 index 00000000..f6f5487d --- /dev/null +++ b/calendar_shared_ics/README.rst @@ -0,0 +1,201 @@ +====================================== +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://``). 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 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 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. + +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** + +.. contents:: + :local: + +Usage +===== + + +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. + +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 +~~~~~~~~~~~~ + +* 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/__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..2279325e --- /dev/null +++ b/calendar_shared_ics/__manifest__.py @@ -0,0 +1,23 @@ +# 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", + "views/landing_page.xml", + ], + "installable": True, + "application": False, + "external_dependencies": {"python": ["vobject"]}, + "maintainers": ["ntsirintanis"], +} 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..7e620a52 --- /dev/null +++ b/calendar_shared_ics/controllers/main.py @@ -0,0 +1,65 @@ +# 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"), + ], + ) + + @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/__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..ae45641e --- /dev/null +++ b/calendar_shared_ics/models/calendar_shared_ics.py @@ -0,0 +1,233 @@ +# 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 +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_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", + ) + 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""" + 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_url", "access_token") + def _compute_share_urls(self): + 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://" + ) + + @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).""" + # Enforce security + self.check_access_rights("write") + self.check_access_rule("write") + for cal in self: + 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): + self.ensure_one() + 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() + 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.""" + 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: + extra = safe_eval(self.domain.strip(), {"uid": self.env.uid}) + 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 _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() + for ev in events: + payload = files_by_event_id.get(ev.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) + 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/readme/CONTRIBUTORS.rst b/calendar_shared_ics/readme/CONTRIBUTORS.rst new file mode 100644 index 00000000..f2715168 --- /dev/null +++ b/calendar_shared_ics/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* Nikos Tsirintanis diff --git a/calendar_shared_ics/readme/DESCRIPTION.rst b/calendar_shared_ics/readme/DESCRIPTION.rst new file mode 100644 index 00000000..9352124e --- /dev/null +++ b/calendar_shared_ics/readme/DESCRIPTION.rst @@ -0,0 +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. + +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 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 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. + +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/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..1ac2dfbc --- /dev/null +++ b/calendar_shared_ics/readme/USAGE.rst @@ -0,0 +1,61 @@ + +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/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/static/description/index.html b/calendar_shared_ics/static/description/index.html new file mode 100644 index 00000000..6ef4216c --- /dev/null +++ b/calendar_shared_ics/static/description/index.html @@ -0,0 +1,532 @@ + + + + + +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://). 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 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 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.

+

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

+

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. +
+

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

    +
  • +
  • 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:

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

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

+
+
+

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

+ +
+
+

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/__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..1c6ffa46 --- /dev/null +++ b/calendar_shared_ics/tests/test_shared_ics.py @@ -0,0 +1,283 @@ +# 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 + +import vobject + +from odoo import 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{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": [(6, 0, [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": [(6, 0, [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": [(6, 0, [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 + 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_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) + 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")) + ) + 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) + + 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 new file mode 100644 index 00000000..10c7160f --- /dev/null +++ b/calendar_shared_ics/views/calendar_shared_ics_views.xml @@ -0,0 +1,103 @@ + + + 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/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 @@ + + + + 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, +)