diff --git a/nextcloud_odoo_sync/README.rst b/nextcloud_odoo_sync/README.rst new file mode 100644 index 0000000..e8774e3 --- /dev/null +++ b/nextcloud_odoo_sync/README.rst @@ -0,0 +1,102 @@ +=================== +Nextcloud-Odoo Sync +=================== + +.. !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + !! This file is generated by oca-gen-addon-readme !! + !! changes will be overwritten. !! + !!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!!! + +.. |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%2Fvolendra--misc-lightgray.png?logo=github + :target: https://github.com/OCA/volendra-misc/tree/main/nextcloud_odoo_sync + :alt: OCA/volendra-misc +.. |badge4| image:: https://img.shields.io/badge/weblate-Translate%20me-F47D42.png + :target: https://translation.odoo-community.org/projects/volendra-misc-main/volendra-misc-main-nextcloud_odoo_sync + :alt: Translate me on Weblate +.. |badge5| image:: https://img.shields.io/badge/runboat-Try%20me-875A7B.png + :target: https://runboat.odoo-community.org/webui/builds.html?repo=OCA/volendra-misc&target_branch=main + :alt: Try me on Runboat + +|badge1| |badge2| |badge3| |badge4| |badge5| + +This module sync Nextcloud apps into Odoo counterpart app. + +**Table of contents** + +.. contents:: + :local: + +Usage +===== + +Odoo Configuration +~~~~~~~~~~~~~~~~~~ + +#. Go to Settins/ Nextcloud +#. Click *Enable Calendar Sync* +#. Indicate the *Server URL* and the Nextcloud admin credentials then click Save. Do not put a trailing forward slash "/" at the end of the *Server URL* + + +User Configuration +~~~~~~~~~~~~~~~~~~ + +To use this module, you need to: + +#. On the upper right corner of the Odoo web client, click the *User* menu and select *Preferences* +#. Click the button *Setup Nextcloud User* +#. On the pop-up dialog, input your Nextcloud *Username* and *Password* and click *Login to Nextcloud* button +#. It will try to login into Nextcloud and ask you to select a default Nextcloud Calendar to use + +Bug Tracker +=========== + +Bugs are tracked on `GitHub Issues `_. +In case of trouble, please check there if your issue has already been reported. +If you spotted it first, help us smashing it by providing a detailed and welcomed +`feedback `_. + +Do not contact contributors directly about support or help with technical issues. + +Credits +======= + +Authors +~~~~~~~ + +* iScale Solutions Inc. + +Contributors +~~~~~~~~~~~~ + +* iScale Solutons + +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-iscale-solutions| image:: https://github.com/iscale-solutions.png?size=40px + :target: https://github.com/iscale-solutions + :alt: iscale-solutions + +Current `maintainer `__: + +|maintainer-iscale-solutions| + +This module is part of the `OCA/volendra-misc `_ project on GitHub. + +You are welcome to contribute. To learn how please visit https://odoo-community.org/page/Contribute. diff --git a/nextcloud_odoo_sync/__init__.py b/nextcloud_odoo_sync/__init__.py new file mode 100644 index 0000000..3421cb0 --- /dev/null +++ b/nextcloud_odoo_sync/__init__.py @@ -0,0 +1,7 @@ +# Copyright (c) 2022 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import models +from . import wizards +from . import tests +from .hooks import uninstall_hook diff --git a/nextcloud_odoo_sync/__manifest__.py b/nextcloud_odoo_sync/__manifest__.py new file mode 100644 index 0000000..bf34e76 --- /dev/null +++ b/nextcloud_odoo_sync/__manifest__.py @@ -0,0 +1,38 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) +{ + "name": "Nextcloud-Odoo Sync", + "version": "16.0.1.0.0", + "category": "Others", + "description": """Sync Nextcloud apps into Odoo""", + "author": "iScale Solutions Inc.", + "website": "http://iscale-solutions.com", + "external_dependencies": {"python": ["caldav"]}, + "depends": ["base", "calendar", "resource", "contacts"], + "maintainers": ["iscale-solutions"], + "license": "AGPL-3", + "data": [ + "data/res_groups_data.xml", + "data/nc_sync_error_data.xml", + "data/nc_event_status_data.xml", + "data/nextcloud_odoo_sync_cron_data.xml", + "data/nc_sync_log_capacity_cron_data.xml", + "data/calendar_alarm_data.xml", + "data/calendar_event_type_data.xml", + "security/ir.model.access.csv", + "security/ir_rule.xml", + "views/calendar_event_views.xml", + "views/nc_sync_user_views.xml", + "views/nc_sync_log_views.xml", + "views/nc_sync_error_views.xml", + "views/nc_calendar_views.xml", + "views/res_users_views.xml", + "views/res_config_settings_views.xml", + "wizards/run_sync_wizard_views.xml", + ], + "installable": True, + "active": False, + "auto_install": False, + "application": True, + "uninstall_hook": "uninstall_hook", +} diff --git a/nextcloud_odoo_sync/data/calendar_alarm_data.xml b/nextcloud_odoo_sync/data/calendar_alarm_data.xml new file mode 100644 index 0000000..c798848 --- /dev/null +++ b/nextcloud_odoo_sync/data/calendar_alarm_data.xml @@ -0,0 +1,29 @@ + + + Notification - At the event's starts + + minutes + notification + + + + Notification - 5 minutes + + minutes + notification + + + + Notification - 10 minutes + + minutes + notification + + + + Notification - 2 Days + + days + notification + + diff --git a/nextcloud_odoo_sync/data/calendar_event_type_data.xml b/nextcloud_odoo_sync/data/calendar_event_type_data.xml new file mode 100644 index 0000000..37e24ac --- /dev/null +++ b/nextcloud_odoo_sync/data/calendar_event_type_data.xml @@ -0,0 +1,61 @@ + + + Anniversary + + + + Appointment + + + + Business + + + + Education + + + + Holiday + + + + Meeting + + + + Miscellaneous + + + + Non-working hours + + + + Not in office + + + + Personal + + + + Phone call + + + + Sick day + + + + Special occasion + + + + Travel + + + + Vacation + + diff --git a/nextcloud_odoo_sync/data/nc_event_status_data.xml b/nextcloud_odoo_sync/data/nc_event_status_data.xml new file mode 100644 index 0000000..0bd8f5c --- /dev/null +++ b/nextcloud_odoo_sync/data/nc_event_status_data.xml @@ -0,0 +1,13 @@ + + + Confirmed + + + + Tentative + + + + Canceled + + diff --git a/nextcloud_odoo_sync/data/nc_sync_error_data.xml b/nextcloud_odoo_sync/data/nc_sync_error_data.xml new file mode 100644 index 0000000..eb628a3 --- /dev/null +++ b/nextcloud_odoo_sync/data/nc_sync_error_data.xml @@ -0,0 +1,15 @@ + + + ERR-1000 + Invalid user/password + nextcloud + critical + + + + ERR-1001 + Invalid API URL + nextcloud + critical + + diff --git a/nextcloud_odoo_sync/data/nc_sync_log_capacity_cron_data.xml b/nextcloud_odoo_sync/data/nc_sync_log_capacity_cron_data.xml new file mode 100644 index 0000000..16aa1d4 --- /dev/null +++ b/nextcloud_odoo_sync/data/nc_sync_log_capacity_cron_data.xml @@ -0,0 +1,16 @@ + + + + + NextCloud-Odoo Sync Log Deletion Cron + 1 + days + -1 + + + + model.delete_logs() + code + + + diff --git a/nextcloud_odoo_sync/data/nextcloud_odoo_sync_cron_data.xml b/nextcloud_odoo_sync/data/nextcloud_odoo_sync_cron_data.xml new file mode 100644 index 0000000..b2a23f4 --- /dev/null +++ b/nextcloud_odoo_sync/data/nextcloud_odoo_sync_cron_data.xml @@ -0,0 +1,16 @@ + + + + + NextCloud-Odoo Sync Cron + 12 + hours + -1 + + + + model.sync_cron() + code + + + diff --git a/nextcloud_odoo_sync/data/res_groups_data.xml b/nextcloud_odoo_sync/data/res_groups_data.xml new file mode 100644 index 0000000..d73c902 --- /dev/null +++ b/nextcloud_odoo_sync/data/res_groups_data.xml @@ -0,0 +1,8 @@ + + + + Sync Admin + + + + diff --git a/nextcloud_odoo_sync/hooks.py b/nextcloud_odoo_sync/hooks.py new file mode 100644 index 0000000..25f88c2 --- /dev/null +++ b/nextcloud_odoo_sync/hooks.py @@ -0,0 +1,19 @@ +# © 2023 Onestein +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl.html). +import ast + +from odoo import SUPERUSER_ID, api + +ACTIONS = ( + "calendar.action_calendar_event", +) + +def uninstall_hook(cr, registry): + """Restore calendar action""" + env = api.Environment(cr, SUPERUSER_ID, {}) + for action_id in ACTIONS: + action = env.ref(action_id) + dom = ast.literal_eval(action.domain or "{}") + dom = [x for x in dom if x[0] != "nc_to_delete"] + dom = list(set(dom)) + action.write({"domain": dom}) diff --git a/nextcloud_odoo_sync/i18n/en_US.po b/nextcloud_odoo_sync/i18n/en_US.po new file mode 100644 index 0000000..23475bb --- /dev/null +++ b/nextcloud_odoo_sync/i18n/en_US.po @@ -0,0 +1,586 @@ +# Translation of Odoo Server. +# This file contains the translation of the following modules: +# * nextcloud_odoo_sync +# +msgid "" +msgstr "" +"Project-Id-Version: Odoo Server 15.0+e\n" +"Report-Msgid-Bugs-To: \n" +"POT-Creation-Date: 2023-01-19 06:50+0000\n" +"PO-Revision-Date: 2023-01-19 06:50+0000\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: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.calendar_event_form_view +msgid "Calendar" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_calendar_event +msgid "Calendar Event" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.calendar_event_form_view +msgid "Calendar Hash" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_res_config_settings +msgid "Config Settings" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.ui.menu,name:nextcloud_odoo_sync.menu_main_nextcloud_config +msgid "Configuration" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__conflict +msgid "Conflict" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log__state__connecting +msgid "Connecting" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_res_config_settings__nextcloud_connection_status +msgid "Connection Status" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__create +msgid "Create" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__create_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__create_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__create_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__create_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__create_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__create_uid +msgid "Created by" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__create_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__create_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__create_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__create_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__create_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__create_date +msgid "Created on" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__severity__critical +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__severity__critical +msgid "Critical" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__data_send +msgid "Data Send" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__date_end +msgid "Date End" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__date_start +msgid "Date Start" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__severity__debug +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__severity__debug +msgid "Debug" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__delete +msgid "Delete" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__description +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__description +msgid "Description" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__display_name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__display_name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__display_name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__display_name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__display_name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__display_name +msgid "Display Name" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__duration +msgid "Duration" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_res_config_settings__enable_calendar_sync +msgid "Enable Calendar Syc" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_tree_view +msgid "End" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_res_config_settings__nextcloud_error +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__severity__error +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log__state__error +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__error +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__severity__error +msgid "Error" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__error_code_id +msgid "Error Code" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.actions.act_window,name:nextcloud_odoo_sync.nc_sync_error_action +#: model:ir.ui.menu,name:nextcloud_odoo_sync.menu_main_nc_sync_error +msgid "Error List" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log__state__failed +msgid "Failed" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__res_config_settings__nextcloud_connection_status__fail +msgid "Failed to login" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.res_config_settings_view_form +msgid "General" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__id +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__id +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__id +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__id +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__id +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__id +msgid "ID" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log__state__in_progress +msgid "In Progress" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__severity__info +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__severity__info +msgid "Info" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar____last_update +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status____last_update +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error____last_update +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log____last_update +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line____last_update +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user____last_update +msgid "Last Modified on" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__write_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__write_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__write_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__write_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__write_uid +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__write_uid +msgid "Last Updated by" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__write_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__write_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__write_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__write_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__write_date +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__write_date +msgid "Last Updated on" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__line_ids +msgid "Line" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__log_id +msgid "Log" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_res_config_settings__nextcloud_login +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__login +msgid "Login" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_calendar__name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_event_status__name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__name +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__name +msgid "Name" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_calendar_event__nc_calendar_ids +msgid "Nc Calendar" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_calendar_event__nc_calendar_hash +msgid "Nc Calendar Hash" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_calendar_event__nc_color +msgid "Nc Color" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_calendar_event__nc_resources +msgid "Nc Resources" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_calendar_event__nc_status +msgid "Nc Status" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_calendar_event__nc_uid +msgid "Nc Uid" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__new_value +msgid "New Value" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__next_cloud_url +msgid "Next Cloud Url" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__type__nextcloud +msgid "NextCloud" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nextcloud_base +msgid "NextCloud Base API" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.ui.menu,name:nextcloud_odoo_sync.menu_main_nextcloud +msgid "NextCloud Sync" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +msgid "NextCloud URL" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_form_view +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_tree_view +msgid "NextCloud User ID" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_form_view +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_tree_view +msgid "NextCloud Username" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nextcloud_webdav +msgid "NextCloud WebDav" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.calendar_event_form_view +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.res_config_settings_view_form +msgid "Nextcloud" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.module.category,name:nextcloud_odoo_sync.module_nextcloud_sync +msgid "Nextcloud Sync" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_error_tree_view +msgid "Nextcloud Sync Error Tree" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +msgid "Nextcloud Sync Log Form" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_tree_view +msgid "Nextcloud Sync Log Tree" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_form_view +msgid "Nextcloud Sync User Form" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_tree_view +msgid "Nextcloud Sync User Tree" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__nextcloud_user_id +msgid "Nextcloud User" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__type__odoo +msgid "Odoo" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +msgid "Odoo URL" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__odoo_url +msgid "Odoo Url" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_form_view +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_user_tree_view +msgid "Odoo User" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log__state__ok +msgid "Ok" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__res_config_settings__nextcloud_connection_status__online +msgid "Online" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__operation_type +msgid "Operation Type" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_res_config_settings__nextcloud_password +msgid "Password" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__prev_value +msgid "Prev Value" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +msgid "Previous Value" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__read +msgid "Read" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.calendar_event_form_view +msgid "Resources" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__response_description +msgid "Response Description" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_res_config_settings__nextcloud_url +msgid "Server URL" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.actions.act_window,name:nextcloud_odoo_sync.res_config_settings_nc_sync_settings_action +#: model:ir.ui.menu,name:nextcloud_odoo_sync.nc_sync_settings_menu +msgid "Settings" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__severity +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log_line__severity +msgid "Severity" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_tree_view +msgid "Start" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_log__state +msgid "State" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.calendar_event_form_view +msgid "Status" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.ui.menu,name:nextcloud_odoo_sync.menu_main_nextcloud_nextcloud +msgid "Sync" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.ui.menu,name:nextcloud_odoo_sync.menu_main_nextcloud_sync_log +msgid "Sync Activity" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:res.groups,name:nextcloud_odoo_sync.group_nextcloud_sync_admin +msgid "Sync Admin" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__sync_calendar +msgid "Sync Calendar" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_form_view +msgid "Sync Code" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:res.groups,name:nextcloud_odoo_sync.group_nextcloud_sync_user +msgid "Sync User" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.nc_sync_log_tree_view +msgid "Sync code" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.actions.act_window,name:nextcloud_odoo_sync.nc_sync_log_action +msgid "Sync's" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_error__type +msgid "Type" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model_terms:ir.ui.view,arch_db:nextcloud_odoo_sync.calendar_event_form_view +msgid "UID" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__user_id +msgid "User" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields,field_description:nextcloud_odoo_sync.field_nc_sync_user__user_name +msgid "User Name" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.actions.act_window,name:nextcloud_odoo_sync.nc_sync_user_action +#: model:ir.ui.menu,name:nextcloud_odoo_sync.menu_main_nextcloud_sync_user +msgid "User Setup" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_error__severity__warning +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__warning +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__severity__warning +msgid "Warning" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model.fields.selection,name:nextcloud_odoo_sync.selection__nc_sync_log_line__operation_type__write +msgid "Write" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nc_calendar +msgid "nc.calendar" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nc_event_status +msgid "nc.event.status" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nc_sync_error +msgid "nc.sync.error" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nc_sync_log +msgid "nc.sync.log" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nc_sync_log_line +msgid "nc.sync.log.line" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nc_sync_user +msgid "nc.sync.user" +msgstr "" + +#. module: nextcloud_odoo_sync +#: model:ir.model,name:nextcloud_odoo_sync.model_nextcloud_caldav +msgid "nextcloud.caldav" +msgstr "" diff --git a/nextcloud_odoo_sync/models/__init__.py b/nextcloud_odoo_sync/models/__init__.py new file mode 100644 index 0000000..3d80f41 --- /dev/null +++ b/nextcloud_odoo_sync/models/__init__.py @@ -0,0 +1,15 @@ +# Copyright (c) 2022 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from . import nextcloud_base +from . import nextcloud_caldav +from . import calendar_event +from . import calendar_recurrence +from . import nc_sync_user +from . import nc_sync_log +from . import nc_sync_error +from . import nc_calendar +from . import nc_event_status +from . import res_users +from . import res_partner +from . import res_config_settings diff --git a/nextcloud_odoo_sync/models/calendar_event.py b/nextcloud_odoo_sync/models/calendar_event.py new file mode 100644 index 0000000..2c4c8ef --- /dev/null +++ b/nextcloud_odoo_sync/models/calendar_event.py @@ -0,0 +1,368 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import ast +import pytz + +from odoo import api, models, fields, _ +from odoo.exceptions import UserError + + +class CalendarEvent(models.Model): + _inherit = "calendar.event" + + def _get_nc_calendar_selection(self): + nc_calendar_ids = self.nc_calendar_ids or self.env.user.nc_calendar_ids + values = [] + if nc_calendar_ids: + [values.append((str(x.id), x.name)) for x in nc_calendar_ids] + return values + + nc_uid = fields.Char("UID") + nc_rid = fields.Char("RECURRENCE-ID", compute="_compute_nc_rid", store=True) + nc_color = fields.Char(string="Color") + + nc_calendar_select = fields.Selection( + _get_nc_calendar_selection, + string="Nextcloud Calendar", + help="Select which Nextcloud Calendar the event will be recorded into", + ) + + nc_calendar_id = fields.Many2one( + "nc.calendar", "Nextcloud Calendar", compute="_compute_nc_calendar" + ) + nc_status_id = fields.Many2one("nc.event.status", string="Status") + nc_calendar_ids = fields.Many2many("nc.calendar", string="Calendars") + nc_hash_ids = fields.One2many( + "calendar.event.nchash", "calendar_event_id", "Hash Values" + ) + + nc_require_calendar = fields.Boolean(compute="_compute_nc_require_calendar") + nc_synced = fields.Boolean("Synced") + nc_to_delete = fields.Boolean("To Delete") + nc_allday = fields.Boolean("Nextcloud All day") + nc_detach = fields.Boolean("Detach from recurring event") + nc_event_updateable = fields.Boolean("Event Updateable In Nextcloud", compute="_compute_nc_event_updateable") + nextcloud_event_timezone = fields.Char('Nextcloud Event Timezone') + nextcloud_calendar_type = fields.Char('Nextcloud Calendar Type') + nextcloud_rrule = fields.Char('Nextcloud Rrule') + + @api.model + def default_get(self, fields): + """ + Inherited odoo base function: Added event status default value + to 'Confirmed' + :param fields: Odoo base fields + :return Super: add changes into this predefined functions + """ + res = super(CalendarEvent, self).default_get(fields) + res["nc_status_id"] = self.env.ref( + "nextcloud_odoo_sync.nc_event_status_confirmed" + ).id + return res + + @api.depends("recurrence_id", "allday", "start", "event_tz", "nextcloud_event_timezone") + def _compute_nc_rid(self): + """ + This method generates a value for RECURRENCE-ID + of Nextcloud recurring event + """ + for event in self: + if event.recurrence_id: + if not event.allday: + start = event.start + tz = event.nextcloud_event_timezone + if tz and not event.nc_rid: + dt_tz = start.replace(tzinfo=pytz.utc) + start = dt_tz.astimezone( + pytz.timezone(tz)) + event.nc_rid = start.strftime("%Y%m%dT%H%M%S") + else: + event.nc_rid = event.nc_rid or False + else: + event.nc_rid = event.nc_rid or event.start.strftime("%Y%m%d") + else: + event.nc_rid = event.nc_rid or False + + @api.depends("nc_calendar_ids") + def _compute_nc_calendar(self): + """ + This method computes the value of Nextcloud calendar name to display + """ + for event in self.sudo().with_context(sync=True): + calendar = False + if event.nc_calendar_ids: + # Get calendar to display on event based on the current user + calendar_id = event.nc_calendar_ids.filtered( + lambda x: x.user_id == self.env.user + ) + if calendar_id: + calendar = calendar_id.ids[0] + event.nc_calendar_id = calendar + event.nc_calendar_select = str(calendar) if calendar else False + + @api.depends('nc_calendar_id', 'nextcloud_calendar_type') + def _compute_nc_event_updateable(self): + for event in self: + nc_event_updateable = True + if event.nextcloud_calendar_type: + nc_event_updateable = False + elif event.nc_calendar_id: + default_calendar_id = ( + self.env["nc.sync.user"] + .search([("user_id", "=", self.env.user.id), ("sync_calendar", "=", True)], limit=1) + .mapped("nc_calendar_id") + ) + if event.nc_calendar_id != default_calendar_id: + nc_event_updateable = False + event.nc_event_updateable = nc_event_updateable + + @api.depends("duration", "partner_ids", "user_id") + def _compute_nc_require_calendar(self): + """ + This method determine whether to require a + value for the Nextcloud calendar + """ + nc_calendar_ids = self.env.user.nc_calendar_ids + for event in self: + if nc_calendar_ids and event.user_id and event.user_id == event.env.user: + event.nc_require_calendar = True + else: + event.nc_require_calendar = False + + @api.onchange("user_id") + def onchange_nc_user_id(self): + """ + This method will set the default value of nc_calendar_select + if the user is required to select a Nextcloud Calendar + """ + if self.user_id: + if self.nc_require_calendar: + default_calendar_id = ( + self.env["nc.sync.user"] + .search([("user_id", "=", self.user_id.id), ("sync_calendar", "=", True)], limit=1) + .mapped("nc_calendar_id") + ) + if default_calendar_id and self.user_id == self.env.user: + self.nc_calendar_select = str(default_calendar_id.id) + else: + self.nc_calendar_select = False + self.nc_calendar_ids = False + else: + self.nc_calendar_select = False + self.nc_calendar_ids = False + + @api.onchange("nc_calendar_select") + def onchange_nc_calendar_select(self): + """ + This method ensures that the Nextcloud Calendar stored in the + nc_calanedar_ids field is updated with the value selected by + the user in nc_calendar_select and that old values are removed + """ + if self.nc_require_calendar: + if self.nc_calendar_select: + calendar_id = self.env["nc.calendar"].browse( + int(self.nc_calendar_select) + ) + elif not self.nc_calendar_select and self.user_id: + calendar_id = ( + self.env["nc.sync.user"] + .search([("user_id", "=", self.user_id.id), ("sync_calendar", "=", True)], limit=1) + .mapped("nc_calendar_id") + ) + else: + calendar_id = self.env["nc.calendar"] + new_calendar_ids = [] + if self.nc_calendar_ids: + new_calendar_ids = self.nc_calendar_ids.ids + # Get previously linked current user calendar and + # replace it with the newly selected calendar + prev_calendar_ids = self.nc_calendar_ids.filtered( + lambda x: x.user_id == self.env.user + ) + if prev_calendar_ids: + new_calendar_ids = list( + set(new_calendar_ids) - set(prev_calendar_ids.ids) + ) + if calendar_id: + new_calendar_ids.append(calendar_id.id) + self.nc_calendar_ids = [(6, 0, new_calendar_ids)] + + @api.model + def create(self, vals): + """ + Inherited odoo base function + :params vals: Dictionary of record changes + :return Super: add changes into this predefined functions + """ + # Handle untitled event since Nextcloud event + # can be saved without title + if "name" not in vals or not vals["name"]: + vals["name"] = "Untitled event" + if "allday" in vals: + vals["nc_allday"] = vals["allday"] + if "nc_status_id" not in vals or not vals["nc_status_id"]: + vals["nc_status_id"] = self.env.ref( + "nextcloud_odoo_sync.nc_event_status_confirmed" + ).id + res = super(CalendarEvent, self).create(vals) + if vals.get('user_id'): + # Check if a value for calendar exist for the user: + nc_sync_user_id = self.env["nc.sync.user"].search( + [("user_id", "=", vals["user_id"]), ("sync_calendar", "=", True)], limit=1 + ) + if "nc_calendar_ids" not in vals or vals["nc_calendar_ids"] == [[6, False, []]]: + if nc_sync_user_id and nc_sync_user_id.nc_calendar_id: + res.nc_calendar_ids = [(4, nc_sync_user_id.nc_calendar_id.id)] + if not self._context.get("sync_from_nextcloud", + False) and res.nc_calendar_id and res.nc_calendar_id != nc_sync_user_id.nc_calendar_id: + raise UserError(_('You cannot create nextcloud events for calendars other than default one(%s)', + nc_sync_user_id.nc_calendar_id.name)) + return res + + def write(self, vals): + """ + Inherited odoo base function + :params vals: Dictionary of record changes + :return Super: add changes into this predefined functions + """ + + ex_fields = [ + "nc_uid", + "nc_rid", + "nc_hash_ids", + "nc_synced", + "nc_to_delete", + "recurrence_id", + "nc_calendar_select", + "nextcloud_event_timezone", + "nextcloud_rrule", + ] + fields_to_update = list(vals.keys()) + calendar_recurrence_obj = self.env["calendar.recurrence"].sudo() + detach = False + if not self._context.get('update_recurring', False): + if not vals.get('recurrence_update', '') in ['future_events', 'all_events']: + for f in fields_to_update: + if f not in ex_fields: + detach = True + break + else: + self = self.with_context(update_recurring=True) + ex_fields.extend(["nc_allday", "event_tz", "write_date", "nextcloud_calendar_type"]) + ex_fields.remove('nc_to_delete') + record_updated = False + for f in fields_to_update: + if f not in ex_fields: + record_updated = True + break + if not self._context.get("sync", + False) and "nc_synced" not in vals and record_updated and not self._context.get( + 'update_recurring', False): + vals["nc_synced"] = False + if self._context.get('update_recurring', False) and len(self.ids) == 1 and self.ids == [ + self.recurrence_id.base_event_id.id]: + vals["nc_synced"] = False + if not self._context.get('update_nc_rid', False): + if not self.allday: + start = self.start + tz = self.nextcloud_event_timezone + if tz: + dt_tz = start.replace(tzinfo=pytz.utc) + start = dt_tz.astimezone( + pytz.timezone(tz)) + nc_rid = start.strftime("%Y%m%dT%H%M%S") + else: + nc_rid = self.nc_rid + else: + nc_rid = self.start.strftime("%Y%m%d") + vals["nc_rid"] = nc_rid + if vals.get('recurrence_update') == 'future_events': + for record in self: + record.recurrence_id.base_event_id.sudo().write( + {'nc_synced': False, 'nc_uid': False, 'nc_hash_ids': [(6, 0, [])]}) + for record in self: + # Detach the record from recurring event whenever an edit was made + # to make it compatible when synced to Nextcloud calendar + if not self._context.get("sync_from_nextcloud", + False) and detach and record.nc_uid and record.user_id and record.user_id != self.env.user: + raise UserError(_('You cannot update nextcloud events if you are not the organizer')) + if not self._context.get("sync_from_nextcloud", + False) and not record.nc_event_updateable and detach: + if record.nextcloud_calendar_type: + raise UserError(_('You cannot update nextcloud events for Birthday calendars')) + default_calendar_id = ( + self.env["nc.sync.user"] + .search([("user_id", "=", self.env.user.id), ("sync_calendar", "=", True)], limit=1) + .mapped("nc_calendar_id") + ) + raise UserError(_('You cannot update nextcloud events for calendars other than default one(%s)', + default_calendar_id.name)) + if not self._context.get('sync_from_nextcloud'): + if 'active' in vals and not vals.get('active'): + new_recurrence = calendar_recurrence_obj.search([('base_event_id', '=', record.id)], + limit=1) + if new_recurrence: + new_recurring_events = new_recurrence.calendar_event_ids.sorted( + key=lambda r: r.start + ) + if new_recurring_events: + new_recurrence.base_event_id = new_recurring_events[0].id + hash_vals_list = [] + for rec in record.nc_hash_ids: + hash_vals_list.append((0, 0, {"nc_sync_user_id": rec.nc_sync_user_id.id, + "nc_event_hash": rec.nc_event_hash, })) + new_recurring_events.write({'nc_hash_ids': hash_vals_list, 'nc_synced': True}) + new_recurring_events[0].write({"nc_synced": False}) + if record.recurrence_id: + if detach: + vals.update({"nc_detach": True}) + return super(CalendarEvent, self).write(vals) + + def unlink(self): + """ + We can"t delete an event that is also in Nextcloud Calendar. + Otherwise we would have no clue that the event must must deleted + from Nextcloud Calendar at the next sync. We just mark the event as to + delete (nc_to_delete=True) before we sync. + """ + has_nc_uids = self.env['calendar.event'] + if not self._context.get("force_delete", False): + for record in self: + if record.nc_uid and record.user_id and record.user_id != self.env.user: + raise UserError(_('You cannot delete nextcloud events if you are not the organizer')) + default_calendar_id = ( + self.env["nc.sync.user"] + .search([("user_id", "=", self.env.user.id), ("sync_calendar", "=", True)], limit=1) + .mapped("nc_calendar_id") + ) + has_nc_uids = self.filtered(lambda r: r.nc_uid and r.nc_calendar_id == default_calendar_id) + if has_nc_uids: + has_nc_uids.write({"nc_to_delete": True}) + for record in self: + if record.recurrence_id: + nc_exdates = ( + ast.literal_eval(str(record.recurrence_id.nc_exdate)) + if record.recurrence_id.nc_exdate + else [] + ) + if record.nc_rid: + nc_exdates.append(record.nc_rid) + record.recurrence_id.write({"nc_exdate": nc_exdates}) + self = self - has_nc_uids + return super(CalendarEvent, self).unlink() + + +class CalendarEventNchash(models.Model): + _name = "calendar.event.nchash" + _description = "Calendar Event Nextcloud Hash" + + calendar_event_id = fields.Many2one( + "calendar.event", "Calendar Event", ondelete="cascade" + ) + user_id = fields.Many2one( + "res.users", "Odoo User", related="nc_sync_user_id.user_id", store=True + ) + nc_sync_user_id = fields.Many2one("nc.sync.user", "Sync User", ondelete="cascade") + nc_uid = fields.Char("UID", related="calendar_event_id.nc_uid", store=True) + nc_event_hash = fields.Char("Event Hash") diff --git a/nextcloud_odoo_sync/models/calendar_recurrence.py b/nextcloud_odoo_sync/models/calendar_recurrence.py new file mode 100644 index 0000000..a0492f1 --- /dev/null +++ b/nextcloud_odoo_sync/models/calendar_recurrence.py @@ -0,0 +1,129 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models, fields, _ +from dateutil import rrule +from odoo.exceptions import UserError +from datetime import timedelta,datetime + +SELECT_FREQ_TO_RRULE = { + 'daily': rrule.DAILY, + 'weekly': rrule.WEEKLY, + 'monthly': rrule.MONTHLY, + 'yearly': rrule.YEARLY, +} + +RRULE_WEEKDAYS = {'SUN': 'SU', 'MON': 'MO', 'TUE': 'TU', 'WED': 'WE', 'THU': 'TH', 'FRI': 'FR', 'SAT': 'SA'} + + +def freq_to_rrule(freq): + return SELECT_FREQ_TO_RRULE[freq] + +def weeks_between(start_date, end_date): + weeks = rrule.rrule(rrule.WEEKLY, dtstart=start_date, until=end_date) + return weeks.count() - 1 + +def months_between(start_date, end_date): + months = rrule.rrule(rrule.MONTHLY, dtstart=start_date, until=end_date) + return months.count() - 1 + +def years_between(start_date, end_date): + years = rrule.rrule(rrule.YEARLY, dtstart=start_date, until=end_date) + return years.count() - 1 + +def days_between(start_date, end_date): + days = rrule.rrule(rrule.DAILY, dtstart=start_date, until=end_date) + return days.count() - 1 + + +class CalendarRecurrence(models.Model): + _inherit = "calendar.recurrence" + + nc_exdate = fields.Char("Nextcloud Exdate") + + + + def _get_rrule(self, dtstart=None): + self.ensure_one() + + if self._context.get('update_until', False): + if self.until: + self.until = self.until - timedelta(days=1) + if not self.base_event_id.nc_calendar_id or self.end_type != 'forever': + return super()._get_rrule(dtstart) + freq = self.rrule_type + rrule_params = dict( + dtstart=dtstart, + interval=self.interval, + ) + today = datetime.today().date() + dtstart_date = (self.dtstart or dtstart).date() + past_event = True if (today - dtstart_date).days > 0 else False + config = self.env["ir.config_parameter"].sudo() + if freq == 'monthly' and self.month_by == 'date': # e.g. every 15th of the month + rrule_params['bymonthday'] = self.day + elif freq == 'monthly' and self.month_by == 'day': # e.g. every 2nd Monday in the month + rrule_params['byweekday'] = getattr(rrule, RRULE_WEEKDAYS[self.weekday])( + int(self.byday)) # e.g. MO(+2) for the second Monday of the month + elif freq == 'weekly': + weekdays = self._get_week_days() + if not weekdays: + raise UserError(_("You have to choose at least one day in the week")) + rrule_params['byweekday'] = weekdays + rrule_params['wkst'] = self._get_lang_week_start() + weekly_recurring_events_limit_value = (2 + if not config.get_param( + "nextcloud_odoo_sync.weekly_recurring_events_limit") + else int(config.get_param( + "nextcloud_odoo_sync.weekly_recurring_events_limit")) + ) * 52 + if past_event: + weekly_recurring_events_limit_value += weeks_between(dtstart_date,today) + rrule_params['count'] = (( + weekly_recurring_events_limit_value // self.interval) if self.interval < weekly_recurring_events_limit_value else 1) * len( + weekdays) # maximum recurring events for 2 years + + elif freq == 'daily': + daily_recurring_events_limit_value = (2 + if not config.get_param( + "nextcloud_odoo_sync.daily_recurring_events_limit") + else int(config.get_param( + "nextcloud_odoo_sync.daily_recurring_events_limit") + )) * 365 + if past_event: + daily_recurring_events_limit_value += days_between(dtstart_date,today) + rrule_params['count'] = ( + daily_recurring_events_limit_value // self.interval) if self.interval < daily_recurring_events_limit_value else 1 # maximum recurring events for 2 years + if freq in ('yearly', 'monthly'): + yearly_recurring_events_limit_value = (10 + if not config.get_param( + "nextcloud_odoo_sync.yearly_recurring_events_limit") + else int(config.get_param( + "nextcloud_odoo_sync.yearly_recurring_events_limit") + )) + monthly_recurring_events_limit_value = (2 + if not config.get_param( + "nextcloud_odoo_sync.monthly_recurring_events_limit") + else int(config.get_param( + "nextcloud_odoo_sync.monthly_recurring_events_limit") + )) * 12 + if freq == 'yearly': + if past_event: + yearly_recurring_events_limit_value += years_between(dtstart_date, today) + rrule_params['count'] = ( + yearly_recurring_events_limit_value // self.interval) if self.interval < yearly_recurring_events_limit_value else 1 # maximum recurring events for 10 years + elif freq == 'monthly': + if self.interval >= 12: + if past_event: + yearly_recurring_events_limit_value += years_between(dtstart_date, today) + rrule_params['count'] = (yearly_recurring_events_limit_value // ( + self.interval // 12)) # maximum recurring events for years defined + else: + if past_event: + monthly_recurring_events_limit_value += months_between(dtstart_date, today) + rrule_params['count'] = ( + monthly_recurring_events_limit_value // self.interval) # maximum recurring events for months defined + + return rrule.rrule( + freq_to_rrule(freq), **rrule_params + ) diff --git a/nextcloud_odoo_sync/models/jicson.py b/nextcloud_odoo_sync/models/jicson.py new file mode 100644 index 0000000..8bc3649 --- /dev/null +++ b/nextcloud_odoo_sync/models/jicson.py @@ -0,0 +1,76 @@ +from urllib.request import Request, urlopen +import io + + +class StreamObject: + def __init__(self, type, url=None, auth=None, filePath=None, text=None): + self.type = type + self.url = url + self.auth = auth + self.filePath = filePath + self.text = text + + if self.type == "web": + request = Request(url) + request.add_header("Authorization", "Basic " + auth) + self.response = urlopen(request) + elif self.type == "file": + self.file = open(filePath) + elif self.type == "text": + self.buf = io.StringIO(text) + else: + self.buf = io.StringIO(text) + + def readline(self): + if self.type == "web": + line = self.response.readline().decode("utf-8") + elif self.type == "file": + line = self.file.readline() + elif self.type == "text": + line = self.buf.readline() + else: + line = self.buf.readline() + + line = line.rstrip("\n") + return line + + +def fromWeb(icsFileUrl, auth=None): + streamObject = StreamObject(type="web", url=icsFileUrl, auth=auth) + return parseChild({}, streamObject) + + +def fromFile(icsFilePath): + streamObject = StreamObject(type="file", filePath=icsFilePath) + return parseChild({}, streamObject) + + +def fromText(icsFileText): + streamObject = StreamObject(type="text", text=icsFileText) + return parseChild({}, streamObject) + + +def parseChild(json, fileObject): + while True: + line = fileObject.readline() + if not line: + return json + + line = line.rstrip("\n\r") + + separator = line.find(":") + + if separator == -1: + continue + + key = line[:separator] + value = line[separator + 1 :] + + if key == "BEGIN": + if value not in json: + json[value] = [] + json[value].append(parseChild({}, fileObject)) + elif key == "END": + return json + else: + json[key] = value diff --git a/nextcloud_odoo_sync/models/nc_calendar.py b/nextcloud_odoo_sync/models/nc_calendar.py new file mode 100644 index 0000000..dde87f5 --- /dev/null +++ b/nextcloud_odoo_sync/models/nc_calendar.py @@ -0,0 +1,22 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models, fields, api + + +class NcCalendar(models.Model): + _name = "nc.calendar" + _description = "Nextcloud Calendar" + + name = fields.Char(string="Calendar", required=True) + user_id = fields.Many2one( + "res.users", string="User", ondelete="cascade", required=True + ) + calendar_url = fields.Text(string="Calendar URL") + + @api.model + def create(self, vals): + if not self._context.get("sync", False): + if "user_id" not in vals: + vals["user_id"] = self.env.user.id + return super(NcCalendar, self).create(vals) diff --git a/nextcloud_odoo_sync/models/nc_event_status.py b/nextcloud_odoo_sync/models/nc_event_status.py new file mode 100644 index 0000000..f622216 --- /dev/null +++ b/nextcloud_odoo_sync/models/nc_event_status.py @@ -0,0 +1,11 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models, fields + + +class NcEventStatus(models.Model): + _name = "nc.event.status" + _description = "Nextcloud Event Status" + + name = fields.Char("Nextcloud Status") diff --git a/nextcloud_odoo_sync/models/nc_sync_error.py b/nextcloud_odoo_sync/models/nc_sync_error.py new file mode 100644 index 0000000..87791be --- /dev/null +++ b/nextcloud_odoo_sync/models/nc_sync_error.py @@ -0,0 +1,22 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models, fields + + +class NcSyncError(models.Model): + _name = "nc.sync.error" + _description = "Nextcloud Sync Error" + + name = fields.Char() + description = fields.Text() + type = fields.Selection([("odoo", "Odoo"), ("nextcloud", "NextCloud")]) + severity = fields.Selection( + [ + ("debug", "Debug"), + ("info", "Info"), + ("warning", "Warning"), + ("error", "Error"), + ("critical", "Critical"), + ] + ) diff --git a/nextcloud_odoo_sync/models/nc_sync_log.py b/nextcloud_odoo_sync/models/nc_sync_log.py new file mode 100644 index 0000000..c08bcec --- /dev/null +++ b/nextcloud_odoo_sync/models/nc_sync_log.py @@ -0,0 +1,284 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from datetime import datetime, timedelta +from odoo import models, fields + +import logging + +_logger = logging.getLogger(__name__) + + +class NcSyncLog(models.Model): + _name = "nc.sync.log" + _description = "Nextcloud Sync Log" + _order = "create_date desc" + + name = fields.Char(string="Sync code") + description = fields.Char() + date_start = fields.Datetime(string="Start") + date_end = fields.Datetime(string="End") + state = fields.Selection( + [ + ("connecting", "Connecting"), + ("in_progress", "In Progress"), + ("success", "Success"), + ("failed", "Failed"), + ("error", "Error"), + ], + string="State", + default="in_progress", + ) + next_cloud_url = fields.Char(string="NextCloud URL") + odoo_url = fields.Char(string="Odoo URL") + duration = fields.Char() + line_ids = fields.One2many("nc.sync.log.line", "log_id") + + def get_time_diff(self, date_from, date_to=False): + """ + This method checks the time difference between two datetime objects + in hours, minutes and seconds + @param: date_from, datetime + @param: date_to, datetime + @return: string + """ + if not date_to: + date_to = datetime.now() + diff = date_to - date_from + # Convert the difference to hours, minutes and seconds + hours, remainder = divmod(diff.seconds, 3600) + minutes, seconds = divmod(remainder, 60) + result = "" + if hours: + result += f"{hours} hours " + if minutes: + result += f"{minutes} minutes " + result += f"{seconds} seconds" + return result + + def check_and_log_users(self, sync_log_id): + """ + Function to Check and log NextCloud users information. + :param sync_log_id: single recordset of nc.sync.log model + :return: List, NextCloud users that are in linked in Odoo + """ + nc_sync_user_obj = self.env["nc.sync.user"] + nc_users = self.env["nextcloud.base"].get_users()["ocs"]["data"]["users"] + nc_user_email = [nc["id"] for nc in nc_users] + [nc["email"] for nc in nc_users] + odoo_users = nc_sync_user_obj.search_read([("sync_calendar", "=", True)]) + stg_users_odoo_not_in_nc = [ + x for x in odoo_users if x["user_name"] not in nc_user_email + ] + stg_users_nc_not_in_odoo = [] + username_list = [o["user_name"].lower() for o in odoo_users] + for x in nc_users: + if x["email"] and x["email"].lower() not in username_list: + if x["displayname"] and x["displayname"].lower() not in username_list: + stg_users_nc_not_in_odoo.append(x) + else: + stg_users_nc_not_in_odoo.append(x) + stg_users_nc_in_odoo = [] + # Compare Odoo users with Nextcloud users + if stg_users_odoo_not_in_nc: + odoo_usernames = ", ".join( + [x["name"] for x in stg_users_odoo_not_in_nc if x["name"]] + ) + sync_log_id.line_ids.create( + { + "log_id": sync_log_id.id, + "operation_type": "read", + "severity": "info", + "response_description": "Compare Odoo users with Nextcloud " + "users\n\t\tOdoo users not in Nextcloud: %s" % odoo_usernames, + } + ) + # Compare Nextcloud users with Odoo users + if stg_users_nc_not_in_odoo: + nc_usernames = ", ".join( + [x["displayname"] for x in stg_users_nc_not_in_odoo if x["displayname"]] + ) + sync_log_id.line_ids.create( + { + "log_id": sync_log_id.id, + "operation_type": "read", + "severity": "info", + "response_description": "Compare Nextcloud users with Odoo " + "users\n\tNextcloud users not in Odoo: %s" % nc_usernames, + } + ) + for odoo_user in odoo_users: + for nc_user in nc_users: + user_list = [] + if "email" in nc_user and nc_user["email"]: + user_list.append(nc_user["email"].lower()) + if "id" in nc_user and nc_user["id"]: + user_list.append(nc_user["id"].lower()) + if odoo_user["user_name"].lower() in user_list: + stg_users_nc_in_odoo.append(odoo_user) + nc_sync_user_obj.browse(odoo_user["id"]).write( + {"nextcloud_user_id": nc_user["id"]} + ) + + # Number of users to sync + count = len(stg_users_nc_in_odoo) + sync_log_id.line_ids.create( + { + "log_id": sync_log_id.id, + "operation_type": "read", + "severity": "info", + "response_description": "Number of users to sync: %s" % count, + } + ) + return stg_users_nc_in_odoo + + def log_event(self, mode="text", log_id=False, **params): + """ + This method takes care of the logging process + @param: mode, string, indicates the sync phase + @param: log_id, single recordset of nc.sync.log model + @return dictionary of values + """ + result = {"resume": True, "stg_users_nc_in_odoo": []} + log_line = self.env["nc.sync.log.line"] + res = {} + if mode == "pre_sync": + # Start Sync Process: Date + Time + datetime_now = datetime.now() + log_id = self.create( + { + "name": datetime_now.strftime("%Y%m%d-%H%M%S"), + "date_start": datetime_now, + "state": "connecting", + "line_ids": [ + ( + 0, + 0, + { + "operation_type": "login", + "response_description": "Start Sync Process", + }, + ) + ], + } + ) + result["log_id"] = log_id + # # Nextcloud connection test for Caldav + # if caldav_sync: + # data_send = "url: {}, username: {}, password: ****".format( + # nc_url, username + # ) + # res = { + # "operation_type": "login", + # "log_id": log_id.id, + # "data_send": data_send, + # } + # connection, principal = caldav_obj.check_nextcloud_connection( + # url=nc_url, username=username, password=password + # ) + # if not isinstance(principal, dict): + # res[ + # "response_description" + # ] = "Nextcloud connection test for Caldav: OK" + # else: + # response_description = ( + # """Nextcloud connection test for Caldav: Error + # \t%s""" + # % principal["response_description"] + # ) + # res.update( + # { + # "response_description": response_description, + # "error_code_id": principal["sync_error_id"].id + # if "sync_error_id" in principal + # else False, + # } + # ) + # result["resume"] = False + # log_line.create(res) + # + # # Compare Nextcloud users with Odoo users and vice versa + # if result["resume"] and log_id: + # result["stg_users_nc_in_odoo"] = self.check_and_log_users(log_id) + else: + error = str(params["error"]) if "error" in params else False + severity = params["severity"] if "severity" in params else "info" + operation_type = ( + params["operation_type"] if "operation_type" in params else "read" + ) + if not log_id: + log_id = self.browse(self.ids[0]) + res = { + "log_id": log_id.id, + "operation_type": operation_type, + "severity": severity, + } + + if mode == "text" and "message" in params: + res["response_description"] = params["message"] + + elif mode == "error" and error: + # Undo the last uncommitted changes + self.env.cr.rollback() + message = "%s " % params["message"] if "message" in params else "" + res["response_description"] = """{}{}""".format(message, error) + + try: + log_line.create(res) + except Exception as e: + _logger.warning("Error encountered during log operation: %s" % e) + return result + + # Create an event log + if "response_description" in res: + _logger.warning(res["response_description"]) + # Commit the changes to the database + self.env.cr.commit() + return result + + def delete_logs(self): + config = self.env["ir.config_parameter"].sudo() + capacity_value = ( + 7 + if not config.get_param("nextcloud_odoo_sync.log_capacity") + else config.get_param("nextcloud_odoo_sync.log_capacity") + ) + date_capacity = datetime.now() - timedelta(days=int(capacity_value)) + date_capacity = date_capacity.strftime("%Y-%m-%d %H:%M:%S") + sync_log_ids = self.search( + [("create_date", "<", date_capacity)], order="create_date desc" + ) + sync_log_ids.unlink() + + +class NcSyncLogLine(models.Model): + _name = "nc.sync.log.line" + _description = "Nextcloud Sync Log Line" + + log_id = fields.Many2one("nc.sync.log", "Log ID", ondelete="cascade") + operation_type = fields.Selection( + [ + ("create", "Create"), + ("write", "Write"), + ("delete", "Delete"), + ("read", "Read"), + ("login", "Login"), + ("conflict", "Conflict"), + ("warning", "Warning"), + ("error", "Error"), + ], + string="Operation Type", + ) + data_send = fields.Text("Data Sent") + error_code_id = fields.Many2one("nc.sync.error", "Error Code") + severity = fields.Selection( + [ + ("debug", "Debug"), + ("info", "Info"), + ("warning", "Warning"), + ("error", "Error"), + ("critical", "Critical"), + ], + string="Severity", + default="info", + ) + response_description = fields.Text("Response Description") diff --git a/nextcloud_odoo_sync/models/nc_sync_user.py b/nextcloud_odoo_sync/models/nc_sync_user.py new file mode 100644 index 0000000..73e5fb6 --- /dev/null +++ b/nextcloud_odoo_sync/models/nc_sync_user.py @@ -0,0 +1,566 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + + +import hashlib +import json + +from datetime import datetime, date +from odoo import models, fields, api, _ +from odoo.exceptions import ValidationError +from odoo.addons.nextcloud_odoo_sync.models import jicson + + +class NcSyncUser(models.Model): + _name = "nc.sync.user" + _inherit = ["nextcloud.caldav"] + _description = "Nextcloud Sync User" + + name = fields.Char("Odoo Username", related="user_id.name") + nextcloud_user_id = fields.Char("Nextcloud User ID") + user_name = fields.Char("Username", required=True) + nc_password = fields.Char("Password", required=True) + user_message = fields.Char( + default="'Default Calendar' field will be used" + "as your default Nextcloud calendar when " + "creating new events in Odoo" + ) + + user_id = fields.Many2one( + "res.users", "Odoo User", required=True, default=lambda self: self.env.user.id + ) + partner_id = fields.Many2one( + "res.partner", "Partner", related="user_id.partner_id", store=True + ) + nc_calendar_id = fields.Many2one("nc.calendar", "Default Calendar", help="Allows 2 way syncing with this calendar") + nc_calendar_ids = fields.Many2many("nc.calendar", "nc_sync_user_nc_calendar_rel", "sync_user_id", "nc_calendar_id", + string="Show Other Calendars") + nc_hash_ids = fields.One2many( + "calendar.event.nchash", "nc_sync_user_id", "Hash Values" + ) + + user_has_calendar = fields.Boolean( + "User has calendar", compute="compute_user_has_calendar" + ) + sync_calendar = fields.Boolean("Sync Calendar") + nc_email = fields.Char("Email") + start_date = fields.Date("Sync Events From This Date", default=date.today()) + nextcloud_url = fields.Char(string="Server URL", required=True) + + @api.depends("user_id", "user_name", "nc_password") + def compute_user_has_calendar(self): + """ + This method determine if current Odoo user + have Nextcloud calendar records + """ + for user in self: + user.user_has_calendar = ( + True if user.user_id and user.user_id.nc_calendar_ids else False + ) + + @api.constrains("user_id", "user_name") + def check_user_exist(self): + """ + Checks if user information is already exists + :return error if record already exist + """ + for user in self: + sync_user_id = self.search( + [ + "&", + ("id", "!=", user.id), + "|", + ("user_id", "=", user.user_id.id), + ("user_name", "=ilike", user.user_name), + ], + limit=1, + ) + if sync_user_id: + raise ValidationError( + _( + "Existing configuration found. The selected Odoo User '%s'" + " or Nextcloud Username '%s' is already mapped to an" + " existing record" % (user.user_id.name, user.user_name) + ) + ) + + @api.onchange("nc_calendar_id", "nc_email") + def onchange_nc_calendar_id(self): + """ + Update user message upon changing nextclound calenda + """ + if self.nc_calendar_id: + self.user_message = ( + "%s will be used as your default Odoo " + "calendar when creating new events" + ) % self.nc_calendar_id.name + if self.nc_email: + self.sync_calendar = True + if self.nc_calendar_id.id in self.nc_calendar_ids.ids: + self.nc_calendar_ids = [(3, self.nc_calendar_id.id)] + else: + self.sync_calendar = False + self.user_message = ( + "'Default Calendar' field will be used as " + "your default odoo calendar when creating " + "new events" + ) + + @api.onchange("nc_calendar_ids") + def onchange_nc_calendar_ids(self): + if self.nc_calendar_id.id in self.nc_calendar_ids.ids: + self.nc_calendar_ids = [(3, self.nc_calendar_id.id)] + + @api.model + def create(self, vals): + if vals.get('nextcloud_url', False): + vals["nextcloud_url"] = vals["nextcloud_url"].strip("/") + return super(NcSyncUser, self).create(vals) + + def write(self, vals): + """ + Inherited odoo base function + :param vals: Dictionary of record changes + :return add changes into this predefined functions + """ + nc_calendar_ids = [] + calendar_event_obj = self.env["calendar.event"] + if vals.get('user_name') or vals.get('nc_password') or vals.get('nextcloud_url', False) or vals.get( + 'sync_calendar'): + nextcloud_caldav_obj = self.env["nextcloud.caldav"] + for record in self: + nc_url = ((vals.get("nextcloud_url", "").strip("/") if vals.get( + "nextcloud_url") else record.nextcloud_url) + "/remote.php/dav") + username = vals.get('user_name', False) or record.user_name + nc_password = vals.get('nc_password', False) or record.nc_password + connection, principal = nextcloud_caldav_obj.check_nextcloud_connection( + url=nc_url, username=username, password=nc_password + ) + if isinstance(principal, dict): + sync_error = principal["sync_error_id"].name + response = principal["response_description"] + raise ValidationError(f"{sync_error}: {response}") + if "nc_calendar_id" in vals: + calendar_event_ids = ( + calendar_event_obj + .search( + [ + "|", + ("user_id", "=", self.user_id.id), + ("partner_ids", "in", self.user_id.partner_id.id), + ("nc_synced", "=", False), + ] + ) + .filtered( + lambda x: (not x.nc_calendar_ids) and x.start >= datetime.combine(self.start_date or date.today(), + datetime.min.time()) + ) + ) + # calendar_ids = calendar_event_ids.nc_calendar_ids.filtered( + # lambda x: x.user_id == self.user_id + # ) + # for calendar_id in calendar_ids: + # calendar_event_ids.nc_calendar_ids = [(3, calendar_id.id)] + calendar_event_ids.with_context(sync=True).write( + {"nc_calendar_ids": [(4, vals["nc_calendar_id"])]} + ) + if vals.get('nextcloud_url', False): + vals["nextcloud_url"] = vals["nextcloud_url"].strip("/") + if vals.get('nc_calendar_ids'): + nc_calendar_ids = self.nc_calendar_ids + res = super(NcSyncUser, self).write(vals) + if vals.get('nc_calendar_ids'): + for record in self: + for rec in nc_calendar_ids: + if rec not in record.nc_calendar_ids and rec != record.nc_calendar_id: + calendar_event_obj.search( + [("partner_ids", "in", record.user_id.partner_id.id), ('nc_uid', '!=', False)]).filtered( + lambda x: len(x.nc_calendar_ids) == 1 and rec in x.nc_calendar_ids).with_context( + force_delete=True).unlink() + return res + + def unlink(self): + """ + Inherited odoo base function + :return add changes into this predefined functions + """ + calendar_event_obj = self.env["calendar.event"] + calendar_event_hash_obj = self.env["calendar.event.nchash"] + for record in self.filtered(lambda x: x.user_id): + calendar_event_hash_obj.search([('nc_sync_user_id','=',record.id)]).unlink() + calendar_ids = calendar_event_obj.search( + [("user_id", "=", record.user_id.id)] + ) + calendar_ids.filtered(lambda x:not x.nc_hash_ids).write({"nc_uid": False, "nc_synced": False}) + # Remove all Nextcloud calendar records + record.user_id.nc_calendar_ids.unlink() + return super(NcSyncUser, self).unlink() + + def save_user_config(self): + """ + Returns calendar event action. Close the pop-up and display the + calendar event records + :return calendar event action + """ + return self.env.ref("calendar.action_calendar_event").sudo().read()[0] + + def get_user_connection(self): + nc_url = (self.nextcloud_url + "/remote.php/dav") + connection, principal = self.env["nextcloud.caldav"].check_nextcloud_connection( + url=nc_url, username=self.user_name, password=self.nc_password + ) + if isinstance(principal, dict): + sync_error = principal["sync_error_id"].name + response = principal["response_description"] + raise ValidationError(f"{sync_error}: {response}") + user_data = self.env["nextcloud.base"].get_user(principal.client.username, self.nextcloud_url, self.user_name, + self.nc_password) + self.nc_email = user_data.get("email", False) if user_data else False + return {"connection": connection, "principal": principal} + + def get_user_calendars(self, principal): + """ + This method gets all the calendar records of the + Nextcloud user and create it in Odoo if not exist + :param connection: CalDav connection principal object + """ + nc_calendars = principal.calendars() + nc_calendars = [ + cal for cal in nc_calendars if "shared_by" not in cal.canonical_url + ] + # Remove Nextcloud calendars in Odoo if the canonical URL + # no longer exist in Nextcloud + nc_calendar_obj = self.env["nc.calendar"] + calendar_not_in_odoo_ids = nc_calendar_obj.search( + [ + ( + "calendar_url", + "not in", + [str(x.canonical_url) for x in nc_calendars], + ), + ("user_id", "=", self.user_id.id), + ] + ) + if calendar_not_in_odoo_ids: + calendar_not_in_odoo_ids.sudo().unlink() + result = [] + nc_calendar_ids = nc_calendar_obj.search([("user_id", "=", self.user_id.id)]) + for record in nc_calendars: + nc_calendar_id = nc_calendar_ids.filtered( + lambda x: x.name == record.name + and x.calendar_url == record.canonical_url + ) + if not nc_calendar_id: + result.append( + { + "name": record.name, + "user_id": self.user_id.id, + "calendar_url": record.canonical_url, + } + ) + if result: + self.env["nc.calendar"].sudo().create(result) + + def check_nc_connection(self): + """ + This method connects to Nextcloud server using the + username and password provided for the user and + triggers the creation of Nextcloud Calendar record + for use in creating events in Odoo + :return Dictionary, odoo action + """ + connection_dict = self.sudo().get_user_connection() + principal = connection_dict.get("principal", False) + self.get_user_calendars(principal) + if not self.nc_calendar_id and self.user_id.nc_calendar_ids: + self.nc_calendar_id = self.user_id.nc_calendar_ids[0] + target = "new" if self._context.get("pop_up") else "main" + res = { + "name": "NextCloud User Setup", + "view_mode": "form", + "res_model": "nc.sync.user", + "type": "ir.actions.act_window", + "target": target, + "res_id": self.id, + } + if target == "main": + res.update( + {"context": {"form_view_initial_mode": "edit", "no_footer": True}} + ) + return res + + def get_event_data(self, event): + """ + This method returns the following data of an event: + UID, hash, dictionary of event values + :param event: Calendar event object + :return dictionary of event values + """ + event_vals = jicson.fromText(event.data).get("VCALENDAR")[0].get("VEVENT") + data = [] + nc_uid = False + # Remove the DTSTAMP values as it always changes + # when event get queried from Nextcloud + for d in event_vals: + nc_uid = d["UID"] + d.pop("DTSTAMP") + d.pop("SEQUENCE", False) + exdate_key = [k for k, v in d.items() if "EXDATE" in k] + vevent = event.vobject_instance.vevent + if isinstance(vevent.dtstart.value, datetime): + date_format = "%Y%m%dT%H%M%S" + else: + date_format = "%Y%m%d" + if exdate_key: + tz = False + if "TZID" in exdate_key[0]: + tz = exdate_key[0].split("=")[1] + d.pop(exdate_key[0]) + d["exdates"] = [ + x.value[0].strftime(date_format) for x in vevent.exdate_list + ] + d["exdate_tz"] = tz + data.append(d) + vals = {"data": data} + vals["uid"] = nc_uid + json_data = str(json.dumps(vals["data"], sort_keys=True)).encode("utf-8") + vals["hash"] = hashlib.sha1(json_data).hexdigest() + return vals + + def get_all_user_events(self, **params): + """ + This method get all user events in both Odoo and Nextcloud + :param log_id: single recordset of nc.sync.log model + :params **params: dictionary of multiple recordsets + from different models + :return Dictionary of odoo and nextcloud events + """ + events = params["all_odoo_event_ids"] + log_obj = params["log_obj"] + result = { + "od_events": [], + "nc_events": [], + "connection": False, + "principal": False, + } + for user in self: + start_date = datetime.combine(self.start_date or date.today(), datetime.min.time()) + if not events: + events = self.env["calendar.event"].sudo().search([('start', '>=', start_date)], order="start") + try: + connection_dict = self.get_user_connection() + principal = connection_dict.get("principal", False) + result["principal"] = principal + result["connection"] = connection_dict.get("connection", False) + self.get_user_calendars(principal) + except Exception as error: + if log_obj: + log_obj.log_event("error", error=error, message="Nextcloud:") + return result + else: + raise ValidationError(_(error)) + if not self.nc_calendar_id: + error = "Default Calendar is deleted from Nextcloud" + if log_obj: + log_obj.log_event("error", error=error, message="Nextcloud:") + return result + else: + raise ValidationError(_(error)) + try: + # Get all Odoo events where user is organizer or attendee + od_event_ids = events.filtered( + lambda x: x.user_id == user.user_id + or (x.partner_ids and user.partner_id in x.partner_ids) + ) + for event in od_event_ids: + # if event is not yet syned into nextcloud but the current + # sync user is only an attendee of the event then the event + # should not be created in nextcloud since it will + # be automatically created by the event organizer + if ( + event.user_id in params["all_sync_user_ids"].mapped("user_id") + and not event.nc_uid + and event.user_id != user.user_id + ): + continue + event_hash = False + if event.nc_hash_ids: + event_hash = event.nc_hash_ids.filtered( + lambda x: x.nc_sync_user_id == user + ).mapped("nc_event_hash") + result["od_events"].append( + { + "nc_uid": event.nc_uid, + "od_event": event, + "event_hash": event_hash[0] if event_hash else False, + } + ) + if event.recurrence_id and event.recurrence_id.base_event_id not in od_event_ids: + event_hash = False + base_event = event.recurrence_id.base_event_id + if base_event.nc_hash_ids: + event_hash = base_event.nc_hash_ids.filtered( + lambda x: x.nc_sync_user_id == user + ).mapped("nc_event_hash") + result["od_events"].append( + { + "nc_uid": base_event.nc_uid, + "od_event": base_event, + "event_hash": event_hash[0] if event_hash else False, + } + ) + + # Get all Nextcloud events of the user + nc_calendar_obj = self.env["nc.calendar"] + for calendar in principal.calendars(): + # Check if calendar exist for the user and make sure + # it has the same name as the Nextcloud calendar in case + # the user rename it in Nextcloud, otherwise create a new + # calendar if not exist + if "shared_by" in calendar.canonical_url: + continue + nc_calendar_id = nc_calendar_obj.search( + [ + ("user_id", "=", self.user_id.id), + ("calendar_url", "=", calendar.canonical_url), + ], + limit=1, + ) + if nc_calendar_id: + if calendar.name != nc_calendar_id.name: + nc_calendar_id.name = calendar.name + else: + nc_calendar_obj.sudo().create( + { + "name": calendar.name, + "user_id": user.user_id.id, + "calendar_url": calendar.canonical_url, + } + ) + continue + if calendar.canonical_url not in self.nc_calendar_ids.mapped( + 'calendar_url') and not calendar.canonical_url == self.nc_calendar_id.calendar_url: + continue + events_fetched = calendar.search( + start=start_date, + event=True, + ) + for item in events_fetched: + event_vals = user.get_event_data(item) + result["nc_events"].append( + { + "nc_uid": event_vals["uid"], + "event_hash": event_vals["hash"], + "nc_event": event_vals["data"], + "nc_caldav": item, + } + ) + return result + except Exception as error: + if log_obj: + log_obj.log_event("error", error=error, message="Nextcloud:") + return result + else: + raise ValidationError(_(error)) + + def check_nc_event_organizer(self, caldav_event): + """ + Checks if nextcloud organizer is the same with odoo email + :param caldav_event: Caldav event data + :return Boolean + """ + if "organizer" in caldav_event.instance.vevent.contents: + organizer_email = caldav_event.instance.vevent.organizer.value.replace( + "mailto:", "" + ) + if organizer_email == self.nc_email: + return True + else: + return False + return True + + def get_nc_event_hash_by_uid(self, nc_uid): + """ + Check and get nextcloud event hash using UID + :param nc_uid: string, Nextcloud UID + :return Event hash + """ + nc_calendar_obj = self.env["nc.calendar"] + for user in self: + connection_dict = user.get_user_connection() + principal = connection_dict["principal"] + for calendar in principal.calendars(): + # Check if calendar exist for the user and make sure + # it has the same name as the Nextcloud calendar in case + # the user rename it in Nextcloud, otherwise create a new + # calendar if not exist + if "shared_by" in calendar.canonical_url: + continue + nc_calendar_id = nc_calendar_obj.search( + [ + ("user_id", "=", self.user_id.id), + ("calendar_url", "=", calendar.canonical_url), + ], + limit=1, + ) + if nc_calendar_id: + if calendar.name != nc_calendar_id.name: + nc_calendar_id.name = calendar.name + else: + return False + if calendar.canonical_url not in self.nc_calendar_ids.mapped( + 'calendar_url') and not calendar.canonical_url == self.nc_calendar_id.calendar_url: + continue + start_date = datetime.combine(self.start_date or date.today(), datetime.min.time()) + events_fetched = calendar.search( + start=start_date, + event=True, + ) + for item in events_fetched: + event_vals = user.get_event_data(item) + if event_vals["uid"] == nc_uid: + return event_vals["hash"] + return False + + def get_nc_event_hash_by_uid_for_other_user(self, nc_uid): + """ + Check and get nextcloud event hash using UID + :param nc_uid: string, Nextcloud UID + :return Event hash, Calendar + """ + nc_calendar_obj = self.env["nc.calendar"] + for user in self: + connection_dict = user.get_user_connection() + principal = connection_dict["principal"] + for calendar in principal.calendars(): + # Check if calendar exist for the user and make sure + # it has the same name as the Nextcloud calendar in case + # the user rename it in Nextcloud, otherwise create a new + # calendar if not exist + if "shared_by" in calendar.canonical_url: + continue + nc_calendar_id = nc_calendar_obj.search( + [ + ("user_id", "=", self.user_id.id), + ("calendar_url", "=", calendar.canonical_url), + ], + limit=1, + ) + if nc_calendar_id: + if calendar.name != nc_calendar_id.name: + nc_calendar_id.name = calendar.name + else: + return False, False + if calendar.canonical_url not in self.nc_calendar_ids.mapped( + 'calendar_url') and not calendar.canonical_url == self.nc_calendar_id.calendar_url: + continue + start_date = datetime.combine(self.start_date or date.today(), datetime.min.time()) + events_fetched = calendar.search( + start=start_date, + event=True, + ) + for item in events_fetched: + event_vals = user.get_event_data(item) + if event_vals["uid"] == nc_uid: + return event_vals["hash"], nc_calendar_id + return False, False diff --git a/nextcloud_odoo_sync/models/nextcloud_base.py b/nextcloud_odoo_sync/models/nextcloud_base.py new file mode 100644 index 0000000..3fcd4d5 --- /dev/null +++ b/nextcloud_odoo_sync/models/nextcloud_base.py @@ -0,0 +1,119 @@ +# Copyright (c) 2022 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import requests +from odoo import models +from odoo.http import request + + +class NextcloudBase(models.AbstractModel): + _name = "nextcloud.base" + _description = "NextCloud Base API" + + """ + These functions were derived from: + https://github.com/EnterpriseyIntranet/nextcloud-API + """ + + # def get_auth_data(self): + # """ + # Get and return nextcloud authentication data + # :return Dictionary: nextcloud authentication data + # """ + # config_obj = self.env["ir.config_parameter"] + # data = { + # "h_get": {"OCS-APIRequest": "true"}, + # "h_post": { + # "OCS-APIRequest": "true", + # "Content-Type": "application/x-www-form-urlencoded", + # }, + # "auth_pk": ( + # config_obj.sudo().get_param("nextcloud_odoo_sync.nextcloud_login"), + # config_obj.sudo().get_param("nextcloud_odoo_sync.nextcloud_password"), + # ), + # } + # return data + # + # def get_full_url(self, additional_url="", api_url=""): + # """ + # Build full url for request to NextCloud api + # Construct url from self.base_url, self.API_URL, + # additional_url (if given), + # add format=json param if self.json + # :param additional_url: str + # add to url after api_url + # :return: str + # """ + # if additional_url and not str(additional_url).startswith("/"): + # additional_url = "/{}".format(additional_url) + # config_obj = self.env["ir.config_parameter"] + # res = "{base_url}{api_url}{additional_url}".format( + # base_url=config_obj.sudo().get_param("nextcloud_odoo_sync.nextcloud_url"), + # api_url=api_url, + # additional_url=additional_url, + # ) + # res += "?format=json" + # return res + # + def rtn(self, resp): + """ + converts response a json format + :param resp: api response + :return json api response + """ + return resp.json() + # + # def get(self, url="", params=None): + # url = self.get_full_url(url, "/ocs/v1.php/cloud/users") + # data = self.get_auth_data() + # res = requests.get( + # url, auth=data["auth_pk"], headers=data["h_get"], params=params + # ) + # return self.rtn(res) + # + # def get_users(self, search=None, limit=None, offset=None): + # """ + # Retrieve a list of users from the Nextcloud server + # :param search: string, optional search string + # :param limit: int, optional limit value + # :param offset: int, optional offset value + # :return: List of users info + # """ + # params = {"search": search, "limit": limit, "offset": offset} + # result = self.get(params=params) + # if isinstance(result, dict): + # users = [] + # for uid in result["ocs"]["data"]["users"]: + # res = self.get(uid) + # users.append(res["ocs"]["data"]) + # result["ocs"]["data"]["users"] = users + # return result + + def get_user(self, uid, url, username, password): + """ + Retrieve information about a single user + :param uid: str, uid of user + :return: Dictionary of user info + """ + res = "{base_url}{api_url}{additional_url}".format( + base_url=url, + api_url="/ocs/v1.php/cloud/users/", + additional_url=uid, + ) + res += "?format=json" + data = { + "h_get": {"OCS-APIRequest": "true"}, + "h_post": { + "OCS-APIRequest": "true", + "Content-Type": "application/x-www-form-urlencoded", + }, + "auth_pk": ( + username, + password + ), + } + request = requests.get( + res, auth=data["auth_pk"], headers=data["h_get"] + ) + result = self.rtn(request) + return result["ocs"]["data"] diff --git a/nextcloud_odoo_sync/models/nextcloud_caldav.py b/nextcloud_odoo_sync/models/nextcloud_caldav.py new file mode 100644 index 0000000..64c5867 --- /dev/null +++ b/nextcloud_odoo_sync/models/nextcloud_caldav.py @@ -0,0 +1,2288 @@ +# Copyright (c) 2022 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +import logging +import requests +import pytz +import ast +from odoo.tools import html2plaintext +from dateutil.parser import parse +from datetime import datetime, timedelta, date as dtdate +from odoo import models, _ +from odoo.addons.nextcloud_odoo_sync.models import jicson +from odoo.exceptions import ValidationError + +_logger = logging.getLogger(__name__) + +try: + import caldav + from icalendar import Alarm +except (ImportError, IOError) as err: + _logger.debug(err) + + +class Nextcloudcaldav(models.AbstractModel): + _name = "nextcloud.caldav" + _description = "Caldav methods" + + def compare_events(self, od_events, nc_events, sync_user_id, log_obj): + """ + This method compares the Odoo and Nextcloud events and returns + the value to be created, modified or delete + :param od_events: list of dictionary of Odoo events + :param nc_events: list of dictionary of Nexcloud events + :param sync_user_id: Recordset of the current sync user (nc.sync.user) + :param log_obj: Recordset of the Sync Activity (nc.sync.log) + :param return: value to be created, modified or delete + for odoo and nextcloud (Tuple) + Case summary: + Nextcloud + - If event hash value changes from hash value recorded in + counterpart event in Odoo, update event in Odoo + - If event UID value does not exist in any Odoo events, add new + event in Odoo + - If existing UID in Odoo no longer exist in Nextcloud, delete + event in Odoo + Odoo + - If nc_synced value is False, a change happen in Odoo event, + update event in Nextcloud + - If to_delete value is True, the event needs to be deleted in + Nextcloud first, then delete in Odoo after sync + - If event in Odoo has no UID and hash, create the event in + Nextcloud (its a new event from Odoo) + Conflict + - If event hash value changes and Odoo event has nc_synced = False + means both had updated the event prior to the sync + * Need to check both event most recent modified date + to determine which is the most recent of the two which + will then override the other event + - If event is recurring and UID is shared between multiple calendar + event in Nextcloud, delete and recreate all recurring events + * We can only delete and recreate since there is no way we can + identify the single instance of recurring event in Nextcloud + because they share the same UID. Some instance of a recurring + event can have a change in date and time that is out of the + recurrence rule scope, so we can"t rely on the recurrence rule + to identify these events + """ + od_events_dict = {"create": [], "write": [], "delete": []} + nc_events_dict = {"create": [], "write": [], "delete": []} + nc_events_create = [] + all_odoo_events = self.env['calendar.event'].search([]) + recurrence_id_key = 'RECURRENCE-ID' + # Compare Odoo events to sync + if od_events and nc_events: + # Odoo -> Nextcloud + try: + nc_event_status_confirmed_id = self.env.ref( + "nextcloud_odoo_sync.nc_event_status_confirmed" + ) + except BaseException: + raise ValidationError( + _( + "Missing value for Confirmed status." + "Consider upgrading the nextcloud_odoo_sync" + "module and try again" + ) + ) + for odoo_event in od_events: + ode = odoo_event.copy() + # Case 1: Event created in Odoo and not yet synced to Nextcloud + # (nc_synced=False, nc_uid=False) + od_event = ode["od_event"] + if not ode["nc_uid"] and not od_event.nc_synced: + if not od_event.nc_status_id: + od_event.nc_status_id = nc_event_status_confirmed_id.id + if ( + od_event.nc_status_id + and od_event.nc_status_id.name.lower() != "canceled" + ): + duplicate = self.check_duplicate(nc_events, ode) + if not duplicate: + if od_event.recurrence_id: + base_event = od_event.recurrence_id.base_event_id + if not base_event.nc_uid and base_event not in nc_events_create: + base_event_vals = { + "nc_uid": base_event.nc_uid, + "od_event": base_event, + "event_hash": False, + } + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + nc_events_dict["create"].append(base_event_vals) + nc_events_create.append(base_event) + continue + else: + if od_event not in nc_events_create: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + nc_events_dict["create"].append(ode) + nc_events_create.append(od_event) + continue + else: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + od_event.nc_uid = duplicate["nc_uid"] + ode["nc_uid"] = duplicate["nc_uid"] + duplicate["od_event"] = od_event + od_events_dict["write"].append(duplicate) + continue + if ode["nc_uid"] and ode["event_hash"]: + valid_nc_uid = False + for nextcloud_event in nc_events: + if valid_nc_uid: + break + nce = nextcloud_event.copy() + if ode["nc_uid"] == nce["nc_uid"]: + valid_nc_uid = True + # If a matching event was found then save the + # caldav event in od_events_dict and save the odoo + # event in nc_events_dict + ode["nc_caldav"] = nce["nc_caldav"] + nce["od_event"] = od_event + # Case 2: If both hash values are the same + if ode["event_hash"] == nce["event_hash"]: + # Case 2.a: If Odoo event has no changes to + # sync (nc_synced=True) then no change to + # update to Nextcloud + if od_event.nc_synced: + vevent = ode["nc_caldav"].vobject_instance.vevent + if not od_event.nextcloud_calendar_type: + if ( + "status" not in vevent.contents + or vevent.status.value.lower() == "cancelled" + ): + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if nce not in od_events_dict["delete"]: + od_events_dict["delete"].append(nce) + if ( + od_event.nc_to_delete + ): + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in nc_events_dict["delete"]: + nc_events_dict["delete"].append(ode) + else: + if not od_event.nextcloud_calendar_type: + if ( + od_event.nc_status_id + and od_event.nc_status_id.name.lower() + != "canceled" + ): + # Case 2.b: If there are changes to + # sync (nc_synced=False) and to delete + # (nc_to_delete=True), delete Nextcloud + # event + if ( + od_event.nc_to_delete + ): + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in nc_events_dict["delete"]: + nc_events_dict["delete"].append(ode) + # Case 2.c: If there are changes to + # sync (nc_sycned=False) but not to + # delete (nc_to_delete=False), update + # Nextcloud event + else: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if not od_event.recurrence_id: + if "LAST-MODIFIED" in nce["nc_event"][0]: + # The "Z" stands for Zulu time + # (zero hours ahead of GMT) which + # is another name for UTC + nc_last_modified = datetime.strptime( + nce["nc_event"][0]["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + if od_event.nc_rid and "exdates" in nce["nc_event"][ + 0] and od_event.nc_rid in nce["nc_event"][0][ + 'exdates']: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + else: + od_events_dict["write"].append(nce) + else: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + if od_event.nc_rid: + od_event_nc_rid = od_event.nc_rid + nc_modified = False + for nce_events_dict in nce["nc_event"]: + matching_values = [ + value for key, value in nce_events_dict.items() + if recurrence_id_key in key + ] + if matching_values: + if od_event_nc_rid == matching_values[ + 0] and "LAST-MODIFIED" in nce_events_dict: + nc_last_modified = datetime.strptime( + nce_events_dict["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + recurring_nce = nce.copy() + recurring_nce.update( + {'nc_event': [nce_events_dict], + 'detach': True}) + od_events_dict["write"].append( + recurring_nce) + nc_modified = True + break + if not nc_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if od_event == od_event.recurrence_id.base_event_id: + if "LAST-MODIFIED" in nce["nc_event"][0]: + # The "Z" stands for Zulu time + # (zero hours ahead of GMT) which + # is another name for UTC + nc_last_modified = datetime.strptime( + nce["nc_event"][0]["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + if nc_last_modified > od_event.recurrence_id.write_date: + if nce not in od_events_dict["write"]: + if od_event.nc_rid and "exdates" in \ + nce["nc_event"][ + 0] and od_event.nc_rid in \ + nce["nc_event"][0]['exdates']: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + else: + od_events_dict["write"].append(nce) + else: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + if od_event.nc_rid: + od_event_nc_rid = od_event.nc_rid + nc_modified = False + for nce_events_dict in nce["nc_event"]: + matching_values = [ + value for key, value in nce_events_dict.items() + if recurrence_id_key in key + ] + if matching_values: + if od_event_nc_rid == matching_values[ + 0] and "LAST-MODIFIED" in nce_events_dict: + nc_last_modified = datetime.strptime( + nce_events_dict["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + nc_modified = True + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + recurring_nce = nce.copy() + recurring_nce.update( + {'nc_event': [nce_events_dict],'detach':True}) + od_events_dict["write"].append( + recurring_nce) + break + if not nc_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in nc_events_dict["delete"]: + nc_events_dict["delete"].append(ode) + else: + if ( + od_event.nc_to_delete + ): + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in nc_events_dict["delete"]: + nc_events_dict["delete"].append(ode) + # Case 3: If both hash differs + else: + if od_event.nextcloud_calendar_type: + if ( + od_event.nc_to_delete + ): + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in nc_events_dict["delete"]: + nc_events_dict["delete"].append(ode) + elif nce not in od_events_dict["write"]: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + od_events_dict["write"].append(nce) + continue + # Case 3.a: If Odoo event has no change + # (nc_synced=True) update based on Nextcloud + if od_event.nc_synced: + # delete if cancelled + vevent = ode["nc_caldav"].vobject_instance.vevent + if ( + "status" not in vevent.contents + or vevent.status.value.lower() == "cancelled" + ): + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if nce not in od_events_dict["delete"]: + od_events_dict["delete"].append(nce) + else: + if nce not in od_events_dict["write"]: + # in nextcloud an attendee can + # only modify an event for itself + # and will not reflect on organizer + # hence we retrict modification to + # odoo event by the attendee as + # well + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if not od_event.recurrence_id: + if "LAST-MODIFIED" in nce["nc_event"][0]: + # The "Z" stands for Zulu time + # (zero hours ahead of GMT) which + # is another name for UTC + nc_last_modified = datetime.strptime( + nce["nc_event"][0]["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + if od_event.nc_rid and "exdates" in nce["nc_event"][0] and od_event.nc_rid in nce["nc_event"][0]['exdates']: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + else: + od_events_dict["write"].append(nce) + else: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + if od_event.nc_rid: + od_event_nc_rid = od_event.nc_rid + nc_modified = False + for nce_events_dict in nce["nc_event"]: + matching_values = [ + value for key, value in nce_events_dict.items() + if recurrence_id_key in key + ] + if matching_values: + if od_event_nc_rid == matching_values[ + 0] and "LAST-MODIFIED" in nce_events_dict: + nc_last_modified = datetime.strptime( + nce_events_dict["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + recurring_nce = nce.copy() + recurring_nce.update( + {'nc_event': [nce_events_dict],'detach':True}) + od_events_dict["write"].append( + recurring_nce) + nc_modified = True + break + if not nc_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if od_event == od_event.recurrence_id.base_event_id: + if "LAST-MODIFIED" in nce["nc_event"][0]: + # The "Z" stands for Zulu time + # (zero hours ahead of GMT) which + # is another name for UTC + nc_last_modified = datetime.strptime( + nce["nc_event"][0]["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + if nc_last_modified > od_event.recurrence_id.write_date: + if nce not in od_events_dict["write"]: + if od_event.nc_rid and "exdates" in nce["nc_event"][ + 0] and od_event.nc_rid in nce["nc_event"][0][ + 'exdates']: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + else: + od_events_dict["write"].append(nce) + else: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + if od_event.nc_rid: + od_event_nc_rid = od_event.nc_rid + nc_modified = False + for nce_events_dict in nce["nc_event"]: + matching_values = [ + value for key, value in nce_events_dict.items() + if recurrence_id_key in key + ] + if matching_values: + if od_event_nc_rid == matching_values[ + 0] and "LAST-MODIFIED" in nce_events_dict: + nc_last_modified = datetime.strptime( + nce_events_dict["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + recurring_nce = nce.copy() + recurring_nce.update( + {'nc_event': [nce_events_dict],'detach':True}) + od_events_dict["write"].append( + recurring_nce) + nc_modified = True + break + if not nc_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + # revert the event of attendee + # in nextcloud to event of + # attendee in odoo + # nc_events_dict["write"].append( + # ode) + + # Since is not possible to + # modify the event by the + # attendee in nextcloud + log_obj.log_event( + message="A Nextcloud event" + " has been modified by one" + " of its attendee in Nextcloud" + " but does not get" + " reflected in the organizer" + " event. This changes will be" + " ignored in Odoo. Event details:" + "\n%s" % nce["nc_event"][0] + ) + else: + # Case 3.b: If Odoo has changes + # (nc_synced=False) and to delete + # (nc_to_delete=True), delete Nextcloud + # event + if not od_event.nc_synced and od_event.nc_to_delete: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in nc_events_dict["delete"]: + nc_events_dict["delete"].append(ode) + # Case 3.c: If Odoo has changes + # (nc_synced=False) and not to delete + # (nc_to_delete=False) + else: + # Check LAST-MODIFIED date value in + # Nextcloud event + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if not od_event.recurrence_id: + if "LAST-MODIFIED" in nce["nc_event"][0]: + # The "Z" stands for Zulu time + # (zero hours ahead of GMT) which + # is another name for UTC + nc_last_modified = datetime.strptime( + nce["nc_event"][0]["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + if od_event.nc_rid and "exdates" in nce["nc_event"][ + 0] and od_event.nc_rid in nce["nc_event"][0]['exdates']: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + else: + od_events_dict["write"].append(nce) + else: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + if od_event.nc_rid: + od_event_nc_rid = od_event.nc_rid + nc_modified = False + for nce_events_dict in nce["nc_event"]: + matching_values = [ + value for key, value in nce_events_dict.items() + if recurrence_id_key in key + ] + if matching_values: + if od_event_nc_rid == matching_values[ + 0] and "LAST-MODIFIED" in nce_events_dict: + nc_last_modified = datetime.strptime( + nce_events_dict["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + recurring_nce = nce.copy() + recurring_nce.update( + {'nc_event': [nce_events_dict], + 'detach': True}) + od_events_dict["write"].append( + recurring_nce) + nc_modified = True + break + if not nc_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if od_event == od_event.recurrence_id.base_event_id: + if "LAST-MODIFIED" in nce["nc_event"][0]: + # The "Z" stands for Zulu time + # (zero hours ahead of GMT) which + # is another name for UTC + nc_last_modified = datetime.strptime( + nce["nc_event"][0]["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + if nc_last_modified > od_event.recurrence_id.write_date: + if nce not in od_events_dict["write"]: + if od_event.nc_rid and "exdates" in nce["nc_event"][ + 0] and od_event.nc_rid in nce["nc_event"][0][ + 'exdates']: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + else: + od_events_dict["write"].append(nce) + else: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + if od_event.nc_rid: + od_event_nc_rid = od_event.nc_rid + nc_modified = False + for nce_events_dict in nce["nc_event"]: + matching_values = [ + value for key, value in nce_events_dict.items() + if recurrence_id_key in key + ] + if matching_values: + if od_event_nc_rid == matching_values[ + 0] and "LAST-MODIFIED" in nce_events_dict: + nc_last_modified = datetime.strptime( + nce_events_dict["LAST-MODIFIED"], + "%Y%m%dT%H%M%SZ", + ) + od_last_modified = od_event.write_date + if od_last_modified > nc_last_modified: + if ode not in nc_events_dict["write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + else: + if nce not in od_events_dict["write"]: + recurring_nce = nce.copy() + recurring_nce.update( + {'nc_event': [nce_events_dict],'detach':True}) + od_events_dict["write"].append(recurring_nce) + nc_modified=True + break + if not nc_modified: + if ode not in nc_events_dict[ + "write"] and not od_event.nc_synced: + nc_events_dict["write"].append(ode) + # Case 4: If the value of Odoo nc_uid is not found in all + # of Nextcloud events, then it was deleted in Nextcloud + if not valid_nc_uid: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if ode not in od_events_dict["delete"]: + od_events_dict["delete"].append(ode) + # Nextcloud -> Odoo + for nce in nc_events: + vevent = nce["nc_caldav"].vobject_instance.vevent + # ignore if cancelled + if ( + "status" not in vevent.contents + or vevent.status.value.lower() != "cancelled" + ): + valid_nc_uid = all_odoo_events.filtered(lambda ev: ev.nc_uid == nce["nc_uid"]) + # for ode in od_events: + # if nce["nc_uid"] == ode["nc_uid"]: + # valid_nc_uid = True + # break + # Case 5: Nextcloud nc_uid is not found in Odoo + if not valid_nc_uid: + od_events_dict["create"].append(nce) + + # Case 6: If there is not a single event in Odoo, we create everything + # from Nextcloud -> Odoo + if not od_events and nc_events: + for nce in nc_events: + vevent = nce["nc_caldav"].vobject_instance.vevent + # ignore if cancelled + if ( + "status" not in vevent.contents + or vevent.status.value.lower() != "cancelled" + ): + valid_nc_uid = all_odoo_events.filtered(lambda ev: ev.nc_uid == nce["nc_uid"]) + if not valid_nc_uid: + od_events_dict["create"].append(nce) + # Case 7: If there is not a single event in Nextcloud, check if Odoo + # event has nc_uid value or not + if od_events and not nc_events: + for ode in od_events: + # ignore if cancelled + od_event = ode["od_event"] + if ( + od_event.nc_status_id + and od_event.nc_status_id.name.lower() != "canceled" + ): + # Case 7.a: If the event has an existing nc_uid value, then + # its a previous event in Nextcloud that might have been + # deleted + if od_event.nc_uid: + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + od_events_dict["delete"].append(ode) + else: + # Case 7.b: If the event has no nc_uid value then its a + # new event in Odoo to be created in Nextcloud + if od_event.user_id and sync_user_id.user_id == od_event.user_id: + if od_event.recurrence_id: + base_event = od_event.recurrence_id.base_event_id + if not base_event.nc_uid and base_event not in nc_events_create: + base_event_vals = { + "nc_uid": base_event.nc_uid, + "od_event": base_event, + "event_hash": False, + } + nc_events_dict["create"].append(base_event_vals) + nc_events_create.append(base_event) + else: + if od_event not in nc_events_create: + nc_events_dict["create"].append(ode) + nc_events_create.append(od_event) + + return od_events_dict, nc_events_dict + + def check_duplicate(self, nc_events, ode): + """ + Check and returns one record on a dulicated event + :param nc_events: List of caldav nextcloud event + :param ode: Odoo event data in a dictionary + :return caldav nextcloud event in a dictionary + """ + result = {} + fields = {"name": "SUMMARY", "start": "DTSTART", "stop": "DTEND"} + d = 0 + date_fields = ['dtstart', 'dtend', 'rrule', 'recurrence-id', 'last-modified'] + for f in fields: + for nce in nc_events: + for nc_event in nce["nc_event"]: + for key in nc_event: + field = fields[f] + if field == key or field in key: + value = nc_event[key] + key_field = key.lower().split(";") + data = value + if key_field[0] in date_fields: + data = self.get_event_datetime( + key_field, value, nc_event, ode["od_event"], nce["nc_caldav"] + ) + + allday = ode["od_event"].allday + if isinstance(data, datetime) or isinstance(data, dtdate): + if (data == ode["od_event"][f] and not allday) or ( + data == ode["od_event"][f].date() and allday + ): + d += 1 + elif data == ode["od_event"][f]: + d += 1 + if d >= len(fields.keys()): + return nce + return result + + def get_event_attendees(self, calendar_event, user_id, **params): + """ + This method check if the attendee is a user of Odoo or a user of + Nextcloud or an external contact + :param calendar_event: caldav Event object or Odoo calendar.event recordset + :param user_id: single recordset of res.users model + :param **params: dictionary of keyword arguments + :return list of res.partner model record ids + """ + all_user_ids = params.get("all_user_ids", False) + all_sync_user_ids = params.get("all_sync_user_ids", False) + all_partner_ids = params.get("all_partner_ids", False) + attendee_partner_ids = [] + nc_sync_user_obj = self.env["nc.sync.user"] + res_partner_obj = self.env["res.partner"] + res_users_obj = self.env["res.users"] + org_user_id = [] + # Get attendees for Odoo event + if isinstance(calendar_event, caldav.objects.Event): + try: + attendees = [ + value.value + for value in calendar_event.vobject_instance.vevent.contents.get( + "attendee", [] + ) + if value + ] + except Exception: + attendees = [] + try: + organizer = calendar_event.instance.vevent.organizer.value + attendees.append(organizer) + except Exception: + organizer = '' + for att in attendees: + email = att.split(":")[-1].lower() + if email != "false": + # Check if an Odoo user has the same email address + att_user_id = nc_sync_user_obj.search( + [("nc_email", "=", email), ("sync_calendar", "=", True)], limit=1 + ).user_id + if not att_user_id: + att_user_id = all_user_ids.filtered( + lambda x: x.partner_id.email + and x.partner_id.email.lower() == email + ) + # In case more than 1 user has the same email address, + # check which user is in nc.sync.user model + if att_user_id and len(att_user_id.ids) > 1: + sync_user_id = all_sync_user_ids.filtered( + lambda x: x.user_id.id in att_user_id.ids + ) + if sync_user_id: + attendee_partner_ids.append( + nc_sync_user_obj.browse( + sync_user_id.ids[0] + ).user_id.partner_id.id + ) + else: + attendee_partner_ids.append( + res_users_obj.browse(user_id.ids[0]).partner_id.id + ) + else: + if att_user_id: + if not att_user_id.partner_id.email: + att_user_id.partner_id.email = email + attendee_partner_ids.append(att_user_id.partner_id.id) + else: + contact_id = res_partner_obj.search([('email', '=', email)], limit=1) + if not contact_id: + contact_id = res_partner_obj.create( + {"name": email, "email": email, "nc_sync": True} + ) + all_partner_ids |= contact_id + attendee_partner_ids.append(contact_id.id) + if organizer: + organizer_email = organizer.replace( + "mailto:", "" + ) + org_user_id = nc_sync_user_obj.search( + [("nc_email", "=", organizer_email), ("sync_calendar", "=", True)], limit=1 + ).user_id + if not org_user_id: + org_user_id = all_user_ids.filtered( + lambda x: x.partner_id.email + and x.partner_id.email.lower() == email + ) + if not attendees: + attendee_partner_ids = [user_id.partner_id.id] + org_user_id = user_id + # Get attendees for Nextcloud event + elif ( + isinstance(calendar_event, models.Model) + and calendar_event.partner_ids + and all_sync_user_ids + ): + nc_user_ids = self.env["nc.sync.user"] + for partner in calendar_event.partner_ids: + # In Nextcloud, we don"t populate the attendee if there is only + # the organizer involve + if ( + partner != user_id.partner_id + and len(calendar_event.partner_ids) > 1 + ): + nc_user_id = all_sync_user_ids.filtered( + lambda x: x.partner_id.id == partner.id and x.sync_calendar + ) + try: + nc_user_ids |= nc_user_id + connection_dict = nc_user_id.get_user_connection() + nc_user_principal = connection_dict["principal"] + except Exception: + nc_user_principal = False + if nc_user_id and nc_user_principal: + attendee_partner_ids.append( + nc_user_principal.get_vcal_address() + ) + else: + # Get only partner_ids with email address + if partner.email: + attendee_partner_ids.append(f"mailto:{partner.email}") + params["nc_user_ids"] = nc_user_ids + return list(set(attendee_partner_ids)), org_user_id, params + + def get_event_datetime( + self, nc_field, nc_value, vals, od_event=False, nc_event=False + ): + """ + This method will parse the Nextcloud event date, + convert it to datetime in UTC timezone + :param: nc_field: Nextcloud ical data field name + :param: nc_value: Nextcloud ical data field value + :param: vals: Dictionary of event values + :param: od_event: single recordset of calendar.event model + :return: date, datetime or string + """ + try: + recurrence = [x for x in list(vals) if "RECURRENCE-ID" in x] + for key in ["LAST-MODIFIED", "DTSTART", "DTEND", "CREATED", "EXDATE"]: + if recurrence and key in ["DTSTART", "DTEND"]: + # Cannot get the calendar event for single recurring instance + # hence we revent to string manipulation of date + event_date = nc_event.icalendar_component.get(key).dt + tz = 'UTC' + if isinstance(event_date, datetime): + tz = event_date.tzinfo.zone + date = parse(nc_value) + if od_event and od_event.nextcloud_event_timezone and od_event.nextcloud_event_timezone == tz: + dt_tz = pytz.timezone(tz).localize(date, is_dst=None) + date = dt_tz.astimezone(pytz.utc) + else: + if tz != "UTC": + date = date.astimezone(pytz.utc) + value = nc_field[-1].split("=")[-1] + if value == 'date': + if nc_field[0].upper() == "DTEND": + date = date - timedelta(days=1) + return date.date() + return date.replace(tzinfo=None) + elif key in nc_field[0].upper(): + if "EXDATE" in key: + exdate_val = nc_event.icalendar_component.get(key) + date = ( + [exdate_val] + if not isinstance(exdate_val, list) + else exdate_val + ) + else: + date = nc_event.icalendar_component.get(key).dt + if isinstance(date, datetime): + tz = date.tzinfo.zone + if tz != "UTC": + date = date.astimezone(pytz.utc) + elif isinstance(date, list): + data = [] + all_day = False + if od_event and od_event.allday: + all_day = True + for exdate in date: + for item in exdate.dts: + date_data = item.dt + tz = date_data.tzinfo.zone + if tz != "UTC": + date_data = date_data.astimezone(pytz.utc) + if all_day and isinstance(date_data, datetime): + date_data = date_data.date() + data.append(date_data.replace(tzinfo=None)) + return data + else: + if key == "DTEND": + date = date - timedelta(days=1) + value = nc_field[-1].split("=")[-1] + if value == 'date' and isinstance(date, datetime): + date = date.date() + if isinstance(date, datetime): + return date.replace(tzinfo=None) + else: + return date + return nc_value + except Exception as e: + return nc_value + + def get_recurrence_id_date(self, nc_field, nc_value, od_event_id): + """ + This method will parse the recurrence ID, convert to UTC + :param: nc_field: Nextcloud ical data field name + :param: nc_value: Nextcloud ical data field value + :param: od_event_id: single recordset of calendar.event model + :return: date or datetime + """ + tz = nc_field[-1].split("=")[-1] + if "Z" in nc_value: + nc_value = nc_value.replace("Z", "") + date_value = parse(nc_value) + if od_event_id and od_event_id.allday and isinstance(date_value, datetime): + return date_value.date() + # else: + # data = self.convert_date(date_value, tz, "utc") + + return date_value + + def manage_recurring_instance(self, event_dict, operation, vals): + """ + This method manages the changes for a single instance + of a recurring event + :param event_dict: dictionary, Odoo and Nextcloud events + :param operation: string, indicate create, write or delete operation + :param vals: Dictionary of event values + :return single recordset of calendar.event model, + string, dictionary of values + """ + exdates = [] + vevent = False + caldav_event = event_dict.get("nc_caldav", False) + if caldav_event: + vevent = caldav_event.vobject_instance.vevent + event_id = event_dict.get("od_event", False) + date_format = "%Y%m%dT%H%M%S" + if event_id.allday: + date_format = "%Y%m%d" + if caldav_event: + prev_exdates = vevent.contents.pop("exdate", False) + if prev_exdates: + exdates = prev_exdates[0].value + for index, item in enumerate(exdates): + if not isinstance(item, datetime): + exdates[index] = datetime.combine(item, datetime.min.time()) + if event_id.recurrence_id.nc_exdate: + od_exdates = [ + parse(x) for x in ast.literal_eval(event_id.recurrence_id.nc_exdate) + ] + [exdates.append(d) for d in od_exdates if d not in exdates] + # Handle create and delete operation + recurring_event_ids = event_id.recurrence_id.calendar_event_ids + if exdates and operation == "create": + # Check for detached events in Odoo + detach_ids = recurring_event_ids.filtered(lambda x: x.nc_detach and x.nc_rid) + if detach_ids: + detach_exdates = [parse(x.nc_rid) for x in detach_ids] + [exdates.append(d) for d in detach_exdates if d not in exdates] + vals["exdate"] = exdates + if operation == "delete": + # Check if all instance of recurring events are for deletion + to_delete_ids = recurring_event_ids.filtered(lambda x: x.nc_to_delete and x.nc_rid) + if not to_delete_ids or len(to_delete_ids.ids) == len( + event_id.recurrence_id.calendar_event_ids.ids + ): + return event_id, operation, vals + else: + exdates.extend([parse(x.nc_rid) for x in to_delete_ids if x.nc_rid not in exdates]) + + # Handle write operation by marking the existing caldav_event with exdate + # then create a new caldav_event that is detached from recurring rule + if operation == "write" and event_id.nc_detach: + [vals.pop(x, False) for x in ["rrule", "uid", "exdate"] if x in vals] + exdate = parse(event_id.nc_rid) if event_id.nc_rid else False + if exdate and exdate not in exdates: + exdates.append(exdate) + event_id.recurrence_id.nc_exdate = [ + x.strftime(date_format) for x in exdates + ] + operation = "create" + if operation == "write" and not event_id.nc_detach and event_id.recurrence_id.base_event_id == event_id: + operation = "create" + # Set the exdates value in the caldav_event + if exdates and caldav_event: + for index, value in enumerate(exdates): + if isinstance(value,datetime): + if not value.tzinfo: + dt_tz = pytz.timezone(event_id.nextcloud_event_timezone or event_id.event_tz or 'UTC').localize(value, is_dst=None) + else: + dt_tz = value + exdates[index] = dt_tz.date() if event_id.allday else dt_tz + exdates = list(set(exdates)) + parameters = {"VALUE": "DATE" if event_id.allday else "DATE-TIME"} + if not event_id.allday: + parameters.update({"TZID": event_id.nextcloud_event_timezone or event_id.event_tz or 'UTC'}) + caldav_event.icalendar_component.add("exdate", exdates,parameters=parameters) + caldav_event.save() + return event_id, operation, vals + + def get_rrule_dict(self, rrule): + """ + This method converts the rrule string into a dictionary of values + :param rrule: Recurring rule (string) + :return rrule dictionary + """ + rrule = rrule.split(":")[-1] + result = {} + for rec in rrule.split(";"): + k, v = rec.split("=") + try: + v = int(v) + except BaseException: + pass + result.update({k: v}) + return result + + def update_event_hash(self, hash_vals, event_ids=False): + """ + This method updates the hash value of the event + :param hash_vals: dict, dictionary of sync user and event hash values + :param event_ids: single/multiple recordset of calendar.event model + """ + # Update the hash value of the Odoo event that corresponds to the + # current user_id + calendar_event_nc_hash_obj = self.env["calendar.event.nchash"] + if event_ids: + event_ids.nc_synced = True + for event in event_ids: + hash_id = event.nc_hash_ids.filtered( + lambda x: x.nc_sync_user_id.id == hash_vals["nc_sync_user_id"] + ) + if hash_id: + hash_id.nc_event_hash = hash_vals["nc_event_hash"] + else: + # Create the hash value for the event if not exist + hash_vals["calendar_event_id"] = event.id + calendar_event_nc_hash_obj.create(hash_vals) + + elif not event_ids and "principal" in hash_vals: + events_hash = hash_vals["events"] + principal = hash_vals["principal"] + sync_user_id = self.env['nc.sync.user'].browse(hash_vals["nc_sync_user_id"]) + calendars = principal.calendars() + all_user_events = [] + for calendar in calendars: + if calendar.canonical_url not in sync_user_id.nc_calendar_ids.mapped( + 'calendar_url') and not calendar.canonical_url == sync_user_id.nc_calendar_id.calendar_url: + continue + start_date = datetime.combine(sync_user_id.start_date or dtdate.today(), datetime.min.time()) + events = calendar.search( + start=start_date, + event=True, + ) + if events: + all_user_events.extend(events) + for event in all_user_events: + nc_uid = event.vobject_instance.vevent.uid.value + if nc_uid in events_hash: + event_vals = sync_user_id.get_event_data(event) + new_vals = { + "nc_sync_user_id": sync_user_id.id, + "nc_event_hash": event_vals["hash"], + } + event_id = events_hash[nc_uid] + if event_id.recurrence_id: + event_id = event_id.recurrence_id.calendar_event_ids + event_id.nc_uid = nc_uid + self.update_event_hash(new_vals, event_id) + + def update_attendee_invite(self, event_ids): + """ + This method accepts the invitation for the meeting organizer + as part of attendee which is Odoo default behavior + :param event_ids: single/multiple recordset of calendar.event model + """ + for event in event_ids: + attendee_id = event.attendee_ids.filtered( + lambda x: x.partner_id == event.user_id.partner_id + ) + if attendee_id: + attendee_id.state = "accepted" + + def delete_exempted_event(self, event_id, exdates, all_odoo_event_ids): + """ + This method deletes an instance of a recurring event that was + already deleted in Nextcloud + :param event_id: single recordset of calendar.event model + :param exdates: dictionary of event UID and EXDATE value from Nextcloud + :param all_odoo_event_ids: multiple recordset of calendar.event model + :return recordset: returns a new list of all_odoo_event_ids + where delete events were removed + """ + if exdates and event_id.nc_uid in exdates and event_id.recurrence_id: + recurring_event_ids = event_id.recurrence_id.calendar_event_ids + ex_recurring_event_ids = recurring_event_ids.filtered( + lambda x: x.nc_rid in exdates[event_id.nc_uid] + ) + if ex_recurring_event_ids: + all_odoo_event_ids = all_odoo_event_ids.filtered( + lambda x: x not in ex_recurring_event_ids + ) + ex_recurring_event_ids.sudo().with_context(force_delete=True).unlink() + return all_odoo_event_ids + + def update_odoo_events(self, sync_user_id, od_events_dict, **params): + """ + This method updates the Odoo calendar.event records from Caldav events + :param sync_user_id: single recordset of nc.sync.user model + :param od_events_dict: dictionary of create, write and + delete operations for Odoo + :param **params: dictionary of keyword arguments containing multiple + recordset of models + """ + all_odoo_event_ids = params.get("all_odoo_event_ids", False) + all_nc_calendar_ids = params.get("all_nc_calendar_ids", False) + all_odoo_event_type_ids = params.get("all_odoo_event_type_ids", False) + status_vals = params.get("status_vals", False) + calendar_event = self.env["calendar.event"].sudo() + calendar_recurrence_obj = self.env["calendar.recurrence"].sudo() + field_mapping = self.get_caldav_fields() + date_fields = ['dtstart', 'dtend', 'rrule', 'recurrence-id', 'last-modified'] + log_obj = params["log_obj"] + user_id = sync_user_id.user_id + user_name = sync_user_id.user_id.name + nc_event_status_confirmed_id = self.env.ref( + "nextcloud_odoo_sync.nc_event_status_confirmed" + ) + for operation in od_events_dict: + for event in od_events_dict[operation]: + od_event_id = event["od_event"] if "od_event" in event else False + new_event_id = False + detach = event.get('detach',False) + if od_event_id and not od_event_id.exists(): + continue + # Perform delete operation + try: + if operation == "delete" and "od_event" in event and event["od_event"]: + all_odoo_event_ids = all_odoo_event_ids - event["od_event"] + event["od_event"].sudo().with_context(force_delete=True).unlink() + params["delete_count"] += len(event["od_event"]) + except Exception as e: + message = ( + "Error deleting Odoo event '%s' for user '%s':\n" + % (event["od_event"], user_name) + ) + log_obj.log_event( + mode="error", + error=e, + message=message, + operation_type="delete", + ) + params["error_count"] += 1 + continue + nc_event = event["nc_event"] if "nc_event" in event else False + caldav_event = event["nc_caldav"] if "nc_caldav" in event else False + event_hash = event["event_hash"] + exdates = {} + if nc_event: + for vevent in nc_event: + if od_event_id and not od_event_id.exists(): + continue + if operation == 'create' and new_event_id: + od_event_id = new_event_id + vals = {"nc_uid": event["nc_uid"]} + nc_uid = event["nc_uid"] + all_day = False + # Loop through each fields from the Nextcloud event and + # map it to Odoo fields + for e in vevent: + field = e.lower().split(";") + if field[0] in field_mapping or field[0] == "exdates": + data = vevent[e] + if field[0] in date_fields: + data = self.get_event_datetime( + field, vevent[e], vevent, od_event_id, caldav_event + ) + if field[0] == "dtstart" and not isinstance( + data, datetime + ): + all_day = True + if field[0] == "transp": + if vevent[e].lower() == "opaque": + data = "busy" + elif vevent[e].lower() == "transparent": + data = "free" + elif field[0] == "status" and status_vals: + data = status_vals[vevent[e].lower()] + elif field[0] == "valarm": + data = self.get_odoo_alarms(vevent.get(e, [])) + elif field[0] == "categories": + ( + data, + params["all_odoo_event_type_ids"], + ) = self.get_odoo_categories( + all_odoo_event_type_ids, + vevent[e], + ) + elif field[0] == "rrule": + vals["recurrency"] = True + vals["rrule"] = data + data = False + elif field[0] == "exdates" and data: + if nc_uid not in exdates: + exdates[nc_uid] = [] + for item in data: + if isinstance(item, datetime): + item = item.strftime("%Y%m%dT%H%M%S") + elif isinstance(item, dtdate): + item = item.strftime("%Y%m%d") + exdates[nc_uid].append(item) + data = False + elif field[0] == "recurrence-id" and data: + try: + data = self.get_recurrence_id_date( + field, vevent[e], od_event_id + ) + except: + pass + # Convert it back to string for matching + # with nc_rid field of calendar.event model + if isinstance(data, datetime): + data = data.strftime("%Y%m%dT%H%M%S") + elif isinstance(data, dtdate): + data = data.strftime("%Y%m%d") + if data: + vals[field_mapping[field[0]]] = data + if caldav_event.icalendar_component.get('DTSTART'): + event_start_date = caldav_event.icalendar_component.get('DTSTART').dt + tz = False + if isinstance(event_start_date, datetime): + tz = event_start_date.tzinfo.zone + if tz: + vals['nextcloud_event_timezone'] = tz + if caldav_event.icalendar_component.get('X-NEXTCLOUD-BC-FIELD-TYPE'): + vals['nextcloud_calendar_type'] = caldav_event.icalendar_component.get('X-NEXTCLOUD-BC-FIELD-TYPE') + if all_day: + vals["start_date"] = vals.pop("start") + vals["stop_date"] = vals.pop("stop") + if detach: + vals['recurrence_id'] = False + vals['recurrency'] = False + # Populate the nc_calendar_ids field in Odoo + nc_calendar_id = all_nc_calendar_ids.filtered( + lambda x: x.calendar_url + == caldav_event.parent.canonical_url + and x.user_id == user_id + ) + if all_odoo_event_ids: + event_nc_calendar_ids = all_odoo_event_ids.filtered( + lambda x: x.nc_uid == vals["nc_uid"] + ).mapped("nc_calendar_ids") + user_nc_calendar_ids = all_nc_calendar_ids.filtered( + lambda x: x.user_id == user_id + ) + new_nc_calendar_ids = list( + set(event_nc_calendar_ids.ids) + - set(user_nc_calendar_ids.ids) + ) + else: + new_nc_calendar_ids = [] + if nc_calendar_id: + new_nc_calendar_ids.append(nc_calendar_id.id) + if vals.get('rrule',False): + vals['nextcloud_rrule'] = vals.get('rrule') + # clear categ_ids when not present + if "categ_ids" not in vals: + vals["categ_ids"] = [(6, 0, [])] + # clear alarm_ids when not present + if "alarm_ids" not in vals: + vals["alarm_ids"] = [(6, 0, [])] + # Set the status + if "nc_status_id" not in vals: + vals["nc_status_id"] = nc_event_status_confirmed_id.id + # Populate attendees and rest of remaining fields + event_name = vals.get("name", "Untitled event") + vals.pop("write_date", False) + ( + attendee_partner_ids, organizer, + params + ) = self.get_event_attendees(caldav_event, user_id, **params) + organizer_user_id = organizer[0].id if organizer else False + hash_vals_list = [{ + "nc_sync_user_id": sync_user_id.id, + "nc_event_hash": event_hash, + }] + if organizer_user_id: + nc_sync_user_id = self.env["nc.sync.user"].search( + [("user_id", "=", organizer_user_id), ("sync_calendar", "=", True)], limit=1 + ) + if nc_sync_user_id != sync_user_id: + nc_user_event_hash, nc_sync_user_calendar_id = ( + nc_sync_user_id.get_nc_event_hash_by_uid_for_other_user( + nc_uid + ) + ) + hash_vals_list.append({ + "nc_sync_user_id": nc_sync_user_id.id, + "nc_event_hash": nc_user_event_hash, + }) + if nc_sync_user_calendar_id: + new_nc_calendar_ids.append(nc_sync_user_calendar_id.id) + vals["nc_calendar_ids"] = [(6, 0, new_nc_calendar_ids)] + vals.update( + { + "partner_ids": [(6, 0, attendee_partner_ids)], + "allday": all_day, + "nc_allday": all_day, + "nc_synced": True, + "user_id": organizer_user_id + } + ) + # Perform create operation + if operation == "create": + try: + new_event_id = False + # Check if the event is part of recurring event + if "nc_rid" in vals and nc_uid: + recurring_event_id = all_odoo_event_ids.filtered( + lambda x: x.nc_uid == nc_uid + and x.nc_rid == vals["nc_rid"] + ) + if recurring_event_id: + recurring_event_id.with_context(sync_from_nextcloud=True).write(vals) + self.update_attendee_invite(recurring_event_id) + for hash_vals in hash_vals_list: + self.update_event_hash( + hash_vals, recurring_event_id + ) + all_odoo_event_ids = self.delete_exempted_event( + recurring_event_id, + exdates, + all_odoo_event_ids, + ) + else: + nc_hash_ids = [] + for hash_vals in hash_vals_list: + nc_hash_ids.append((0, 0, hash_vals)) + vals["nc_hash_ids"] = nc_hash_ids + context_dict = {'sync_from_nextcloud':True} + if caldav_event.icalendar_component.get('RELATED-TO'): + if 'until' in vals.get('nextcloud_rrule','').lower(): + context_dict.update({'update_until': True}) + new_event_id = calendar_event.with_context(context_dict).create(vals) + if ( + new_event_id.recurrence_id + and new_event_id.recurrence_id.calendar_event_ids + ): + recurring_event_ids = ( + new_event_id.recurrence_id.calendar_event_ids + ) + all_odoo_event_ids |= recurring_event_ids + self.update_attendee_invite(recurring_event_ids) + for hash_vals in hash_vals_list: + self.update_event_hash( + hash_vals, recurring_event_ids + ) + all_odoo_event_ids = self.delete_exempted_event( + new_event_id, exdates, all_odoo_event_ids + ) + + else: + all_odoo_event_ids |= new_event_id + # In Odoo, the organizer is by default part of + # the attendee and automatically accepts the invite + # Accepted calendar event in Odoo appears with + # background filled in Calendar view + if new_event_id: + self.update_attendee_invite(new_event_id) + # Commit the changes to the database + self.env.cr.commit() + params["create_count"] += 1 + except Exception as e: + message = ( + "Error creating Odoo event '%s' for user '%s':\n" + % (event_name, user_name) + ) + log_obj.log_event( + mode="error", + error=e, + message="Error creating Odoo event", + operation_type="create", + ) + params["error_count"] += 1 + continue + # Perform write operation + if operation == "write" and od_event_id: + try: + # We don"t update if the event only contains + # rrule but no nc_rid + if "rrule" in vals and "nc_rid" not in vals: + if od_event_id.exists(): + if od_event_id.recurrence_id.base_event_id == od_event_id: + update = self.check_recurrent_event_vals(od_event_id,vals) + if update: + recurrence_vals = vals + (od_event_id.recurrence_id.calendar_event_ids - od_event_id.recurrence_id.base_event_id).write( + {'nc_uid': False}) + recurrence_vals.update({'recurrence_update': 'all_events'}) + recurring_events = od_event_id.recurrence_id.calendar_event_ids + context_dict = {'sync_from_nextcloud':True} + if (vals.get('nextcloud_rrule', + False) and od_event_id.nextcloud_rrule != vals.get( + 'nextcloud_rrule')) or (od_event_id.allday and (( + vals.get('start_date', False) and vals.get( + 'start_date') == od_event_id.start_date) or (vals.get('stop_date', + False) and vals.get( + 'stop_date') == od_event_id.stop_date))) or ( + not od_event_id.allday and (( + vals.get('start', False) and vals.get( + 'start') == od_event_id.start) or ( + vals.get('stop', False) and vals.get( + 'stop') == od_event_id.stop))): + recurrence_vals.update( + {'nextcloud_rrule': vals['nextcloud_rrule']}) + recurrence_vals.update( + {'rrule': vals['nextcloud_rrule']}) + recurrence_vals.update(calendar_recurrence_obj._rrule_parse( + vals['nextcloud_rrule'], vals.get('start', od_event_id.start))) + if (vals.get('nextcloud_rrule', + False) and od_event_id.nextcloud_rrule != vals.get( + 'nextcloud_rrule')) and 'until' in vals['nextcloud_rrule'].lower(): + context_dict.update({'update_until':True}) + context_dict.update({'update_nc_rid':True}) + else: + recurrence_vals.pop('rrule',None) + recurrence_vals.pop('nextcloud_rrule',None) + od_event_id.recurrence_id.base_event_id.with_context(context_dict).write( + recurrence_vals) + new_recurring_events = od_event_id.recurrence_id.calendar_event_ids + if not od_event_id.active: + new_recurrence = calendar_recurrence_obj.search([('base_event_id','=',od_event_id.id)],limit=1) + if new_recurrence: + new_recurring_events = new_recurrence.calendar_event_ids.sorted( + key=lambda r: r.start + ) + if new_recurring_events: + all_odoo_event_ids = all_odoo_event_ids - od_event_id + recurring_events = recurring_events - od_event_id + od_event_id.with_context(force_delete=True).unlink() + new_recurrence.base_event_id = new_recurring_events[0].id + od_event_id = new_recurring_events[0] + all_odoo_event_ids = self.update_recurring_events_in_all_events(new_recurring_events,recurring_events,all_odoo_event_ids) + if context_dict.get('update_nc_rid'): + if not od_event_id.allday: + start = od_event_id.start + tz = od_event_id.nextcloud_event_timezone + if tz: + dt_tz = start.replace(tzinfo=pytz.utc) + start = dt_tz.astimezone( + pytz.timezone(tz)) + od_event_id.nc_rid = start.strftime("%Y%m%dT%H%M%S") + else: + od_event_id.nc_rid = od_event_id.nc_rid + else: + od_event_id.nc_rid = od_event_id.start.strftime("%Y%m%d") + for hash_vals in hash_vals_list: + self.update_event_hash( + hash_vals, new_recurring_events + ) + for hash_vals in hash_vals_list: + self.update_event_hash(hash_vals, od_event_id) + all_odoo_event_ids = self.delete_exempted_event( + od_event_id, exdates, all_odoo_event_ids + ) + self.env.cr.commit() + params["write_count"] += 1 + continue + # Check if the event is part of recurring event + elif ( + "rrule" not in vals + and "nc_rid" in vals + and od_event_id + and od_event_id.recurrence_id + ): + recurring_event_ids = ( + od_event_id.recurrence_id.calendar_event_ids + ) + recurring_event_id = recurring_event_ids.filtered( + lambda x: x.nc_rid == vals["nc_rid"] + ) + if not recurring_event_id: + continue + else: + od_event_id = recurring_event_id + vals.pop('nextcloud_event_timezone',None) + od_event_id.with_context(sync_from_nextcloud=True).write(vals) + # # Update the hash value of the Odoo event that + # # corresponds to the current user_id + # if od_event_id.recurrence_id: + # od_event_id = ( + # od_event_id.recurrence_id.calendar_event_ids + # ) + # Update hash values and attendee invite + self.update_attendee_invite(od_event_id) + for hash_vals in hash_vals_list: + self.update_event_hash(hash_vals, od_event_id) + all_odoo_event_ids = self.delete_exempted_event( + od_event_id, exdates, all_odoo_event_ids + ) + # Commit the changes to the database + self.env.cr.commit() + params["write_count"] += 1 + except Exception as e: + message = ( + "Error updating Odoo event '%s' for user '%s':\n" + % (event_name, user_name) + ) + log_obj.log_event( + mode="error", + error=e, + message=message, + operation_type="write", + ) + params["error_count"] += 1 + params["all_odoo_event_ids"] = all_odoo_event_ids + return params + + def update_nextcloud_events(self, sync_user_id, nc_events_dict, **params): + """ + This method updates the Nexcloud calendar event records from Odoo + :param sync_user_id: single recordset of nc.sync.user model + :param nc_events_dict: dictionary of create, write and delete + operations for Nextcloud + :param **params: dictionary of keyword arguments containing + multiple recordset of models + """ + calendar_event_obj = self.env["calendar.event"] + connection = params.get("connection", False) + principal = params.get("principal", False) + fields = calendar_event_obj._fields + update_events_hash = {} + # Reverse the mapping of the field in get_caldav_fiels() method for + # Odoo -> Nextcloud direction + field_mapping = {v: k for k, v in self.get_caldav_fields().items()} + alarms_mapping = {v: k for k, v in self.get_alarms_mapping().items()} + log_obj = params["log_obj"] + user_name = sync_user_id.user_id.name + recurrent_rule_ids = {} + for operation in nc_events_dict: + recurrent_rule_ids[operation] = [] + prev_operation = operation + for event in nc_events_dict[operation]: + current_operation = operation + event_id = event["od_event"] + if event_id and not event_id.exists(): + continue + caldav_event = event.get("nc_caldav", False) + vevent = False + if caldav_event: + vevent = caldav_event.vobject_instance.vevent + if event_id.recurrence_id: + if ( + event_id.recurrence_id in recurrent_rule_ids[operation] + and not event_id.nc_detach + ): + continue + else: + if operation == "create" and not event_id.nc_detach: + # Get the first event of the recurring event by + # sorting it by record id + event_ids = ( + event_id.recurrence_id.calendar_event_ids.sorted( + key=lambda r: r.start + ) + ) + event_id = calendar_event_obj.browse(event_ids.ids[0]) + attendees = [] + vals = {} + # Loop through each fields from the Nextcloud event and map it + # to Odoo fields with values + for field in field_mapping: + if ( + field not in fields + or not event_id[field] + or field in ["id", "write_date", "nc_rid"] + ): + continue + value = event_id[field] + if field in ["start", "stop"]: + if "allday" in event_id and event_id["allday"]: + start_stop = { + "start": event_id["start_date"], + "stop": event_id["stop_date"] + timedelta(days=1), + } + vals[field_mapping[field]] = start_stop[field] + else: + user_tz = event_id.nextcloud_event_timezone or sync_user_id.user_id.tz + vals[field_mapping[field]] = self.convert_date( + value, user_tz, "local" + ) + elif field == "partner_ids": + attendees, organizer, params = self.get_event_attendees( + event_id, sync_user_id.user_id, **params + ) + elif field == "description": + description = html2plaintext(value) + if description != "" or description: + vals[field_mapping[field]] = description + elif field == "show_as": + show_as = {"free": "TRANSPARENT", "busy": "OPAQUE"} + vals[field_mapping[field]] = show_as[value] + elif field == "nc_status_id": + vals[field_mapping[field]] = value.name.upper() + elif field == "alarm_ids": + vals[field_mapping[field]] = [ + alarms_mapping.get(x.id) + for x in value + if x.id in alarms_mapping.keys() + ] + if not vals[field_mapping[field]]: + vals.pop(field_mapping[field]) + elif field == "categ_ids": + vals[field_mapping[field]] = value.mapped("name") + elif field == "recurrence_id": + if value not in recurrent_rule_ids[operation]: + recurrent_rule_ids[operation].append(value) + rrule = self.get_rrule_dict(value._rrule_serialize()) + if rrule.get('UNTIL'): + rrule.update({'UNTIL':parse(rrule.get('UNTIL'))}) + vals[field_mapping[field]] = rrule + else: + vals[field_mapping[field]] = value + # Get the Nextcloud calendar + event_name = vals["summary"] + nc_calendar_id = ( + sync_user_id.nc_calendar_id + if sync_user_id.nc_calendar_id + else False + ) + if event_id["nc_calendar_ids"]: + event_nc_calendar_id = event_id.nc_calendar_ids.filtered( + lambda x: x.user_id == event_id.user_id + ) + if event_nc_calendar_id: + nc_calendar_id = event_nc_calendar_id + + if event_id.recurrence_id or event_id.nc_rid: + event_id, operation, vals = self.manage_recurring_instance( + event, operation, vals + ) + if operation == 'null': + params["create_count"] += len( + event_id.recurrence_id.calendar_event_ids.filtered(lambda x: x.nc_to_delete)) + operation = current_operation + continue + elif not event_id.recurrence_id and operation != prev_operation: + operation = prev_operation + + # Perform create operation + if operation == "create": + # Check if event is recurrent and there are exempted dates + try: + if nc_calendar_id and connection and principal: + calendar_obj = self.get_user_calendar( + connection, principal, nc_calendar_id.name + ) + new_caldav_event = calendar_obj.save_event(**vals) + caldav_event = new_caldav_event + # After creating the event, add attendees and + # alarms data + event = self.add_nc_alarm_data( + caldav_event, vals.get("valarm", []) + ) + if attendees: + new_caldav_event.parent.save_with_invites( + caldav_event.icalendar_instance, + attendees=attendees, + schedule_agent="NONE", + ) + vevent = caldav_event.vobject_instance.vevent + params["create_count"] += 1 + else: + params["error_count"] += 1 + raise ValidationError( + _( + "No Nextcloud calendar specified " + "for the event %s for user %s" + % (event_id.name, event_id.user_id.name) + ) + ) + except Exception as e: + message = ( + "Error creating Nextcloud event '%s' for user '%s':\n" + % (event_name, user_name) + ) + log_obj.log_event( + mode="error", + error=e, + message=message, + operation_type="create", + ) + params["error_count"] += 1 + + # Perform write operation + if operation == "write" and caldav_event: + try: + # Check if there is a change in nc_calendar in Odoo + od_calendar_url = ( + nc_calendar_id.calendar_url if nc_calendar_id else False + ) + nc_calendar_url = caldav_event.parent.canonical_url + if od_calendar_url != nc_calendar_url and nc_calendar_id: + # If the nc_calendar was changed in Odoo, + # delete the existing event in Nextcloud + # old calendar and recreate the same event + # in Nextcloud on the new calendar + event_data = caldav_event.data + new_calendar_obj = self.get_user_calendar( + connection, principal, nc_calendar_id.name + ) + caldav_event = new_calendar_obj.add_event(event_data) + old_event = caldav_event.event_by_uid(event_id.nc_uid) + old_event.delete() + # Remove the current event dtstart and dtend values if + # its an allday event in Nextcloud but not in Odoo + if not event_id.allday and event_id.nc_allday: + vevent.contents.pop("dtstart") + vevent.contents.pop("dtend") + # Update the event alarms (alarm_ids) + event = self.add_nc_alarm_data( + caldav_event, vals.pop("valarm", []) + ) + # Handle special case when no value exist for some + # fields + for f in [ + "transp", + "description", + "location", + "dtstart", + "dtend", + ]: + # if no value exist in Odoo, remove the field in + # Nextcloud event + if f not in vals and f != "transp": + vevent.contents.pop(f, False) + # if no value exist in Nextcloud, use the value + # from Odoo + elif not vevent.contents.get(f, False): + caldav_event.icalendar_component.add(f, vals.pop(f)) + # Update rest of remaining fields + [ + exec( + f"caldav_event.vobject_instance.vevent.{i}.value = val", + {"caldav_event": caldav_event, "val": vals[i]}, + ) + for i in vals + ] + # Update attendees + if "attendee" in vevent.contents: + vevent.contents.pop("attendee") + if attendees: + for attendee in attendees: + caldav_event.add_attendee(attendee) + # caldav_event.parent.save_with_invites( + # caldav_event.icalendar_instance, + # attendees=attendees, + # schedule_agent="NONE", + # ) + params["write_count"] += 1 + except Exception as e: + message = ( + "Error updating Nextcloud event '%s' for user '%s':\n" + % (event_name, user_name) + ) + log_obj.log_event( + mode="error", + error=e, + message=message, + operation_type="write", + ) + params["error_count"] += 1 + + # Save changes to the event after create/write operation + if operation in ("create", "write"): + try: + caldav_event.save() + event_hash = sync_user_id.get_nc_event_hash_by_uid( + vevent.uid.value + ) + # Update the Odoo event record + res = {'nc_synced': True} + if "nc_hash_ids" not in res: + res["nc_hash_ids"] = [] + event_nchash_id = event_id.nc_hash_ids.filtered( + lambda x: x.nc_sync_user_id == sync_user_id + ) + res["nc_hash_ids"].append( + ( + 0 if not event_nchash_id else 1, + 0 if not event_nchash_id else event_nchash_id[0].id, + { + "nc_sync_user_id": sync_user_id.id, + "nc_event_hash": event_hash, + }, + ) + ) + update_events_hash[event_id.nc_uid] = event_id + nc_user_ids = params["nc_user_ids"] + if nc_user_ids: + for nc_user_id in nc_user_ids: + nc_user_event_hash = ( + nc_user_id.get_nc_event_hash_by_uid( + vevent.uid.value + ) + ) + event_nchash_id = event_id.nc_hash_ids.filtered( + lambda x: x.nc_sync_user_id == nc_user_id + ) + res["nc_hash_ids"].append( + ( + 0 if not event_nchash_id else 1, + 0 + if not event_nchash_id + else event_nchash_id[0].id, + { + "nc_sync_user_id": nc_user_id.id, + "nc_event_hash": nc_user_event_hash, + }, + ) + ) + + if event_id.recurrence_id: + hash_updated = False + if not event_id.nc_detach and event_id.recurrence_id.base_event_id == event_id: + if caldav_event.icalendar_component.get( + 'DTSTART') and caldav_event.icalendar_component.get('RRULE'): + event_start_date = caldav_event.icalendar_component.get('DTSTART').dt + tz = False + if isinstance(event_start_date, datetime): + tz = event_start_date.tzinfo.zone + if tz: + if event_id.nextcloud_event_timezone != tz: + res['nextcloud_event_timezone'] = tz + res['nextcloud_rrule'] = vevent.contents.get('rrule',False) and vevent.rrule.value or event_id.nextcloud_rrule or event_id.rrule + event_id.recurrence_id.calendar_event_ids.with_context(sync_from_nextcloud=True).write(res) + event_id.recurrence_id.calendar_event_ids.filtered(lambda x:not x.nc_hash_ids).write({'nc_hash_ids':[( + 0 , + 0 , + { + "nc_sync_user_id": sync_user_id.id, + "nc_event_hash": event_hash, + }, + )]}) + event_id.recurrence_id.calendar_event_ids.filtered(lambda x:not x.nc_uid).write({"nc_uid": vevent.uid.value}) + hash_updated = True + if not hash_updated: + event_vals = {"nc_uid": vevent.uid.value,"nc_hash_ids":res['nc_hash_ids']} + else: + event_vals = {} + if event_id.nc_detach: + if event_id.recurrence_id and event_id.recurrence_id.base_event_id == event_id: + new_base_event = (event_id.recurrence_id.calendar_event_ids - event_id).sorted( + key=lambda r: r.start + ) + if new_base_event: + event_id.recurrence_id.base_event_id = new_base_event[0].id + event_vals.update({ + "recurrence_id": False, + "recurrency": False, + "nc_detach": False, + }) + event_id.with_context(sync_from_nextcloud=True).write(event_vals) + else: + res.update({"nc_uid": vevent.uid.value}) + if event_id.nc_detach: + if event_id.recurrence_id and event_id.recurrence_id.base_event_id == event_id: + new_base_event = (event_id.recurrence_id.calendar_event_ids - event_id).sorted( + key=lambda r: r.start + ) + if new_base_event: + event_id.recurrence_id.base_event_id = new_base_event[0].id + res.update({ + "recurrence_id": False, + "recurrency": False, + "nc_detach": False, + }) + event_id.with_context(sync_from_nextcloud=True).write(res) + # Commit the changes to the database since it is + # already been updated in Nextcloud + self.env.cr.commit() + except Exception as e: + message = "Error saving event '{}' for user '{}':\n".format( + event_name, + user_name, + ) + log_obj.log_event( + mode="error", + error=e, + message=message, + operation_type="write", + ) + + # Perform delete operation + if operation == "delete": + try: + # Delete the event in Nextcloud first before deleting + # it in Odoo + # Delete all Odoo events with the same nc_uid + # TODO: Handle deletion of specific instance of a + # recurring event where nc_uid are the same + all_events_with_nc_uid = params["all_odoo_event_ids"].filtered( + lambda x: x.nc_uid == event_id.nc_uid + ) + to_delete_event_ids = all_events_with_nc_uid.filtered( + lambda x: x.nc_to_delete + ) + params["all_odoo_event_ids"] = params[ + "all_odoo_event_ids" + ].filtered(lambda x: x not in to_delete_event_ids) + if not (len(all_events_with_nc_uid) - len(to_delete_event_ids)): + caldav_event.delete() + to_delete_event_ids.sudo().with_context(force_delete=True).unlink() + # Commit the changes to the database since it is + # already been deleted in Nextcloud + self.env.cr.commit() + params["delete_count"] += 1 + except Exception as e: + message = "Error deleting event '{}' for user '{}':\n".format( + event_name, + user_name, + ) + log_obj.log_event( + mode="error", + error=e, + message=message, + operation_type="delete", + ) + params["error_count"] += 1 + return params + + def sync_cron(self): + """ + This method triggers the sync event operation + """ + self = self.sudo() + per_user_id = self._context.get("per_user", False) + # Start Sync Process: Date + Time + sync_start = datetime.now() + result = self.env["nc.sync.log"].log_event("pre_sync") + log_obj = result["log_id"] + calendar_event_obj = self.env["calendar.event"] + # To minimize impact on performance, search only once rather than + # searching each loop + params = { + "log_obj": result["log_id"], + "all_nc_calendar_ids": self.env["nc.calendar"].search( + [("user_id", "!=", False)] + ), + "all_user_ids": self.env["res.users"].search([]), + "all_sync_user_ids": self.env["nc.sync.user"].search([("sync_calendar", "=", True)]), + "all_partner_ids": self.env["res.partner"].search([("email", "!=", False)]), + "all_odoo_event_type_ids": self.env["calendar.event.type"].search([]), + "status_vals": { + "confirmed": self.env.ref( + "nextcloud_odoo_sync.nc_event_status_confirmed" + ).id, + "tentative": self.env.ref( + "nextcloud_odoo_sync.nc_event_status_tentative" + ).id, + "cancelled": self.env.ref( + "nextcloud_odoo_sync.nc_event_status_canceled" + ).id, + }, + "create_count": 0, + "write_count": 0, + "delete_count": 0, + "error_count": 0, + } + if not per_user_id: + params["all_odoo_event_ids"] = calendar_event_obj.search([]) + if result["log_id"] and result["resume"]: + sync_users_domain = [("sync_calendar", "=", True)] + if per_user_id: + sync_users_domain.append(("user_id", "=", per_user_id.id)) + sync_users = self.env["nc.sync.user"].search(sync_users_domain) + for user in sync_users: + # Get all events from Odoo and Nextcloud + # log_obj.log_event(message="Getting events for '%s'" % user.user_id.name) + if per_user_id: + start_date = datetime.combine(user.start_date or dtdate.today(), datetime.min.time()) + params["all_odoo_event_ids"] = calendar_event_obj.search([('start', '>=', start_date)], + order="start") + events_dict = user.get_all_user_events(**params) + od_events = events_dict["od_events"] + nc_events = events_dict["nc_events"] + connection = events_dict["connection"] + principal = events_dict["principal"] + if connection and principal: + params.update({"connection": connection, "principal": principal}) + # # Compare all events + # log_obj.log_event( + # message="Comparing events for '%s'" % user["user_name"] + # ) + od_events_dict, nc_events_dict = self.compare_events( + od_events, nc_events, user, log_obj + ) + # # Log number of operations to do + # all_stg_events = { + # "Nextcloud": nc_events_dict, + # "Odoo": od_events_dict, + # } + # for stg_events in all_stg_events: + # message = "%s:" % stg_events + # for operation in all_stg_events[stg_events]: + # count = len(all_stg_events[stg_events][operation]) + # message += " {} events to {},".format(count, operation) + # log_obj.log_event(message=message.strip(",")) + # Update events in Odoo and Nextcloud + # log_obj.log_event(message="Updating Odoo events") + params = self.update_odoo_events(user, od_events_dict, **params) + # log_obj.log_event(message="Updating Nextcloud events") + params = self.update_nextcloud_events( + user, nc_events_dict, **params + ) + # Compute duration of sync operation + log_obj.duration = log_obj.get_time_diff(sync_start) + summary_message = """- Total create {} +- Total write {} +- Total delete {} +- Total error {}""".format( + params["create_count"], + params["write_count"], + params["delete_count"], + params["error_count"], + ) + log_obj.log_event(message="""End Sync Process\n%s""" % summary_message) + log_obj.write({"description": summary_message, "date_end": datetime.now()}) + + def add_nc_alarm_data(self, event, valarm): + """ + This method adds reminders on a nextcloud event + :param event: Caldav Nextcloud event + :param valarm: list of event reminders/alarms + operations for Nextcloud + :return event: returns a Caldav Nextcloud event with the + corresponding reminders + """ + if valarm: + event.vobject_instance.vevent.contents.pop("valarm", False) + for item in valarm: + alarm_obj = Alarm() + alarm_obj.add("action", "DISPLAY") + alarm_obj.add("TRIGGER;RELATED=START", item) + event.icalendar_component.add_component(alarm_obj) + return event + + def get_user_calendar(self, connection, connection_principal, nc_calendar): + """ + This record gets the Caldav record on the event besed on the name. + It will create a new calendar record in nextcloud if it does not exist + :param connection: Caldav user connection data + :param connection_principal: Caldav user connection principal data + :param nc_calendar: Calendar name (String) + :return calendar_obj: Caldav calendar object + """ + try: + principal_calendar_obj = connection_principal.calendar(name=nc_calendar) + principal_calendar_obj.events() + calendar_obj = connection.calendar(url=principal_calendar_obj.url) + except BaseException: + calendar_obj = connection_principal.make_calendar(name=nc_calendar) + return calendar_obj + + def check_nextcloud_connection(self, url, username, password): + """ + This method checks the NextCloud connection + :param url: string, NextCloud server URL + :param username: string, NextCloud username + :param password: string, NextCloud password + @return tuple: Caldav client object, client principal / dictionary + """ + with caldav.DAVClient(url=url, username=username, password=password) as client: + try: + return client, client.principal() + except caldav.lib.error.NotFoundError as e: + _logger.warning("Error: %s" % e) + return client, { + "sync_error_id": self.sudo().env.ref( + "nextcloud_odoo_sync.nc_sync_error_1001" + ), + "response_description": str(e), + } + except caldav.lib.error.AuthorizationError as e: + _logger.warning("Error: %s" % e) + return client, { + "sync_error_id": self.sudo().env.ref( + "nextcloud_odoo_sync.nc_sync_error_1000" + ), + "response_description": str(e), + } + except ( + caldav.lib.error.PropfindError, + requests.exceptions.ConnectionError, + ) as e: + _logger.warning("Error: %s" % e) + return client, { + "sync_error_id": self.sudo().env.ref( + "nextcloud_odoo_sync.nc_sync_error_1001" + ), + "response_description": str(e), + } + + def get_alarms_mapping(self): + """ + This method returns the equivalent record ID of Odoo calendar.alarm model + based according to Nextcloud alarm code + :return dictionary + {string: Nextcloud alarm code, integer: Odoo calendar.alarm record id} + """ + alarm_mapping = { + "PT0S": "nextcloud_odoo_sync.calendar_alarm_notif_at_event_start", + "-PT5M": "nextcloud_odoo_sync.calendar_alarm_notif_5_mins", + "-PT10M": "nextcloud_odoo_sync.calendar_alarm_notif_10_mins", + "-PT15M": "calendar.alarm_notif_1", + "-PT30M": "calendar.alarm_notif_2", + "-PT1H": "calendar.alarm_notif_3", + "-PT2H": "calendar.alarm_notif_4", + "-P1D": "calendar.alarm_notif_5", + "-P2D": "nextcloud_odoo_sync.calendar_alarm_notif_2_days", + } + result = {} + for alarm_code in alarm_mapping: + try: + result.update({alarm_code: self.env.ref(alarm_mapping[alarm_code]).id}) + except Exception: + result.update({alarm_code: False}) + return result + + def get_odoo_alarms(self, valarm): + """ + This method is used to get the Nextcloud alarm code and + find the equivalent record ID in Odoo calendar.alarm model + :param valarm: dictionary of Nextcloud alarms from the event + :return list: List of values to populate a many2many field + """ + result = [] + alarms_mapping = self.get_alarms_mapping() + for v_item in valarm: + if isinstance(v_item, dict): + v_item.pop("ACTION", False) + val = [v_item[x] for x in v_item] + if val: + alarm_id = alarms_mapping.get(val[0]) + if alarm_id: + result.append(alarm_id) + return [(6, 0, result)] + + def get_odoo_categories(self, categ_ids, value): + """ + This method returns the corresponding odoo data result + for categ_ids field + :param categ_ids: categories record set + :param value: comma separated string of categories + :return odoo value for categ_ids and updated value for categ_ids + """ + result = [] + if value: + for category in value.lower().split(","): + category_id = categ_ids.filtered(lambda x: x.name.lower() == category) + if not category_id: + category_id = categ_ids.create({"name": category}) + categ_ids |= category_id + result.append(category_id.id) + return [(6, 0, result)], categ_ids + + def get_caldav_fields(self): + """ + Function for mapping of CalDav fields to Odoo fields + :return dictionary: CalDav fields as key and Odoo + calendar.event model fields as value + """ + return { + "summary": "name", + "dtstart": "start", + "dtend": "stop", + "description": "description", + "status": "nc_status_id", + "location": "location", + "attendee": "partner_ids", + "categories": "categ_ids", + "transp": "show_as", + "uid": "nc_uid", + "valarm": "alarm_ids", + "rrule": "recurrence_id", + "recurrence-id": "nc_rid", + "last-modified": "write_date", + "id": "id", + } + + def convert_date(self, dt, tz, mode): + """ + This method converts datetime object to UTC and vice versa + :param dt, datetime object + :param tz, string (e.g. "Asia/Manila") + :param mode, string ("utc":local time -> utc, + "local":utc -> local time) + :return: datetime + """ + dt_conv = False + if mode and dt and tz: + if mode == "utc": + dt_tz = pytz.timezone(tz).localize(dt, is_dst=None) + dt_conv = dt_tz.astimezone(pytz.utc).replace(tzinfo=None) + if mode == "local": + dt_tz = dt.replace(tzinfo=pytz.utc) + dt_conv = dt_tz.astimezone(pytz.timezone(tz)) + return dt_conv + + def update_recurring_events_in_all_events(self,new_recurring_events,recurring_events,all_odoo_event_ids): + for event in recurring_events: + if not event.exists(): + all_odoo_event_ids = all_odoo_event_ids - event + for event in new_recurring_events: + if event not in all_odoo_event_ids: + all_odoo_event_ids = all_odoo_event_ids + event + return all_odoo_event_ids + + def check_recurrent_event_vals(self, od_event_id, vals): + update = False + if vals.get('nextcloud_rrule', '') != od_event_id.nextcloud_rrule or vals.get('name', + '') != od_event_id.name or vals.get( + 'show_as', '') != od_event_id.show_as: + update = True + elif (vals.get('description', False) and vals.get('description') != od_event_id.description) or ( + vals.get('location', False) and vals.get('location') != od_event_id.location): + update = True + if od_event_id.allday: + if (vals.get('start_date', False) and vals.get('start_date') != od_event_id.start_date) or ( + vals.get('stop_date', False) and vals.get('stop_date') != od_event_id.stop_date): + update = True + else: + if (vals.get('start', False) and vals.get('start') != od_event_id.start) or ( + vals.get('stop', False) and vals.get('stop') != od_event_id.stop): + update = True + if vals.get('alarm_ids', []): + new_vals = vals.get('alarm_ids')[0][2] + alarm_ids = od_event_id.alarm_ids.ids + if len(alarm_ids) != len(new_vals): + update = True + for rec in new_vals: + if rec not in alarm_ids: + update = True + break + if vals.get('categ_ids', []): + new_vals = vals.get('categ_ids')[0][2] + categ_ids = od_event_id.categ_ids.ids + if len(categ_ids) != len(new_vals): + update = True + for rec in new_vals: + if rec not in categ_ids: + update = True + break + if vals.get('partner_ids', []): + new_vals = vals.get('partner_ids')[0][2] + partner_ids = od_event_id.partner_ids.ids + if len(partner_ids) != len(new_vals): + update = True + for rec in new_vals: + if rec not in partner_ids: + update = True + break + return update diff --git a/nextcloud_odoo_sync/models/res_config_settings.py b/nextcloud_odoo_sync/models/res_config_settings.py new file mode 100644 index 0000000..6cf15d2 --- /dev/null +++ b/nextcloud_odoo_sync/models/res_config_settings.py @@ -0,0 +1,35 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import fields, models, api + + +class ResConfigSettings(models.TransientModel): + _inherit = "res.config.settings" + + log_capacity = fields.Integer( + string="Log Capacity", + default=7, + config_parameter="nextcloud_odoo_sync.log_capacity", + ) + monthly_recurring_events_limit = fields.Integer( + string="Monthly Recurring Events Limit", + default=2, + config_parameter="nextcloud_odoo_sync.monthly_recurring_events_limit", + ) + daily_recurring_events_limit = fields.Integer( + string="Daily Recurring Events Limit", + default=2, + config_parameter="nextcloud_odoo_sync.daily_recurring_events_limit", + ) + weekly_recurring_events_limit = fields.Integer( + string="Weekly Recurring Events Limit", + default=2, + config_parameter="nextcloud_odoo_sync.weekly_recurring_events_limit", + ) + yearly_recurring_events_limit = fields.Integer( + string="Yearly Recurring Events Limit", + default=10, + config_parameter="nextcloud_odoo_sync.yearly_recurring_events_limit", + ) + diff --git a/nextcloud_odoo_sync/models/res_partner.py b/nextcloud_odoo_sync/models/res_partner.py new file mode 100644 index 0000000..35113e3 --- /dev/null +++ b/nextcloud_odoo_sync/models/res_partner.py @@ -0,0 +1,11 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models, fields + + +class ResPartner(models.Model): + _name = "res.partner" + _inherit = "res.partner" + + nc_sync = fields.Boolean() diff --git a/nextcloud_odoo_sync/models/res_users.py b/nextcloud_odoo_sync/models/res_users.py new file mode 100644 index 0000000..858b675 --- /dev/null +++ b/nextcloud_odoo_sync/models/res_users.py @@ -0,0 +1,45 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo import models, fields +from odoo.exceptions import UserError + + +class ResUsers(models.Model): + _inherit = "res.users" + + nc_calendar_ids = fields.One2many("nc.calendar", "user_id", "NextCloud Calendar") + + def setup_nc_sync_user(self): + action = { + "name": "Nextcloud User Setup", + "view_mode": "form", + "res_model": "nc.sync.user", + "type": "ir.actions.act_window", + "context": {"pop_up": True}, + "target": "new", + } + nc_sync_user_id = self.env["nc.sync.user"].search( + [("user_id", "=", self.env.user.id)], limit=1 + ) + if nc_sync_user_id: + action["res_id"] = nc_sync_user_id.id + return action + + def sync_user_events(self): + sync_users = self.env["nc.sync.user"].search([("user_id", "=", self.id)],limit=1) + if not sync_users: + raise UserError("Sync User not found") + elif sync_users and not sync_users.sync_calendar: + raise UserError("Sync Calendar is not enabled for this User") + self.env["nextcloud.caldav"].with_context({"per_user": self}).sync_cron() + return { + "type": "ir.actions.client", + "tag": "display_notification", + "params": { + "title": ("Nextcloud Sync"), + "message": "Sync Done", + "type": "success", + "sticky": False, + }, + } diff --git a/nextcloud_odoo_sync/readme/CONTRIBUTORS.rst b/nextcloud_odoo_sync/readme/CONTRIBUTORS.rst new file mode 100644 index 0000000..66dfdf4 --- /dev/null +++ b/nextcloud_odoo_sync/readme/CONTRIBUTORS.rst @@ -0,0 +1 @@ +* iScale Solutons diff --git a/nextcloud_odoo_sync/readme/DESCRIPTION.rst b/nextcloud_odoo_sync/readme/DESCRIPTION.rst new file mode 100644 index 0000000..da9f8e9 --- /dev/null +++ b/nextcloud_odoo_sync/readme/DESCRIPTION.rst @@ -0,0 +1 @@ +This module sync Nextcloud apps into Odoo counterpart app. diff --git a/nextcloud_odoo_sync/readme/USAGE.rst b/nextcloud_odoo_sync/readme/USAGE.rst new file mode 100644 index 0000000..0288466 --- /dev/null +++ b/nextcloud_odoo_sync/readme/USAGE.rst @@ -0,0 +1,17 @@ +Odoo Configuration +~~~~~~~~~~~~~~~~~~ + +#. Go to Settins/ Nextcloud +#. Click *Enable Calendar Sync* +#. Indicate the *Server URL* and the Nextcloud admin credentials then click Save. Do not put a trailing forward slash "/" at the end of the *Server URL* + + +User Configuration +~~~~~~~~~~~~~~~~~~ + +To use this module, you need to: + +#. On the upper right corner of the Odoo web client, click the *User* menu and select *Preferences* +#. Click the button *Setup Nextcloud User* +#. On the pop-up dialog, input your Nextcloud *Username* and *Password* and click *Login to Nextcloud* button +#. It will try to login into Nextcloud and ask you to select a default Nextcloud Calendar to use diff --git a/nextcloud_odoo_sync/security/ir.model.access.csv b/nextcloud_odoo_sync/security/ir.model.access.csv new file mode 100644 index 0000000..01b254c --- /dev/null +++ b/nextcloud_odoo_sync/security/ir.model.access.csv @@ -0,0 +1,13 @@ +id,name,model_id:id,group_id:id,perm_read,perm_write,perm_create,perm_unlink +access_nextcloud_sync_user_all,access.nextcloud.sync.user.all,model_nc_sync_user,base.group_user,1,1,1,0 +access_nextcloud_sync_user_user,access.nextcloud.sync.user.user,model_nc_sync_user,base.group_user,1,1,1,1 +access_nextcloud_sync_log_admin,access.nextcloud.sync.log.admin,model_nc_sync_log,nextcloud_odoo_sync.group_nextcloud_sync_admin,1,1,1,1 +access_nextcloud_sync_log_line_admin,access.nextcloud.sync.log.line.admin,model_nc_sync_log_line,nextcloud_odoo_sync.group_nextcloud_sync_admin,1,1,1,1 +access_nextcloud_sync_error_admin,access.nextcloud.sync.error.admin,model_nc_sync_error,base.group_system,1,1,1,1 +access_nextcloud_event_status_admin,access.nextcloud.event.status.admin,model_nc_event_status,nextcloud_odoo_sync.group_nextcloud_sync_admin,1,1,1,1 +access_nextcloud_event_status_all,access.nextcloud.event.status.all,model_nc_event_status,base.group_user,1,0,0,0 +access_nextcloud_calendar_all,access.nextcloud.calendar.all,model_nc_calendar,base.group_user,1,0,0,0 +access_nextcloud_calendar_admin,access.nextcloud.calendar.admin,model_nc_calendar,nextcloud_odoo_sync.group_nextcloud_sync_admin,1,1,1,1 +access_run_sync_wizard,access.run.sync.wizard,model_run_sync_wizard,base.group_user,1,1,1,1 +access_calendar_event_nchash_all,access.calendar.event.nchash.all,model_calendar_event_nchash,base.group_user,1,0,0,0 +access_calendar_event_nchash_admin,access.calendar.event.nchash.admin,model_calendar_event_nchash,base.group_system,1,1,1,1 diff --git a/nextcloud_odoo_sync/security/ir_rule.xml b/nextcloud_odoo_sync/security/ir_rule.xml new file mode 100644 index 0000000..775bb9c --- /dev/null +++ b/nextcloud_odoo_sync/security/ir_rule.xml @@ -0,0 +1,11 @@ + + + Users can read and setup their own Nextcloud configuration + + [('user_id', '=', user.id)] + + + + + + \ No newline at end of file diff --git a/nextcloud_odoo_sync/static/description/icon.png b/nextcloud_odoo_sync/static/description/icon.png new file mode 100644 index 0000000..72322c5 Binary files /dev/null and b/nextcloud_odoo_sync/static/description/icon.png differ diff --git a/nextcloud_odoo_sync/static/description/index.html b/nextcloud_odoo_sync/static/description/index.html new file mode 100644 index 0000000..32800fd --- /dev/null +++ b/nextcloud_odoo_sync/static/description/index.html @@ -0,0 +1,727 @@ + + + + + + + Nextcloud-Odoo Sync + + + +
+

