diff --git a/base_ical/README.rst b/base_ical/README.rst new file mode 100644 index 000000000..b90b1532c --- /dev/null +++ b/base_ical/README.rst @@ -0,0 +1,173 @@ +================================ +Readonly publishing of calendars +================================ + +.. + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! source digest: sha256:fcc362c6ec99b15b1310731ed8fe11a12ee242565f3b449ad387f663683ae54d + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |badge1| image:: https://img.shields.io/badge/maturity-Alpha-red.png + :target: https://odoo-community.org/page/development-status + :alt: Alpha +.. |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%2Fserver--backend-lightgray.png?logo=github + :target: https://github.com/OCA/server-backend/tree/18.0/base_ical + :alt: OCA/server-backend +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/server-backend-18-0/server-backend-18-0-base_ical + :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/server-backend&target_branch=18.0 + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module allows administrators to configure iCalendars based on an +arbitrary selection on arbitrary models. + +Users can selectively subscribe to them by enabling them in their +profile form. + +This is useful for exposing Odoo data to calendaring application like +Nextcloud. + +.. IMPORTANT:: + This is an alpha version, the data model and design can change at any time without warning. + Only for development or testing purpose, do not use in production. + `More details on development status `_ + +**Table of contents** + +.. contents:: + :local: + +Configuration +============= + +To configure this module, you need to: + +1. Go to Settings/Technical/iCalendars +2. Create a iCalendar, fill in the model you want to expose and possibly + a domain to restrict records. You can use the ``user`` variable to + restrict things relative to the user using the iCalendar +3. A iCalendar is only available to the allowed users. Use the Allow + automatically to make the iCalendar available to all users +4. See the examples below for a start + +Examples +-------- + +Simple example, for model ``calendar.event``, you'd fill in +``record.allday and record.start_date or record.start`` as DTSTART and +``record.allday and record.stop_date or record.stop`` as DTEND. + +Advanced example, for model ``calendar.event``, you'd use +``calendar = record._get_ics_file()`` in the code. + +Advanced example, for model ``hr.leave``, you can use the following code +and ``[("employee_id.user_id", "=", user.id)]`` in the domain to export +the own time offs. This is a bit more complex because of the way Odoo +handles the begin and end times of leaves, and you'll want the extra day +as most clients interpret the end date as non-inclusive.: + +.. code:: python + + confirmed = ("validate", "validate1") + if record.request_unit_half or record.request_unit_hours: + event = { + "dtstart": event["dtstart"].date(), + "dtend": event["dtend"].date() + timedelta(days=1), + } + else: + event = { + "dtstart": record.date_from, + "dtend": record.date_to, + } + + event["summary"] = record.name + event["status"] = "CONFIRMED" if record.state in confirmed else "TENTATIVE" + +Advanced example, for model ``mail.activity``, you can use the following +code and ``[("user_id", "=", user.id)]`` and domain to export all user +activities. + +.. code:: python + + todo = { + "summary": record.display_name, + "due": record.date_deadline, + "description": html2plaintext(record.note) if record.note else "" + } + +Usage +===== + +To use this module, you need to: + +1. Go to your profile form +2. Click Enable on one of the calendars listed in tab Calendars +3. Copy the URL to the application you use + +Known issues / Roadmap +====================== + +- support all of + https://datatracker.ietf.org/doc/html/rfc5545#section-3.8 +- allow users to define their own calendars + +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 +------- + +* Hunki Enterprises BV + +Contributors +------------ + +- Holger Brunn + (https://hunki-enterprises.com) +- Florian Kantelberg + (https://www.initos.com) + +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-hbrunn| image:: https://github.com/hbrunn.png?size=40px + :target: https://github.com/hbrunn + :alt: hbrunn + +Current `maintainer `__: + +|maintainer-hbrunn| + +This module is part of the `OCA/server-backend `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/base_ical/__init__.py b/base_ical/__init__.py new file mode 100644 index 000000000..f7209b171 --- /dev/null +++ b/base_ical/__init__.py @@ -0,0 +1,2 @@ +from . import models +from . import controllers diff --git a/base_ical/__manifest__.py b/base_ical/__manifest__.py new file mode 100644 index 000000000..28699d408 --- /dev/null +++ b/base_ical/__manifest__.py @@ -0,0 +1,28 @@ +# Copyright 2023 Hunki Enterprises BV +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +{ + "name": "Readonly publishing of calendars", + "summary": "Provide (readonly) .ics URLs to calendar-like models", + "version": "18.0.1.0.0", + "development_status": "Alpha", + "category": "Tools", + "website": "https://github.com/OCA/server-backend", + "author": "Hunki Enterprises BV, Odoo Community Association (OCA)", + "maintainers": ["hbrunn"], + "license": "AGPL-3", + "external_dependencies": { + "python": ["vobject"], + }, + "depends": [ + "web", + ], + "data": [ + "security/ir.model.access.csv", + "views/base_ical.xml", + "views/res_users.xml", + ], + "demo": [ + "demo/base_ical.xml", + ], +} diff --git a/base_ical/controllers/__init__.py b/base_ical/controllers/__init__.py new file mode 100644 index 000000000..12a7e529b --- /dev/null +++ b/base_ical/controllers/__init__.py @@ -0,0 +1 @@ +from . import main diff --git a/base_ical/controllers/main.py b/base_ical/controllers/main.py new file mode 100644 index 000000000..809344fce --- /dev/null +++ b/base_ical/controllers/main.py @@ -0,0 +1,54 @@ +# Copyright 2023 Hunki Enterprises BV +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import logging + +import werkzeug.wrappers +from werkzeug.exceptions import Unauthorized + +from odoo import http +from odoo.http import request + +_logger = logging.getLogger(__name__) + + +class ICalController(http.Controller): + @http.route( + "/base_ical/", + auth="none", + csrf=False, + methods=["GET"], + type="http", + ) + def get_ical(self, calendar_id, access_token=None): + if not access_token: + raise Unauthorized() + + user_id = request.env["res.users.apikeys"]._check_credentials( + scope=f"odoo.plugin.ical.{calendar_id}", + key=access_token, + ) + if not user_id: + raise Unauthorized() + + calendar = ( + http.request.env["base.ical"] + .with_user(user_id) + .search([("id", "=", calendar_id)]) + ) + if not calendar: + return request.not_found() + + records = calendar._get_items() + response = werkzeug.wrappers.Response() + if records: + response.last_modified = max(records.mapped("write_date")) + + response.make_conditional(request.httprequest) + if response.status_code == 304: + return response + + response.mimetype = "text/calendar" + response.data = calendar._get_ical(records) + return response diff --git a/base_ical/demo/base_ical.xml b/base_ical/demo/base_ical.xml new file mode 100644 index 000000000..b1772853e --- /dev/null +++ b/base_ical/demo/base_ical.xml @@ -0,0 +1,12 @@ + + + + + Demo calendar + + str(record.id) + record.create_date + record.write_date + + diff --git a/base_ical/i18n/.empty b/base_ical/i18n/.empty new file mode 100644 index 000000000..e69de29bb diff --git a/base_ical/i18n/base_ical.pot b/base_ical/i18n/base_ical.pot new file mode 100644 index 000000000..3c18fba74 --- /dev/null +++ b/base_ical/i18n/base_ical.pot @@ -0,0 +1,291 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * base_ical +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0\n" +"Report-Msgid-Bugs-To: \n" +"Last-Translator: \n" +"Language-Team: \n" +"MIME-Version: 1.0\n" +"Content-Type: text/plain; charset=UTF-8\n" +"Content-Transfer-Encoding: \n" +"Plural-Forms: \n" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_icalendar_url_show_form +msgid "" +"Important:\n" +" The url cannot be retrieved later and provides." +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__active +msgid "Active" +msgstr "" + +#. module: base_ical +#: code:addons/base_ical/models/base_ical.py:0 +#, python-format +msgid "Advanced" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_base_ical_form +msgid "Advanced Configuration" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__auto +msgid "Allow automatically" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__allowed_users_ids +msgid "Allowed Users" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__calendar_id +msgid "Calendar" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_res_users_form +msgid "Calendars" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_icalendar_url_description_form +msgid "Cancel" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__code +msgid "Code" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__create_uid +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__create_uid +msgid "Created by" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__create_date +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__create_date +msgid "Created on" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__expression_dtend +msgid "DTEND" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__expression_dtstamp +msgid "DTSTAMP" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__expression_dtstart +msgid "DTSTART" +msgstr "" + +#. module: base_ical +#: model:ir.model,name:base_ical.model_base_ical +msgid "Definition of an iCal export" +msgstr "" + +#. module: base_ical +#: model:base.ical,name:base_ical.demo_calendar +msgid "Demo calendar" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__name +msgid "Description" +msgstr "" + +#. module: base_ical +#: model:ir.model,name:base_ical.model_base_ical_url_description +msgid "Description for iCalendar" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__display_name +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__display_name +msgid "Display Name" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__domain +msgid "Domain" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_icalendar_url_show_form +msgid "Done!" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_res_users_form +msgid "Existing Access" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_icalendar_url_description_form +msgid "Generate url" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_base_ical_form +msgid "Help" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__help_text +msgid "Help Text" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_icalendar_url_show_form +msgid "Here is your new URL to the calendar." +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__id +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__id +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_show__id +msgid "ID" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_res_users__ical_ids +msgid "Ical" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,help:base_ical.field_base_ical__auto +msgid "" +"If you check this, the calendar will be enabled for all current and future " +"users. Not that unchecking this will not disable existing calendar " +"subscriptions" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical____last_update +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description____last_update +msgid "Last Modified on" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__write_uid +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__write_date +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_description__write_date +msgid "Last Updated on" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__mode +msgid "Mode" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__model_id +msgid "Model" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__model +msgid "Model Name" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__name +msgid "Name" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_res_users_form +msgid "New URL" +msgstr "" + +#. module: base_ical +#: code:addons/base_ical/models/base_ical_url_description.py:0 +#, python-format +msgid "Only internal users can create API keys" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__preview +#: model_terms:ir.ui.view,arch_db:base_ical.view_base_ical_form +msgid "Preview" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__expression_summary +msgid "SUMMARY" +msgstr "" + +#. module: base_ical +#: model:ir.model,name:base_ical.model_base_ical_url_show +msgid "Show URL for iCalendar" +msgstr "" + +#. module: base_ical +#: code:addons/base_ical/models/base_ical.py:0 +#, python-format +msgid "Simple" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_base_ical_form +msgid "Simple Configuration" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical__expression_uid +msgid "UID" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,field_description:base_ical.field_base_ical_url_show__url +msgid "Url" +msgstr "" + +#. module: base_ical +#: model:ir.model,name:base_ical.model_res_users +msgid "Users" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,help:base_ical.field_base_ical__domain +msgid "You can use variables `env` and `user` here" +msgstr "" + +#. module: base_ical +#: model:ir.model.fields,help:base_ical.field_base_ical__expression_dtend +#: model:ir.model.fields,help:base_ical.field_base_ical__expression_dtstamp +#: model:ir.model.fields,help:base_ical.field_base_ical__expression_dtstart +#: model:ir.model.fields,help:base_ical.field_base_ical__expression_summary +#: model:ir.model.fields,help:base_ical.field_base_ical__expression_uid +msgid "You can use variables `record` and `user` here" +msgstr "" + +#. module: base_ical +#: model_terms:ir.ui.view,arch_db:base_ical.view_base_ical_form +msgid "[('user_id', '=', user.id)]" +msgstr "" + +#. module: base_ical +#: model:ir.actions.act_window,name:base_ical.action_base_ical +#: model:ir.ui.menu,name:base_ical.menu_ical +msgid "iCalendars" +msgstr "" diff --git a/base_ical/models/__init__.py b/base_ical/models/__init__.py new file mode 100644 index 000000000..f92a582cd --- /dev/null +++ b/base_ical/models/__init__.py @@ -0,0 +1 @@ +from . import base_ical, base_ical_url_description, base_ical_url_show, res_users diff --git a/base_ical/models/base_ical.py b/base_ical/models/base_ical.py new file mode 100644 index 000000000..9b8397b15 --- /dev/null +++ b/base_ical/models/base_ical.py @@ -0,0 +1,324 @@ +# Copyright 2023 Hunki Enterprises BV +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import datetime +import logging +from urllib.parse import urlparse + +import pytz +import vobject +from dateutil import relativedelta + +from odoo import _, api, fields, models +from odoo.tools import html2plaintext +from odoo.tools.safe_eval import safe_eval, wrap_module + +_logger = logging.getLogger(__name__) + +vobject_wrapped = wrap_module(__import__("vobject"), ["iCalendar", "readOne"]) + + +class BaseIcal(models.Model): + _name = "base.ical" + _description = "Definition of an iCal export" + + def _get_operating_modes(self): + return [ + ("simple", _("Simple")), + ("advanced", _("Advanced")), + ] + + active = fields.Boolean(default=True) + name = fields.Char(required=True, translate=True) + mode = fields.Selection("_get_operating_modes", required=True, default="simple") + model_id = fields.Many2one("ir.model", required=True, ondelete="cascade") + model = fields.Char("Model Name", related="model_id.model") + domain = fields.Char( + required=True, default="[]", help="You can use variables `env` and `user` here" + ) + preview = fields.Text(compute="_compute_preview") + code = fields.Text() + expression_dtstamp = fields.Char( + vevent_field="dtstamp", + string="DTSTAMP", + help="You can use variables `record` and `user` here", + default="record.write_date", + ) + expression_uid = fields.Char( + vevent_field="uid", + string="UID", + help="You can use variables `record` and `user` here", + ) + expression_dtstart = fields.Char( + vevent_field="dtstart", + string="DTSTART", + help="You can use variables `record` and `user` here", + ) + expression_dtend = fields.Char( + vevent_field="dtend", + string="DTEND", + help="You can use variables `record` and `user` here", + ) + expression_summary = fields.Char( + vevent_field="summary", + string="SUMMARY", + help="You can use variables `record` and `user` here", + default="record.display_name", + ) + allowed_users_ids = fields.Many2many("res.users") + auto = fields.Boolean( + "Allow automatically", + copy=False, + help="If you check this, the calendar will be enabled for all current and " + "future users. Note that unchecking this will not disable existing calendar " + "subscriptions", + ) + auto_group_ids = fields.Many2many( + "res.groups", + relation="base_ical_auto_groups_rel", + string="Groups", + help="Only users of selected groups will be auto-allowed. " + "Leave empty for all users", + default=lambda self: self.env.ref("base.group_user"), + ) + help_text = fields.Html(compute="_compute_help") + + def _valid_field_parameter(self, field, name): + return super()._valid_field_parameter(field, name) or name == "vevent_field" + + @api.depends( + "model_id", + "domain", + "expression_dtstamp", + "expression_uid", + "expression_dtstart", + "expression_dtend", + "expression_summary", + "code", + "mode", + ) + def _compute_preview(self): + for this in self: + this.preview = this._get_ical(limit=5) + + @api.depends("mode") + def _compute_help(self): + for this in self: + variables = this.default_variables() + lines = [] + for var, desc in sorted(variables.items()): + var = (f"{v.strip()}" for v in var.split(",")) + lines.append(f"
  • {', '.join(sorted(var))}: {desc}
  • ") + + this.help_text = "
      " + "\n".join(lines) + "
    " + + @api.onchange("model_id") + def _onchange_model_id(self): + for field_name, field in self._fields.items(): + if hasattr(field, "vevent_field"): + self[field_name] = False + self.update( + self.default_get(["domain", "expression_dtstamp", "expression_summary"]) + ) + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + self.expression_uid = ( + f"'%s-{self.env.cr.dbname}@{urlparse(base_url).hostname}' % record.id" + ) + + @api.constrains( + "domain", + "expression_dtstamp", + "expression_uid", + "expression_dtstart", + "expression_dtend", + "expression_summary", + "code", + "mode", + ) + def _check_domain(self): + for this in self: + this._get_ical() + + @api.model_create_multi + def create(self, vals_list): + """Enable calendar for all users if auto flag is checked""" + result = super().create(vals_list) + result.filtered("auto")._enable_all_users() + return result + + def write(self, vals): + """Enable calendar for all users if auto flag is checked""" + result = super().write(vals) + if vals.get("auto") or vals.get("auto_group_ids"): + self._enable_all_users() + return result + + def default_variables(self): + self.ensure_one() + variables = { + "datetime, relativedelta, time, timedelta": self.env._( + "useful Python libraries" + ), + "record": self.env._("Record to export"), + "user": self.env._("Current user record"), + } + if self.mode == "advanced": + variables.update( + { + "calendar": self.env._( + "Output: Calendar e.g. from `_get_ics_file`" + ), + "dict2ical": self.env._( + "Function to add the key-values of dict to ical component" + ), + "event": self.env._("Output: Dictionary of an VEVENT"), + "todo": self.env._("Output: Dictionary of an VTODO"), + "vobject": self.env._("vobject python library"), + "html2plaintext": self.env._("Converts HTML to plain text"), + } + ) + return variables + + def _get_eval_expression_context(self): + """Return the evaluation context for expression evaluation""" + return { + "datetime": datetime.datetime, + "date": datetime.date, + "relativedelta": relativedelta.relativedelta, + "timedelta": datetime.timedelta, + "user": self.env.user, + } + + def _get_eval_domain_context(self): + """Return the evaluation context for domain evaluation""" + return { + "user": self.env.user, + "env": self.env, + } + + def _get_items(self, limit=None): + """Return events based on model_id and domain""" + self.ensure_one() + return self.env[self.model_id.sudo().model].search( + safe_eval(self.domain, self._get_eval_domain_context()), + limit=limit, + ) + + def _get_ical(self, records=None, limit=None): + """Return the vcalendar as text""" + if self.mode == "simple": + return self._get_ical_simple(records=records, limit=limit) + + return self._get_ical_advanced(records=records, limit=limit) + + def _get_ical_simple(self, records=None, limit=None): + if not all( + self[field_name] + for field_name, field in self._fields.items() + if hasattr(field, "vevent_field") + ): + return "" + + calendar = vobject.iCalendar() + for record in records or self._get_items(limit): + event = calendar.add("vevent") + for field_name, field in self._fields.items(): + if not hasattr(field, "vevent_field"): + continue + + if not self[field_name]: + continue + + ctx = self._get_eval_expression_context() + ctx["record"] = record + + value = safe_eval(self[field_name], ctx) + event.add(field.vevent_field).value = self._format_ical_value( + value, field=field + ) + return calendar.serialize() + + def _get_ical_advanced(self, records=None, limit=None): + if not self.code: + return "" + + context = self._get_eval_expression_context() + context.update( + { + "dict2ical": self._dict_to_ical_component, + "html2plaintext": html2plaintext, + "vobject": vobject_wrapped, + } + ) + + calendar = vobject.iCalendar() + tz = pytz.timezone(self.env.user.tz or "UTC") + calendar.add(vobject.icalendar.TimezoneComponent(tz)) + for record in records or self._get_items(limit): + context.update( + {"record": record, "calendar": None, "event": None, "todo": None} + ) + safe_eval(self.code, context, mode="exec", nocopy=True) + + cal = context.get("calendar") + if cal: + # Support for `_get_ics_file` + if isinstance(cal, dict) and record.id in cal: + cal = cal[record.id] + + if isinstance(cal, bytes): + cal = cal.decode() + + if isinstance(cal, str): + cal = vobject.readOne(cal) + + self._copy_ical_calendar(calendar, cal) + + event, todo = map(context.get, ("event", "todo")) + if event: + self._dict_to_ical_component(calendar.add("vevent"), event) + + if todo: + self._dict_to_ical_component(calendar.add("vtodo"), todo) + + return calendar.serialize() + + def _dict_to_ical_component(self, component, data): + for key, value in data.items(): + component.add(key).value = self._format_ical_value(value) + + def _copy_ical_calendar(self, dst_calendar, src_calendar): + for item in src_calendar.getChildren(): + if item.name.lower() in ("vevent", "vtodo"): + dst_calendar.add(item) + + def _format_ical_value(self, value, field=None): + """Add timezone to datetime values""" + if isinstance(value, datetime.datetime): + return pytz.utc.localize(value).astimezone( + pytz.timezone(self.env.user.tz or "UTC") + ) + return value + + def _enable_all_users(self, users=None): + """Enable calendar for all users""" + for this in self: + users = ( + users or this.auto_group_ids.users or self.env["res.users"].search([]) + ) + this.write({"allowed_users_ids": users}) + + def action_new_url(self): + """Create or activate current user's token""" + + return { + "type": "ir.actions.act_window", + "res_model": "base.ical.url.description", + "name": "iCalendar URL", + "views": [(False, "form")], + "target": "new", + "context": { + "default_calendar_id": self.id, + }, + } diff --git a/base_ical/models/base_ical_url_description.py b/base_ical/models/base_ical_url_description.py new file mode 100644 index 000000000..f1f87c6e7 --- /dev/null +++ b/base_ical/models/base_ical_url_description.py @@ -0,0 +1,51 @@ +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from urllib.parse import urlparse, urlunparse + +from odoo import _, fields, models +from odoo.exceptions import AccessError + +from odoo.addons.base.models.res_users import check_identity + + +class BaseIcalUrlDescription(models.TransientModel): + _name = "base.ical.url.description" + _description = "Description for iCalendar" + + calendar_id = fields.Many2one("base.ical", required=True) + name = fields.Char("Description", required=True) + + def _make_url(self): + if not self.env.user.has_group("base.group_user"): + raise AccessError(_("Only internal users can create API keys")) + + scope = f"odoo.plugin.ical.{self.calendar_id.id}" + + token = ( + self.env["res.users.apikeys"] + .sudo() + ._generate(scope, f"Calendar: {self.name}", None) + ) + + base_url = self.env["ir.config_parameter"].sudo().get_param("web.base.url") + return urlunparse( + urlparse(base_url)._replace( + path=f"/base_ical/{self.calendar_id.id}", + query=f"access_token={token}", + ) + ) + + @check_identity + def make_url(self): + url = self._make_url() + return { + "type": "ir.actions.act_window", + "res_model": "base.ical.url.show", + "name": "iCalendar URL", + "views": [(False, "form")], + "target": "new", + "context": { + "default_url": url, + }, + } diff --git a/base_ical/models/base_ical_url_show.py b/base_ical/models/base_ical_url_show.py new file mode 100644 index 000000000..c6dca5e6d --- /dev/null +++ b/base_ical/models/base_ical_url_show.py @@ -0,0 +1,12 @@ +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from odoo import fields, models + + +class BaseIcalUrlShow(models.AbstractModel): + _name = "base.ical.url.show" + _description = "Show URL for iCalendar" + + id = fields.Id() + url = fields.Char(readonly=True) diff --git a/base_ical/models/res_users.py b/base_ical/models/res_users.py new file mode 100644 index 000000000..0ef2eb2a0 --- /dev/null +++ b/base_ical/models/res_users.py @@ -0,0 +1,44 @@ +# Copyright 2023 Hunki Enterprises BV +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from odoo import api, fields, models + + +class ResUsers(models.Model): + _inherit = "res.users" + + ical_ids = fields.One2many("base.ical", compute="_compute_ical_ids") + + def _compute_ical_ids(self): + for this in self: + domain = [("allowed_users_ids", "=", this.id)] + this.ical_ids = self.env["base.ical"].search(domain) + + @property + def SELF_READABLE_FIELDS(self): + return super().SELF_READABLE_FIELDS + ["ical_ids"] + + @api.model_create_multi + def create(self, vals_list): + result = super().create(vals_list) + auto_calendars = ( + self.env["base.ical"] + .sudo() + .search( + [ + ("auto", "=", True), + "|", + ("auto_group_ids", "=", False), + ("auto_group_ids", "in", result.groups_id.ids), + ] + ) + ) + if not auto_calendars: + return result + for this in result: + auto_calendars.filtered( + lambda x, this=this: not x.auto_group_ids + or this.groups_id & x.auto_group_ids + ).write({"allowed_users_ids": [fields.Command.link(this.id)]}) + return result diff --git a/base_ical/pyproject.toml b/base_ical/pyproject.toml new file mode 100644 index 000000000..4231d0ccc --- /dev/null +++ b/base_ical/pyproject.toml @@ -0,0 +1,3 @@ +[build-system] +requires = ["whool"] +build-backend = "whool.buildapi" diff --git a/base_ical/readme/CONFIGURE.md b/base_ical/readme/CONFIGURE.md new file mode 100644 index 000000000..37cb1a203 --- /dev/null +++ b/base_ical/readme/CONFIGURE.md @@ -0,0 +1,53 @@ +To configure this module, you need to: + +1. Go to Settings/Technical/iCalendars +2. Create a iCalendar, fill in the model you want to expose and + possibly a domain to restrict records. You can use the `user` + variable to restrict things relative to the user using the iCalendar +3. A iCalendar is only available to the allowed users. Use the Allow + automatically to make the iCalendar available to all users +4. See the examples below for a start + +## Examples + +Simple example, for model `calendar.event`, you'd fill in +`record.allday and record.start_date or record.start` as DTSTART and +`record.allday and record.stop_date or record.stop` as DTEND. + +Advanced example, for model `calendar.event`, you'd use +`calendar = record._get_ics_file()` in the code. + +Advanced example, for model `hr.leave`, you can use the following code +and `[("employee_id.user_id", "=", user.id)]` in the domain to export +the own time offs. This is a bit more complex because of the way Odoo +handles the begin and end times of leaves, and you'll want the extra day +as most clients interpret the end date as non-inclusive.: + +``` python +confirmed = ("validate", "validate1") +if record.request_unit_half or record.request_unit_hours: + event = { + "dtstart": event["dtstart"].date(), + "dtend": event["dtend"].date() + timedelta(days=1), + } +else: + event = { + "dtstart": record.date_from, + "dtend": record.date_to, + } + +event["summary"] = record.name +event["status"] = "CONFIRMED" if record.state in confirmed else "TENTATIVE" +``` + +Advanced example, for model `mail.activity`, you can use the following +code and `[("user_id", "=", user.id)]` and domain to export all user +activities. + +``` python +todo = { + "summary": record.display_name, + "due": record.date_deadline, + "description": html2plaintext(record.note) if record.note else "" +} +``` diff --git a/base_ical/readme/CONTRIBUTORS.md b/base_ical/readme/CONTRIBUTORS.md new file mode 100644 index 000000000..d368da6da --- /dev/null +++ b/base_ical/readme/CONTRIBUTORS.md @@ -0,0 +1,4 @@ +- Holger Brunn \<\> + () +- Florian Kantelberg \<\> + () diff --git a/base_ical/readme/DESCRIPTION.md b/base_ical/readme/DESCRIPTION.md new file mode 100644 index 000000000..8674675fa --- /dev/null +++ b/base_ical/readme/DESCRIPTION.md @@ -0,0 +1,8 @@ +This module allows administrators to configure iCalendars based on an +arbitrary selection on arbitrary models. + +Users can selectively subscribe to them by enabling them in their +profile form. + +This is useful for exposing Odoo data to calendaring application like +Nextcloud. diff --git a/base_ical/readme/ROADMAP.md b/base_ical/readme/ROADMAP.md new file mode 100644 index 000000000..c6d263b0f --- /dev/null +++ b/base_ical/readme/ROADMAP.md @@ -0,0 +1,3 @@ +- support all of + +- allow users to define their own calendars diff --git a/base_ical/readme/USAGE.md b/base_ical/readme/USAGE.md new file mode 100644 index 000000000..06913e3f8 --- /dev/null +++ b/base_ical/readme/USAGE.md @@ -0,0 +1,5 @@ +To use this module, you need to: + +1. Go to your profile form +2. Click Enable on one of the calendars listed in tab Calendars +3. Copy the URL to the application you use diff --git a/base_ical/security/ir.model.access.csv b/base_ical/security/ir.model.access.csv new file mode 100644 index 000000000..602b7a2a4 --- /dev/null +++ b/base_ical/security/ir.model.access.csv @@ -0,0 +1,5 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_base_ical,access_base_ical,model_base_ical,base.group_user,1,0,0,0 +modify_base_ical,modify_base_ical,model_base_ical,base.group_system,1,1,1,1 +access_base_ical_url_description,access_base_ical_url_description,model_base_ical_url_description,base.group_user,1,0,1,0 +access_base_ical_url_show,access_base_ical_url_show,model_base_ical_url_show,base.group_user,1,0,1,0 diff --git a/base_ical/static/description/icon.png b/base_ical/static/description/icon.png new file mode 100644 index 000000000..3a0328b51 Binary files /dev/null and b/base_ical/static/description/icon.png differ diff --git a/base_ical/static/description/index.html b/base_ical/static/description/index.html new file mode 100644 index 000000000..4b4b6e339 --- /dev/null +++ b/base_ical/static/description/index.html @@ -0,0 +1,514 @@ + + + + + +Readonly publishing of calendars + + + +
    +

    Readonly publishing of calendars

    + + +

    Alpha License: AGPL-3 OCA/server-backend Translate me on Weblate Try me on Runboat

    +

    This module allows administrators to configure iCalendars based on an +arbitrary selection on arbitrary models.

    +

    Users can selectively subscribe to them by enabling them in their +profile form.

    +

    This is useful for exposing Odoo data to calendaring application like +Nextcloud.

    +
    +

    Important

    +

    This is an alpha version, the data model and design can change at any time without warning. +Only for development or testing purpose, do not use in production. +More details on development status

    +
    +

    Table of contents

    + +
    +

    Configuration

    +

    To configure this module, you need to:

    +
      +
    1. Go to Settings/Technical/iCalendars
    2. +
    3. Create a iCalendar, fill in the model you want to expose and possibly +a domain to restrict records. You can use the user variable to +restrict things relative to the user using the iCalendar
    4. +
    5. A iCalendar is only available to the allowed users. Use the Allow +automatically to make the iCalendar available to all users
    6. +
    7. See the examples below for a start
    8. +
    +
    +

    Examples

    +

    Simple example, for model calendar.event, you’d fill in +record.allday and record.start_date or record.start as DTSTART and +record.allday and record.stop_date or record.stop as DTEND.

    +

    Advanced example, for model calendar.event, you’d use +calendar = record._get_ics_file() in the code.

    +

    Advanced example, for model hr.leave, you can use the following code +and [("employee_id.user_id", "=", user.id)] in the domain to export +the own time offs. This is a bit more complex because of the way Odoo +handles the begin and end times of leaves, and you’ll want the extra day +as most clients interpret the end date as non-inclusive.:

    +
    +confirmed = ("validate", "validate1")
    +if record.request_unit_half or record.request_unit_hours:
    +   event = {
    +      "dtstart": event["dtstart"].date(),
    +      "dtend": event["dtend"].date() + timedelta(days=1),
    +   }
    +else:
    +  event = {
    +      "dtstart": record.date_from,
    +      "dtend": record.date_to,
    +   }
    +
    +event["summary"] = record.name
    +event["status"] = "CONFIRMED" if record.state in confirmed else "TENTATIVE"
    +
    +

    Advanced example, for model mail.activity, you can use the following +code and [("user_id", "=", user.id)] and domain to export all user +activities.

    +
    +todo = {
    +   "summary": record.display_name,
    +   "due": record.date_deadline,
    +   "description": html2plaintext(record.note) if record.note else ""
    +}
    +
    +
    +
    +
    +

    Usage

    +

    To use this module, you need to:

    +
      +
    1. Go to your profile form
    2. +
    3. Click Enable on one of the calendars listed in tab Calendars
    4. +
    5. Copy the URL to the application you use
    6. +
    +
    +
    +

    Known issues / Roadmap

    + +
    +
    +

    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

    +
      +
    • Hunki Enterprises BV
    • +
    +
    + +
    +

    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:

    +

    hbrunn

    +

    This module is part of the OCA/server-backend project on GitHub.

    +

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

    +
    +
    +
    + + diff --git a/base_ical/tests/__init__.py b/base_ical/tests/__init__.py new file mode 100644 index 000000000..bffcadfff --- /dev/null +++ b/base_ical/tests/__init__.py @@ -0,0 +1,2 @@ +from . import test_base_ical +from . import test_base_ical_http diff --git a/base_ical/tests/test_base_ical.py b/base_ical/tests/test_base_ical.py new file mode 100644 index 000000000..b4f3351a5 --- /dev/null +++ b/base_ical/tests/test_base_ical.py @@ -0,0 +1,135 @@ +# Copyright 2023 Hunki Enterprises BV +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +import logging +from urllib.parse import parse_qs, urlparse + +import vobject + +from odoo import fields +from odoo.tests import Form +from odoo.tests.common import TransactionCase + +_logger = logging.getLogger(__name__) + + +class TestBaseIcal(TransactionCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.calendar = cls.env.ref("base_ical.demo_calendar") + cls.user = cls.env.ref("base.user_demo") + + def test_profile(self): + """Test url generation""" + desc = ( + self.env["base.ical.url.description"] + .with_user(self.user) + .create({"calendar_id": self.calendar.id, "name": "Testing"}) + ) + + url = desc._make_url() + self.assertTrue(url) + parsed = urlparse(url) + params = parse_qs(parsed.query) + access_token = params.get("access_token")[0] + self.assertTrue(access_token) + + user_id = self.env["res.users.apikeys"]._check_credentials( + scope=f"odoo.plugin.ical.{self.calendar.id}", + key=access_token, + ) + self.assertEqual(user_id, self.user.id) + + def test_config_simple(self): + """Configure a simple calendar""" + with self.debug_mode(), Form(self.calendar) as calendar_form: + calendar_form.mode = "simple" + calendar_form.model_id = self.env.ref("base.model_res_partner") + self.assertFalse(calendar_form.expression_dtstart) + self.assertFalse(calendar_form.preview) + + calendar_form.expression_dtstart = "record.create_date" + calendar_form.expression_dtend = "record.write_date" + self.assertTrue(calendar_form.preview) + self.assertTrue(vobject.readOne(calendar_form.preview)) + + def test_config_advanced_event(self): + with self.debug_mode(), Form(self.calendar) as calendar_form: + calendar_form.mode = "advanced" + calendar_form.model_id = self.env.ref("base.model_res_partner") + self.assertFalse(calendar_form.preview) + + calendar_form.code = ( + "event = {" + "'dtstart':record.create_date," + "'dtend':record.write_date," + "'summary':record.name," + "}" + ) + self.assertTrue(calendar_form.preview) + cal = vobject.readOne(calendar_form.preview) + self.assertTrue(cal) + self.assertTrue( + any(item.name.lower() == "vevent" for item in cal.getChildren()) + ) + + def test_config_advanced_todo(self): + with self.debug_mode(), Form(self.calendar) as calendar_form: + calendar_form.mode = "advanced" + calendar_form.model_id = self.env.ref("base.model_res_partner") + self.assertFalse(calendar_form.preview) + calendar_form.code = ( + "todo = {'due':record.write_date,'summary':record.name}" + ) + self.assertTrue(calendar_form.preview) + cal = vobject.readOne(calendar_form.preview) + self.assertTrue(cal) + self.assertTrue( + any(item.name.lower() == "vtodo" for item in cal.getChildren()) + ) + + def test_config_advanced_calendar(self): + code = ( + "cal = vobject.iCalendar()\n" + "dict2ical(cal.add('vevent'),{" + "'summary':record.name," + "'dtstart':record.create_date," + "'dtend':record.write_date})\n" + ) + with self.debug_mode(), Form(self.calendar) as calendar_form: + calendar_form.mode = "advanced" + calendar_form.model_id = self.env.ref("base.model_res_partner") + self.assertFalse(calendar_form.preview) + calendar_form.code = ( + code + "calendar = {record.id:cal.serialize().encode()}" + ) + self.assertTrue(calendar_form.preview) + cal = vobject.readOne(calendar_form.preview) + self.assertTrue(cal) + + def test_auto_flag(self): + """Test the auto flag is honored""" + self.assertFalse(self.calendar.allowed_users_ids) + + self.calendar.auto = True + self.assertTrue(self.calendar.allowed_users_ids) + new_calendar = self.calendar.copy() + self.assertIn(self.user, new_calendar.allowed_users_ids) + + self.calendar.auto = False + new_user = self.user.copy() + self.assertNotIn(new_user, self.calendar.allowed_users_ids) + + self.calendar.auto = True + new_user = new_user.copy() + self.assertIn(new_user, self.calendar.allowed_users_ids) + + new_group = self.env["res.groups"].create({"name": "New group"}) + self.calendar.auto_group_ids = new_group + new_user = new_user.copy() + self.assertNotIn(new_user, self.calendar.allowed_users_ids) + + new_user = new_user.copy({"groups_id": [fields.Command.link(new_group.id)]}) + self.assertIn(new_user, self.calendar.allowed_users_ids) diff --git a/base_ical/tests/test_base_ical_http.py b/base_ical/tests/test_base_ical_http.py new file mode 100644 index 000000000..6f9ffa0e6 --- /dev/null +++ b/base_ical/tests/test_base_ical_http.py @@ -0,0 +1,24 @@ +# Copyright 2023 Hunki Enterprises BV +# Copyright 2024 initOS GmbH +# License AGPL-3.0 or later (https://www.gnu.org/licenses/agpl-3.0) + +from odoo.tests.common import HttpCase + + +class TestBaseIcalHttp(HttpCase): + @classmethod + def setUpClass(cls): + super().setUpClass() + cls.calendar = cls.env.ref("base_ical.demo_calendar") + cls.user = cls.env.ref("base.user_demo") + desc = ( + cls.env["base.ical.url.description"] + .with_user(cls.user) + .create({"calendar_id": cls.calendar.id, "name": "Testing"}) + ) + cls.calendar_url = desc._make_url() + + def test_calendar_retrieval(self): + response = self.url_open(self.calendar_url) + self.assertTrue(response.ok) + self.assertTrue(response.text.startswith("BEGIN:VCALENDAR")) diff --git a/base_ical/views/base_ical.xml b/base_ical/views/base_ical.xml new file mode 100644 index 000000000..1321f2bf2 --- /dev/null +++ b/base_ical/views/base_ical.xml @@ -0,0 +1,155 @@ + + + + + iCalendar: Show Url + base.ical.url.show + +
    + +

    + Here is your new URL to the calendar. +

    +

    + + + +

    + +
    +
    +
    +
    +
    +
    + + + iCalendar: Show Url + base.ical.url.description + +
    + + + + + +
    +
    +
    +
    +
    +
    + + + base.ical + +
    + + +
    +
    +
    + + + base.ical + + + + + + + + + + + iCalendars + ir.actions.act_window + base.ical + list,form + + + +
    diff --git a/base_ical/views/res_users.xml b/base_ical/views/res_users.xml new file mode 100644 index 000000000..eb4814494 --- /dev/null +++ b/base_ical/views/res_users.xml @@ -0,0 +1,47 @@ + + + + + res.users + + + + +