Nextcloud-Odoo Sync

+ + +

+ Beta + License: AGPL-3 + OCA/volendra-misc + Translate me on Weblate + Try me on Runboat +

+

This module sync Nextcloud apps into Odoo counterpart app.

+

Table of contents

+
+ +
+
+

Usage

+
+

Odoo Configuration

+
    +
  1. Go to Settins/ Nextcloud
  2. +
  3. Click Enable Calendar Sync
  4. +
  5. + Indicate the Server URL and the Nextcloud admin + credentials then click Save. Do not put a trailing forward slash + “/” at the end of the Server URL +
  6. +
+
+
+

User Configuration

+

To use this module, you need to:

+
    +
  1. + On the upper right corner of the Odoo web client, click the + User menu and select Preferences +
  2. +
  3. Click the button Setup Nextcloud User
  4. +
  5. + On the pop-up dialog, input your Nextcloud Username and + Password and click Login to Nextcloud button +
  6. +
  7. + It will try to login into Nextcloud and ask you to select a + default Nextcloud Calendar to use +
  8. +
+
+
+
+

Bug Tracker

+

+ Bugs are tracked on + GitHub Issues. In case of trouble, please check there if your issue has already + been reported. If you spotted it first, help us smashing it by + providing a detailed and welcomed + feedback. +

+

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

+
+
+

Credits

+
+

Authors

+
    +
  • iScale Solutions Inc.
  • +
+
+
+

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

+

+ iscale-solutions +

+

+ This module is part of the + OCA/volendra-misc + project on GitHub. +

+

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

+
+
+
+ + diff --git a/nextcloud_odoo_sync/static/description/oca_icon.png b/nextcloud_odoo_sync/static/description/oca_icon.png new file mode 100644 index 0000000..3a0328b Binary files /dev/null and b/nextcloud_odoo_sync/static/description/oca_icon.png differ diff --git a/nextcloud_odoo_sync/tests/__init__.py b/nextcloud_odoo_sync/tests/__init__.py new file mode 100644 index 0000000..5a75885 --- /dev/null +++ b/nextcloud_odoo_sync/tests/__init__.py @@ -0,0 +1,4 @@ +from . import test_sync_common +from . import test_sync_odoo2nextcloud + +# from . import test_nextcloud_config diff --git a/nextcloud_odoo_sync/tests/common.py b/nextcloud_odoo_sync/tests/common.py new file mode 100644 index 0000000..45f3437 --- /dev/null +++ b/nextcloud_odoo_sync/tests/common.py @@ -0,0 +1,154 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests import common +from vcr import VCR +from datetime import datetime, timedelta +from os.path import dirname, join +import logging + +logging.getLogger("vcr").setLevel(logging.WARNING) + +recorder = VCR( + record_mode="once", + cassette_library_dir=join(dirname(__file__), "vcr_cassettes"), + path_transformer=VCR.ensure_suffix(".yaml"), + filter_headers=["Authorization"], +) + + +class TestCommon(common.TransactionCase): + def setUp(self): + super(TestCommon, self).setUp() + + # prepare users + self.organizer_user = self.env["res.users"].search([("name", "=", "test")]) + if not self.organizer_user: + partner = self.env['res.partner'].create({'name': 'test', 'email': 'test@example.com'}) + self.organizer_user = self.env['res.users'].create({ + 'name': 'test', + 'login': 'test@example.com', + 'partner_id': partner.id, + }) + # + self.attendee_user = self.env["res.users"].search([("name", "=", "John Attendee")]) + if not self.attendee_user: + partner = self.env['res.partner'].create({'name': 'John Attendee', 'email': 'john@attendee.com'}) + self.attendee_user = self.env['res.users'].create({ + 'name': 'John Attendee', + 'login': 'john@attendee.com', + 'partner_id': partner.id, + }) + + # ----------------------------------------------------------------------------------------- + # To create Odoo events + # ----------------------------------------------------------------------------------------- + self.start_date = datetime(2023, 8, 22, 10, 0, 0, 0) + self.end_date = datetime(2021, 8, 22, 11, 0, 0, 0) + + # simple event values to create a Odoo event + self.simple_event_values = { + "name": "simple_event", + "description": "my simple event", + "active": True, + "start": self.start_date, + "stop": self.end_date, + "partner_ids": [(4, self.organizer_user.partner_id.id), (4, self.attendee_user.partner_id.id)], + } + self.recurrent_event_values = { + 'name': 'recurring_event', + 'description': 'a recurring event', + "partner_ids": [(4, self.attendee_user.partner_id.id)], + 'recurrency': True, + 'follow_recurrence': True, + 'start': self.start_date.strftime("%Y-%m-%d %H:%M:%S"), + 'stop': self.end_date.strftime("%Y-%m-%d %H:%M:%S"), + 'event_tz': 'Europe/Amsterdam', + 'recurrence_update': 'self_only', + 'rrule_type': 'daily', + 'end_type': 'forever', + 'duration': 1, + } + + def create_events_for_tests(self): + """ + Create some events for test purpose + """ + + # ---- create some events that will be updated during tests ----- + + # a simple event + self.simple_event = self.env["calendar.event"].search([("name", "=", "simple_event")]) + if not self.simple_event: + self.simple_event = self.env["calendar.event"].with_user(self.organizer_user).create( + dict( + self.simple_event_values, + ) + ) + + # a recurrent event with 7 occurrences + self.recurrent_base_event = self.env["calendar.event"].search( + [("name", "=", "recurrent_event")], + order="id", + limit=1, + ) + already_created = self.recurrent_base_event + + if not already_created: + self.recurrent_base_event = self.env["calendar.event"].with_user(self.organizer_user).create( + self.recurrent_event_values + ) + self.recurrence = self.env["calendar.recurrence"].search([("base_event_id", "=", self.recurrent_base_event.id)]) + self.recurrent_events = self.recurrence.calendar_event_ids.sorted(key=lambda r: r.start) + self.recurrent_events_count = len(self.recurrent_events) + + def assert_odoo_event(self, odoo_event, expected_values): + """ + Assert that an Odoo event has the same values than in the expected_values dictionary, + for the keys present in expected_values. + """ + self.assertTrue(expected_values) + + odoo_event_values = odoo_event.read(list(expected_values.keys()))[0] + for k, v in expected_values.items(): + if k in ("user_id", "recurrence_id"): + v = (v.id, v.name) if v else False + + if isinstance(v, list): + self.assertListEqual(sorted(v), sorted(odoo_event_values.get(k)), msg=f"'{k}' mismatch") + else: + self.assertEqual(v, odoo_event_values.get(k), msg=f"'{k}' mismatch") + + def assert_odoo_recurrence(self, odoo_recurrence, expected_values): + """ + Assert that an Odoo recurrence has the same values than in the expected_values dictionary, + for the keys present in expected_values. + """ + odoo_recurrence_values = odoo_recurrence.read(list(expected_values.keys()))[0] + + for k, v in expected_values.items(): + self.assertEqual(v, odoo_recurrence_values.get(k), msg=f"'{k}' mismatch") + + def assert_dict_equal(self, dict1, dict2): + + # check missing keys + keys = set(dict1.keys()) ^ set(dict2.keys()) + self.assertFalse(keys, msg="Following keys are not in both dicts: %s" % ", ".join(keys)) + + # compare key by key + for k, v in dict1.items(): + self.assertEqual(v, dict2.get(k), f"'{k}' mismatch") + + def test_successful_connection(self): + nextcloud_caldav_obj = self.env["nextcloud.caldav"] + url = "https://next-iscale.onestein.eu" + username = "Anjeel5" + password = "anjeel@123" + with recorder.use_cassette("nextcloud_connection"): + client, principal = nextcloud_caldav_obj.check_nextcloud_connection( + url + "/remote.php/dav", username, password + ) + user_data = self.env["nextcloud.base"].get_user(principal.client.username, url, + username, + password) + self.get_user_calendars(principal) diff --git a/nextcloud_odoo_sync/tests/test_nextcloud_config.py b/nextcloud_odoo_sync/tests/test_nextcloud_config.py new file mode 100644 index 0000000..158ad3b --- /dev/null +++ b/nextcloud_odoo_sync/tests/test_nextcloud_config.py @@ -0,0 +1,91 @@ +# Copyright (c) 2023 iScale Solutions Inc. +# License AGPL-3.0 or later (http://www.gnu.org/licenses/agpl) + +from odoo.tests import common +from unittest.mock import patch, MagicMock +from caldav.objects import Event, Calendar + + +class TestNextCloudConfig(common.TransactionCase): + # def test_nextcloud_config(self): + # config_obj = self.env["ir.config_parameter"] + # self.assertEqual( + # config_obj.sudo().get_param( + # "nextcloud_odoo_sync.nextcloud_connection_status" + # ), + # False, + # ) + # config_obj.sudo().set_param( + # "nextcloud_odoo_sync.nextcloud_connection_status", "online" + # ) + # self.assertEqual( + # config_obj.sudo().get_param( + # "nextcloud_odoo_sync.nextcloud_connection_status" + # ), + # "online", + # ) + # config_obj.sudo().set_param( + # "nextcloud_odoo_sync.nextcloud_connection_status", False + # ) + + @patch("odoo.addons.nextcloud_odoo_sync.models.nc_calendar.NcCalendar") + def create_nc_calendar(self, mock_calendar_obj): + nc_calendar_id = mock_calendar_obj.create({"name": "Personal", "user_id": 2}) + return nc_calendar_id.id + + @patch("caldav.DAVClient") + def test_event_record(self, mock_client): + data = """ +BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:20190905T090000-1234567890@example.com +DTSTAMP:20190905T120000Z +DTSTART:20190905T090000Z +DTEND:20190905T100000Z +SUMMARY:Test Event +DESCRIPTION:This is a test event. +LOCATION:Test Location +END:VEVENT +END:VCALENDAR + """ + calendar = Calendar( + client=mock_client, url="http://example.com", parent=None, name="Personal" + ) + event = Event( + client=mock_client, url="http://example.com", data=data, parent=calendar + ) + print(event) + + @patch("caldav.DAVClient") + def test_successful_connection(self, mock_client): + # Mock the DAVClient object and the principal object + nextcloud_caldav_obj = self.env["nextcloud.caldav"] + mock_dav_client = MagicMock() + mock_client.return_value.__enter__.return_value = mock_dav_client + mock_principal = MagicMock() + mock_dav_client.principal.return_value = mock_principal + + # Call the check_nextcloud_connection method with a URL, username, and + # password + url = "http://example.com" + username = "test_user" + password = "test_password" + client, principal = nextcloud_caldav_obj.check_nextcloud_connection( + url, username, password + ) + + # Assert that the DAVClient object was created with the correct URL, + # username, and password + mock_client.assert_called_once_with( + url=url, username=username, password=password + ) + + # Assert that the principal method was called on the DAVClient object + mock_dav_client.principal.assert_called_once() + + # Assert that the return values are the mocked client and principal + # objects + self.assertEqual(client, mock_dav_client) + self.assertEqual(principal, mock_principal) diff --git a/nextcloud_odoo_sync/tests/test_sync_common.py b/nextcloud_odoo_sync/tests/test_sync_common.py new file mode 100644 index 0000000..515a709 --- /dev/null +++ b/nextcloud_odoo_sync/tests/test_sync_common.py @@ -0,0 +1,170 @@ +from datetime import datetime +from unittest.mock import MagicMock, patch +from odoo.tests.common import TransactionCase +from odoo.tools import html2plaintext +from caldav.objects import Event, Calendar +from odoo.addons.nextcloud_odoo_sync.models.nextcloud_caldav import Nextcloudcaldav + + +class TestSyncNextcloud(TransactionCase): + def assertNextcloudEventCreated(self, od_values): + kwrags = Calendar.save_event.call_args.kwargs + expected_args = self.from_odoo_to_nc_format_dict(list(kwrags), od_values[0]) + self.assertEqual(kwrags, expected_args) + + @patch("caldav.DAVClient") + def get_params(self, mock_client): + mock_dav_client = MagicMock() + mock_client.return_value.__enter__.return_value = mock_dav_client + mock_principal = MagicMock() + mock_dav_client.principal.return_value = mock_principal + params = { + "log_obj": self.create_log(), + "all_odoo_event_ids": self.env["calendar.event"].search([]), + "all_nc_calendar_ids": self.env["nc.calendar"].search( + [("user_id", "!=", False)] + ), + "all_user_ids": self.env["res.users"].search([]), + "all_sync_user_ids": self.env["nc.sync.user"].search([]), + "all_partner_ids": self.env["res.partner"].search([("email", "!=", False)]), + "all_odoo_event_type_ids": self.env["calendar.event.type"].search([]), + "status_vals": { + "confirmed": self.env.ref( + "nextcloud_odoo_sync.nc_event_status_confirmed" + ).id, + "tentative": self.env.ref( + "nextcloud_odoo_sync.nc_event_status_tentative" + ).id, + "cancelled": self.env.ref( + "nextcloud_odoo_sync.nc_event_status_canceled" + ).id, + }, + "create_count": 0, + "write_count": 0, + "delete_count": 0, + "error_count": 0, + "connection": mock_dav_client, + "principal": mock_principal, + "nc_user_ids": False, + } + od_events = { + "nc_uid": False, + "od_event": self.create_odoo_event(), + "event_hash": False, + } + return { + "sync_user_id": self.create_nc_sync_user(), + "nc_events_dict": {"create": [od_events], "write": [], "delete": []}, + "params": params, + } + + @patch("caldav.DAVClient") + def create_nextclound_event_allday(self, mock_client): + data = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//IDN nextcloud.com//Calendar app 4.2.4//EN +BEGIN:VEVENT +UID:20230505T051435-1234567890@example.com +DTSTART;VALUE=DATE:20230505 +DTEND;VALUE=DATE:20230506 +DTSTAMP:20230505T051443Z +STATUS:CONFIRMED +SUMMARY:Test all day +DESCRIPTION:This is a test event. +END:VEVENT +END:VCALENDAR""" + calendar = Calendar( + client=mock_client, url="http://example.com", parent=None, name="Personal" + ) + event = Event( + client=mock_client, url="http://example.com", data=data, parent=calendar + ) + return event + + @patch("caldav.DAVClient") + def create_nextcloud_event(self, mock_client): + data = """BEGIN:VCALENDAR +VERSION:2.0 +PRODID:-//Example Corp.//CalDAV Client//EN +BEGIN:VEVENT +UID:20190905T090000-1234567890@example.com +DTSTAMP:20190905T120000Z +DTSTART:20190905T090000Z +DTEND:20190905T100000Z +SUMMARY:Test Event +DESCRIPTION:This is a test event. +LOCATION:Test Location +END:VEVENT +END:VCALENDAR""" + calendar = Calendar( + client=mock_client, url="http://example.com", parent=None, name="Personal" + ) + event = Event( + client=mock_client, url="http://example.com", data=data, parent=calendar + ) + return event + + def create_odoo_event(self): + calendar_event = self.env["calendar.event"] + return calendar_event.create( + { + "name": "Test Event", + "start": "2019-09-05 09:00", + "stop": "2019-09-05 10:00", + "description": "This is a test event.", + "location": "Test Location", + "user_id": 2, + } + ) + + def create_log(self): + datetime_now = datetime.now() + return self.env["nc.sync.log"].create( + { + "name": datetime_now.strftime("%Y%m%d-%H%M%S"), + "date_start": datetime_now, + "state": "connecting", + "next_cloud_url": "test nc url", + "odoo_url": "test odoo url", + } + ) + + def create_nc_calendar(self): + calendar_obj = self.env["nc.calendar"] + nc_calendar_id = calendar_obj.create({"name": "Test Calendar", "user_id": 2}) + return nc_calendar_id.id + + def create_nc_sync_user(self): + sync_user_obj = self.env["nc.sync.user"] + sync_user_id = sync_user_obj.create( + { + "name": "test_sync_common_user", + "user_name": "test_sync_common_user", + "nc_password": "testpw", + "user_id": 2, + "nc_calendar_id": self.create_nc_calendar(), + } + ) + return sync_user_id + + def from_odoo_to_nc_format_dict(self, keys, od_event): + nextcloud_caldav_obj = self.env["nextcloud.caldav"] + field_mapping = nextcloud_caldav_obj.get_caldav_fields() + result = {} + for k in keys: + key = field_mapping[k] + if k in ("dtstart", "dtend"): + val = nextcloud_caldav_obj.convert_date( + od_event[key], "Europe/Amsterdam", "local" + ) + result[k] = val + elif k == "description": + result[k] = html2plaintext(od_event[key]) + elif k == "status" and od_event[key]: + result[k] = od_event[key][1].upper() + elif k == "transp": + show_as = {"free": "TRANSPARENT", "busy": "OPAQUE"} + result[k] = show_as[od_event[key]] + else: + result[k] = od_event[key] + return result diff --git a/nextcloud_odoo_sync/tests/test_sync_odoo2nextcloud.py b/nextcloud_odoo_sync/tests/test_sync_odoo2nextcloud.py new file mode 100644 index 0000000..8526de8 --- /dev/null +++ b/nextcloud_odoo_sync/tests/test_sync_odoo2nextcloud.py @@ -0,0 +1,55 @@ +from unittest.mock import MagicMock, patch +from odoo.addons.nextcloud_odoo_sync.tests.test_sync_common import TestSyncNextcloud +from odoo.addons.nextcloud_odoo_sync.models.nextcloud_caldav import Nextcloudcaldav +from odoo.addons.nextcloud_odoo_sync.models.nc_sync_user import NcSyncUser +from caldav.objects import Calendar, Event + + +class TestSyncOdoo2Nextcloud(TestSyncNextcloud): + def setUp(self): + super().setUp() + + def mock_update_nextcloud_events(self, cal_event): + with patch.object( + Nextcloudcaldav, + "get_user_calendar", + MagicMock(spec=Nextcloudcaldav.get_user_calendar), + ) as mock_get_user_calendar, patch.object( + Calendar, "save_event", MagicMock(spec=Calendar.save_event) + ) as mock_save_event, patch.object( + Event, "save", MagicMock(spec=Calendar.save) + ) as mock_save, patch.object( + NcSyncUser, + "get_nc_event_hash_by_uid", + MagicMock(spec=NcSyncUser.get_nc_event_hash_by_uid), + ) as mock_get_nc_event_hash_by_uid, patch.object( + self.env.cr, "commit" + ) as mock_cr_commit: + + mock_get_user_calendar.return_value = cal_event.parent + mock_save_event.return_value = cal_event + mock_save.return_value = True + mock_get_nc_event_hash_by_uid.return_value = "test_hash" + mock_cr_commit.return_value = True + + nextcloud_caldav_obj = self.env["nextcloud.caldav"] + params = self.get_params() + params2 = params.copy() + + nextcloud_caldav_obj.update_nextcloud_events( + params["sync_user_id"], params["nc_events_dict"], **params["params"] + ) + + od_event = self.env["calendar.event"].search_read( + [("id", "=", params2["nc_events_dict"]["create"][0]["od_event"].id)] + ) + + self.assertNextcloudEventCreated(od_event) + + def test_sync_event_create(self): + cal_event = self.create_nextcloud_event() + self.mock_update_nextcloud_events(cal_event) + + def test_sync_event_create_allday(self): + cal_event = self.create_nextclound_event_allday() + self.mock_update_nextcloud_events(cal_event) diff --git a/nextcloud_odoo_sync/tests/vcr_cassettes/nextcloud_connection.yaml b/nextcloud_odoo_sync/tests/vcr_cassettes/nextcloud_connection.yaml new file mode 100644 index 0000000..159262f --- /dev/null +++ b/nextcloud_odoo_sync/tests/vcr_cassettes/nextcloud_connection.yaml @@ -0,0 +1,166 @@ +interactions: +- request: + body: "\r\n" + headers: + Accept: + - text/xml, text/calendar + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '164' + Content-Type: + - text/xml + Depth: + - '0' + User-Agent: + - Mozilla/5.0 + method: PROPFIND + uri: https://next-iscale.onestein.eu/remote.php/dav + response: + body: + string: "\n\n Sabre\\DAV\\Exception\\NotAuthenticated\n + \ No public access to this resource., No 'Authorization: Basic' + header found. Either the client didn't send one, or the server is misconfigured, + No 'Authorization: Bearer' header found. Either the client didn't send one, + or the server is mis-configured, No 'Authorization: Basic' header found. Either + the client didn't send one, or the server is misconfigured\n\n" + headers: + Cache-Control: + - no-store, no-cache, must-revalidate + Connection: + - Keep-Alive + Content-Length: + - '557' + Content-Security-Policy: + - default-src 'none'; + Content-Type: + - application/xml; charset=utf-8 + Date: + - Fri, 04 Aug 2023 09:06:12 GMT + Expires: + - Thu, 19 Nov 1981 08:52:00 GMT + Keep-Alive: + - timeout=5, max=100 + Pragma: + - no-cache + Referrer-Policy: + - no-referrer + Server: + - Apache/2.4.41 (Ubuntu) + Set-Cookie: + - ocesbiiz1k5x=5lu55klvpci65h5hbj65jailal; path=/; secure; HttpOnly; SameSite=Lax + - oc_sessionPassphrase=%2BDG9kDpvoP3oEo0orBXBfwc%2Fe9DM6RDnnHJ24RAbAZnrvxsLGU9TZqZF2feArqqs5kpPOOXv%2B7P8SMjyGduZ6dv11C85rauntaJDdLokNv2%2B4tU4zja8sYPwZTJbtx6T; + path=/; secure; HttpOnly; SameSite=Lax + - ocesbiiz1k5x=delgbfoaqd9so4hg5j3in35sbq; path=/; secure; HttpOnly; SameSite=Lax + - ocesbiiz1k5x=delgbfoaqd9so4hg5j3in35sbq; path=/; secure; HttpOnly; SameSite=Lax + - __Host-nc_sameSiteCookielax=true; path=/; httponly;secure; expires=Fri, 31-Dec-2100 + 23:59:59 GMT; SameSite=lax + - __Host-nc_sameSiteCookiestrict=true; path=/; httponly;secure; expires=Fri, + 31-Dec-2100 23:59:59 GMT; SameSite=strict + - ocesbiiz1k5x=delgbfoaqd9so4hg5j3in35sbq; path=/; secure; HttpOnly; SameSite=Lax + Strict-Transport-Security: + - max-age=15552000; includeSubDomains + WWW-Authenticate: + - Basic realm="Nextcloud", charset="UTF-8" + X-Content-Type-Options: + - nosniff + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Robots-Tag: + - none + X-XSS-Protection: + - 1; mode=block + status: + code: 401 + message: Unauthorized +- request: + body: "\r\n" + headers: + Accept: + - text/xml, text/calendar + Accept-Encoding: + - gzip, deflate + Connection: + - keep-alive + Content-Length: + - '164' + Content-Type: + - text/xml + Cookie: + - __Host-nc_sameSiteCookielax=true; __Host-nc_sameSiteCookiestrict=true; oc_sessionPassphrase=%2BDG9kDpvoP3oEo0orBXBfwc%2Fe9DM6RDnnHJ24RAbAZnrvxsLGU9TZqZF2feArqqs5kpPOOXv%2B7P8SMjyGduZ6dv11C85rauntaJDdLokNv2%2B4tU4zja8sYPwZTJbtx6T; + ocesbiiz1k5x=delgbfoaqd9so4hg5j3in35sbq + Depth: + - '0' + User-Agent: + - Mozilla/5.0 + method: PROPFIND + uri: https://next-iscale.onestein.eu/remote.php/dav + response: + body: + string: !!binary | + H4sIAAAAAAAAA3WQTW+DMAyG7/0VKPdi2BFBqko9VNphO1S7Z+AVquBEdtL154+o0I9JPdmWH/t9 + 7XpzGW12RpbBUaPKvFAbvaq7aow2DBJMiJJNCEnVNWq3/arUXEqj+hB8BSDmm7Ez59zxEUgWwLU3 + wv1Sa13s/hF0Jwgv4QnRkwdG8Y4EU94z/mhgHF3A3PceJkGoYW5MgGfnk98lT7GNzEhhHQV57Xmg + dvDGvlx3IwTShIChE6J9kIHXO+Eue32b3h8On1DmZfZWFNnHeyLmzgJf/cLjpfD0er36AyYNozuh + AQAA + headers: + Cache-Control: + - no-store, no-cache, must-revalidate + Connection: + - Keep-Alive + Content-Encoding: + - gzip + Content-Length: + - '231' + Content-Security-Policy: + - default-src 'none'; + Content-Type: + - application/xml; charset=utf-8 + DAV: + - 1, 3, extended-mkcol, access-control, calendarserver-principal-property-search, + nextcloud-checksum-update, nc-calendar-search, nc-enable-birthday-calendar + Date: + - Fri, 04 Aug 2023 09:06:12 GMT + Expires: + - Thu, 19 Nov 1981 08:52:00 GMT + Keep-Alive: + - timeout=5, max=99 + Pragma: + - no-cache + Referrer-Policy: + - no-referrer + Server: + - Apache/2.4.41 (Ubuntu) + Set-Cookie: + - ocesbiiz1k5x=delgbfoaqd9so4hg5j3in35sbq; path=/; secure; HttpOnly; SameSite=Lax + - ocesbiiz1k5x=4pfkt55sg55qjmv44kvd0voc4u; path=/; secure; HttpOnly; SameSite=Lax + - cookie_test=test; expires=Fri, 04-Aug-2023 10:06:13 GMT; Max-Age=3600 + Strict-Transport-Security: + - max-age=15552000; includeSubDomains + Vary: + - Brief,Prefer + X-Content-Type-Options: + - nosniff + X-Debug-Token: + - qTTftHjcFbouRb5h2l81 + X-Frame-Options: + - SAMEORIGIN + X-Permitted-Cross-Domain-Policies: + - none + X-Request-Id: + - qTTftHjcFbouRb5h2l81 + X-Robots-Tag: + - none + X-XSS-Protection: + - 1; mode=block + status: + code: 207 + message: Multi-Status +version: 1 diff --git a/nextcloud_odoo_sync/views/calendar_event_views.xml b/nextcloud_odoo_sync/views/calendar_event_views.xml new file mode 100644 index 0000000..01497d9 --- /dev/null +++ b/nextcloud_odoo_sync/views/calendar_event_views.xml @@ -0,0 +1,87 @@ + + + + calendar.event.tree.view + calendar.event + + + + + + + + + + + calendar.event.form.view + calendar.event + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + [('nc_to_delete', '=', False)] + + diff --git a/nextcloud_odoo_sync/views/nc_calendar_views.xml b/nextcloud_odoo_sync/views/nc_calendar_views.xml new file mode 100644 index 0000000..78054ea --- /dev/null +++ b/nextcloud_odoo_sync/views/nc_calendar_views.xml @@ -0,0 +1,15 @@ + + + + + nc.calendar.tree.view + nc.calendar + + + + + + + + + diff --git a/nextcloud_odoo_sync/views/nc_sync_error_views.xml b/nextcloud_odoo_sync/views/nc_sync_error_views.xml new file mode 100644 index 0000000..4fc69ff --- /dev/null +++ b/nextcloud_odoo_sync/views/nc_sync_error_views.xml @@ -0,0 +1,27 @@ + + + nc.sync.error.tree.view + nc.sync.error + + + + + + + + + + + + Error List + nc.sync.error + tree + + + + diff --git a/nextcloud_odoo_sync/views/nc_sync_log_views.xml b/nextcloud_odoo_sync/views/nc_sync_log_views.xml new file mode 100644 index 0000000..878ee96 --- /dev/null +++ b/nextcloud_odoo_sync/views/nc_sync_log_views.xml @@ -0,0 +1,65 @@ + + + + + nc.sync.log.tree.view + nc.sync.log + + + + + + + + + + + + + nc.sync.log.form.view + nc.sync.log + +
+
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + + + + + + +
+
+
+ + Sync Activity + nc.sync.log + tree,form + + + +
+
diff --git a/nextcloud_odoo_sync/views/nc_sync_user_views.xml b/nextcloud_odoo_sync/views/nc_sync_user_views.xml new file mode 100644 index 0000000..7b69d84 --- /dev/null +++ b/nextcloud_odoo_sync/views/nc_sync_user_views.xml @@ -0,0 +1,91 @@ + + + nc.sync.user.tree.view + nc.sync.user + + + + + + + + + + + + nc.sync.user.form.view + nc.sync.user + +
+
+
+ +
+
+
+

+ +

+
+ + + + + + + + + + + + + + + + + + +
+
+
+
+
+
+ + + User Setup + nc.sync.user + tree,form + {'no_footer':1} + + + + + +
diff --git a/nextcloud_odoo_sync/views/res_config_settings_views.xml b/nextcloud_odoo_sync/views/res_config_settings_views.xml new file mode 100644 index 0000000..9579cd7 --- /dev/null +++ b/nextcloud_odoo_sync/views/res_config_settings_views.xml @@ -0,0 +1,73 @@ + + + + res.config.settings.nextcloud.view.form + res.config.settings + + + +
+
+
+

Recurring Event Limits And Log Capacity

+
+
+
+
+ Default Recurring Event Limits +
+ Default Limits Applied To Recurring Events(With End Date As 'Forever') Being Created From/To be Synced to Nextcloud. + Define In Years The Limit For Each Of Frequency For Which The Events Should Be Created. +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ days +
+
+
+
+
+
+ + + + + + Settings + ir.actions.act_window + res.config.settings + form + inline + {'module' : 'nextcloud_odoo_sync', 'bin_size': False} + + + + diff --git a/nextcloud_odoo_sync/views/res_users_views.xml b/nextcloud_odoo_sync/views/res_users_views.xml new file mode 100644 index 0000000..931a818 --- /dev/null +++ b/nextcloud_odoo_sync/views/res_users_views.xml @@ -0,0 +1,17 @@ + + + view.users.form.simple.modif.inherit + res.users + + + + +