diff --git a/debian/control b/debian/control
index a3f92e14..7d76d007 100644
--- a/debian/control
+++ b/debian/control
@@ -16,6 +16,7 @@ Architecture: all
Depends: ghostscript,
pdftk,
poppler-utils,
+ python3-caldav,
python3-cmislib,
python3-cryptography,
python3-dateutil,
diff --git a/passerelle/apps/caldav/__init__.py b/passerelle/apps/caldav/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/caldav/migrations/0001_caldav_connector.py b/passerelle/apps/caldav/migrations/0001_caldav_connector.py
new file mode 100644
index 00000000..68ced5d9
--- /dev/null
+++ b/passerelle/apps/caldav/migrations/0001_caldav_connector.py
@@ -0,0 +1,47 @@
+# Generated by Django 3.2.18 on 2024-02-20 15:41
+
+from django.db import migrations, models
+
+
+class Migration(migrations.Migration):
+ initial = True
+
+ dependencies = [
+ ('base', '0030_resourcelog_base_resour_appname_298cbc_idx'),
+ ]
+
+ operations = [
+ migrations.CreateModel(
+ name='CalDAV',
+ fields=[
+ (
+ 'id',
+ models.AutoField(auto_created=True, primary_key=True, serialize=False, verbose_name='ID'),
+ ),
+ ('title', models.CharField(max_length=50, verbose_name='Title')),
+ ('slug', models.SlugField(unique=True, verbose_name='Identifier')),
+ ('description', models.TextField(verbose_name='Description')),
+ (
+ 'dav_url',
+ models.URLField(
+ help_text='DAV root URL (such as https://test.egw/groupdav.php/)',
+ verbose_name='DAV root URL',
+ ),
+ ),
+ ('dav_login', models.CharField(max_length=128, verbose_name='DAV username')),
+ ('dav_password', models.CharField(max_length=512, verbose_name='DAV password')),
+ (
+ 'users',
+ models.ManyToManyField(
+ blank=True,
+ related_name='_caldav_caldav_users_+',
+ related_query_name='+',
+ to='base.ApiUser',
+ ),
+ ),
+ ],
+ options={
+ 'verbose_name': 'CalDAV',
+ },
+ ),
+ ]
diff --git a/passerelle/apps/caldav/migrations/__init__.py b/passerelle/apps/caldav/migrations/__init__.py
new file mode 100644
index 00000000..e69de29b
diff --git a/passerelle/apps/caldav/models.py b/passerelle/apps/caldav/models.py
new file mode 100644
index 00000000..9c3f7a72
--- /dev/null
+++ b/passerelle/apps/caldav/models.py
@@ -0,0 +1,305 @@
+# passerelle - uniform access to multiple data sources and services
+# Copyright (C) 2024 Entr'ouvert
+#
+# This program is free software: you can redistribute it and/or modify it
+# under the terms of the GNU Affero General Public License as published
+# by the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU Affero General Public License for more details.
+#
+# You should have received a copy of the GNU Affero General Public License
+# along with this program. If not, see .
+
+import functools
+import urllib.parse
+
+import caldav
+import requests
+from django.db import models
+from django.utils.dateparse import parse_date, parse_datetime
+from django.utils.translation import gettext_lazy as _
+
+from passerelle.base.models import BaseResource
+from passerelle.utils.api import endpoint
+from passerelle.utils.conversion import exception_to_text
+from passerelle.utils.jsonresponse import APIError
+
+EVENT_SCHEMA_PART = {
+ 'type': 'object',
+ 'description': _('Ical event properties ( VEVENT RFC 5545 3.6.1 )'),
+ 'required': ['DTSTART', 'DTEND'],
+ 'properties': {
+ 'DTSTART': {
+ 'type': 'string',
+ 'description': _('Event start (included) ISO-8601 date-time or date (for allday event)'),
+ },
+ 'DTEND': {
+ 'type': 'string',
+ 'description': _('Event end (excluded) ISO-8601 date-time or date (for allday event)'),
+ },
+ 'SUMMARY': {
+ 'type': 'string',
+ 'description': 'RFC 5545 3.8.1.12',
+ },
+ 'DESCRIPTION': {
+ 'type': 'string',
+ 'description': 'RFC 5545 3.8.2.5',
+ },
+ 'LOCATION': {
+ 'type': 'string',
+ 'description': 'RFC 5545 3.8.1.7',
+ },
+ 'TRANSP': {
+ 'type': 'boolean',
+ 'description': 'Transparent if true else opaque (RFC 5545 3.8.2.7)',
+ },
+ 'RRULE': {
+ 'description': _('Recurrence rule (RFC 5545 3.8.5.3)'),
+ 'type': 'object',
+ 'properties': {
+ 'FREQ': {
+ 'type': 'string',
+ 'enum': ['WEEKLY', 'MONTHLY', 'YEARLY'],
+ },
+ 'BYDAY': {
+ 'type': 'array',
+ 'items': {
+ 'type': 'string',
+ 'enum': ['MO', 'TU', 'WE', 'TH', 'FR', 'SA', 'SU'],
+ },
+ },
+ 'BYMONTH': {
+ 'type': 'array',
+ 'items': {
+ 'type': 'integer',
+ 'minimum': 1,
+ 'maximum': 12,
+ },
+ },
+ 'COUNT': {
+ 'type': 'integer',
+ 'minimum': 1,
+ },
+ },
+ },
+ },
+}
+
+
+USERNAME_PARAM = {
+ 'description': _('The calendar\'s owner username'),
+ 'type': 'string',
+}
+
+EVENT_UID_PARAM = {
+ 'description': _('An event UID'),
+ 'type': 'string',
+}
+
+
+# Action's request body schema
+EVENT_SCHEMA = {
+ '$schema': 'http://json-schema.org/draft-04/schema#',
+ 'title': _('Event description schema'),
+ 'unflatten': True,
+ **EVENT_SCHEMA_PART,
+}
+
+
+class CalDAV(BaseResource):
+ dav_url = models.URLField(
+ blank=False,
+ verbose_name=_('DAV root URL'),
+ help_text=_('DAV root URL (such as https://test.egw/groupdav.php/)'),
+ )
+ dav_login = models.CharField(max_length=128, verbose_name=_('DAV username'), blank=False)
+ dav_password = models.CharField(max_length=512, verbose_name=_('DAV password'), blank=False)
+
+ category = _('Misc')
+
+ class Meta:
+ verbose_name = _('CalDAV')
+
+ @functools.cached_property
+ def dav_client(self):
+ '''Instanciate a caldav.DAVCLient and return the instance'''
+ return caldav.DAVClient(self.dav_url, username=self.dav_login, password=self.dav_password)
+
+ def check_status(self):
+ '''Attempt a propfind on DAV root URL'''
+ try:
+ rep = self.dav_client.propfind()
+ rep.find_objects_and_props()
+ except caldav.lib.error.AuthorizationError:
+ raise Exception(_('Not authorized: bad login/password ?'))
+
+ @endpoint(
+ name='event',
+ pattern='^create$',
+ example_pattern='create',
+ methods=['post'],
+ post={'request_body': {'schema': {'application/json': EVENT_SCHEMA}}},
+ parameters={
+ 'username': USERNAME_PARAM,
+ },
+ )
+ def create_event(self, request, username, post_data):
+ '''Event creation endpoint'''
+ cal = self.get_calendar(username)
+ self._process_event_properties(post_data)
+
+ try:
+ evt = cal.save_event(**post_data)
+ except requests.exceptions.RequestException as expt:
+ raise APIError(
+ _('Error sending creation request to caldav server'),
+ data={
+ 'expt_class': str(type(expt)),
+ 'expt': str(expt),
+ 'username': username,
+ },
+ )
+ return {'data': {'event_id': evt.id}}
+
+ # Patch do not support request_body validation, using post instead
+ @endpoint(
+ name='event',
+ pattern='^update$',
+ example_pattern='update',
+ methods=['post'],
+ post={'request_body': {'schema': {'application/json': EVENT_SCHEMA}}},
+ parameters={
+ 'username': USERNAME_PARAM,
+ 'event_id': EVENT_UID_PARAM,
+ },
+ )
+ def update_event(self, request, username, event_id, post_data):
+ '''Event update endpoint'''
+ self._process_event_properties(post_data)
+ ical = self.get_event(username, event_id)
+
+ vevent = ical.icalendar_instance.walk('VEVENT')
+ if not len(vevent) == 1:
+ raise APIError(
+ _('Given event (user:%r uid:%r) do not contains VEVENT component') % (username, event_id),
+ data={
+ 'username': username,
+ 'event_uid': event_id,
+ 'VEVENT': str(vevent),
+ },
+ )
+ vevent = vevent[0]
+ # vevent.update(post_data) do not convert values as expected
+ for k, v in post_data.items():
+ vevent[k] = v
+ try:
+ # do not use ical.save(no_create=True) : no_create fails on some calDAV
+ ical.save()
+ except requests.exceptions.RequestException as expt:
+ raise APIError(
+ _('Error sending update request to caldav server'),
+ data={
+ 'expt_class': str(type(expt)),
+ 'expt': str(expt),
+ 'username': username,
+ 'event_id': event_id,
+ },
+ )
+ return {'data': {'event_id': ical.id}}
+
+ @endpoint(
+ name='event',
+ pattern='^delete$',
+ example_pattern='delete',
+ methods=['delete'],
+ parameters={
+ 'username': USERNAME_PARAM,
+ 'event_id': EVENT_UID_PARAM,
+ },
+ )
+ def delete_event(self, request, username, event_id):
+ ical = self.get_event(username, event_id)
+ try:
+ ical.delete()
+ except requests.exceptions.RequestException as expt:
+ raise APIError(
+ _('Error sending deletion request to caldav server'),
+ data={
+ 'expt_class': str(type(expt)),
+ 'expt': str(expt),
+ 'username': username,
+ 'event_id': event_id,
+ },
+ )
+ return {}
+
+ def get_event(self, username, event_uid):
+ '''Fetch an event given a username and an event_uid
+ Arguments:
+ - username: Calendar owner's username
+ - event_uid: The event's UID
+
+ Returns an caldav.Event instance
+ '''
+ event_path = '%s/calendar/%s.ics' % (urllib.parse.quote(username), urllib.parse.quote(str(event_uid)))
+ cal = self.get_calendar(username)
+ try:
+ ical = cal.event_by_url(event_path)
+ except caldav.lib.error.DAVError as expt:
+ raise APIError(
+ _('Unable to get event %r in calendar owned by %r') % (event_uid, username),
+ data={
+ 'expt': exception_to_text(expt),
+ 'expt_cls': str(type(expt)),
+ 'username': username,
+ 'event_uid': event_uid,
+ },
+ )
+ except requests.exceptions.RequestException as expt:
+ raise APIError(
+ _('Unable to communicate with caldav server while fetching event'),
+ data={
+ 'expt': exception_to_text(expt),
+ 'expt_class': str(type(expt)),
+ 'username': username,
+ 'event_uid': event_uid,
+ },
+ )
+ return ical
+
+ def get_calendar(self, username):
+ '''Given a username returns the associated calendar set
+ Arguments:
+ - username: Calendar owner's username
+
+ Returns A caldav.Calendar instance
+ Note: do not raise any caldav exception before a method trying to make
+ a request is called
+ '''
+ path = '%s/calendar' % urllib.parse.quote(username)
+ calendar = caldav.Calendar(client=self.dav_client, url=path)
+ return calendar
+
+ def _process_event_properties(self, data):
+ '''Handles verification & convertion of event properties
+ @note Modify given data dict inplace
+ '''
+ if 'TRANSP' in data:
+ data['TRANSP'] = 'TRANSPARENT' if data['TRANSP'] else 'OPAQUE'
+
+ for dt_field in ('DTSTART', 'DTEND'):
+ value = data[dt_field]
+ try:
+ data[dt_field] = parse_date(value) or parse_datetime(value)
+ except ValueError:
+ data[dt_field] = None
+
+ if not data[dt_field]:
+ raise APIError(
+ _('Unable to convert field %s=%r: not a valid date nor date-time') % (dt_field, value),
+ http_status=400,
+ )
diff --git a/passerelle/settings.py b/passerelle/settings.py
index 3bd8835a..655f3c55 100644
--- a/passerelle/settings.py
+++ b/passerelle/settings.py
@@ -143,6 +143,7 @@ INSTALLED_APPS = (
'passerelle.apps.base_adresse',
'passerelle.apps.bbb',
'passerelle.apps.bdp',
+ 'passerelle.apps.caldav',
'passerelle.apps.cartads_cs',
'passerelle.apps.choosit',
'passerelle.apps.cityweb',
diff --git a/setup.py b/setup.py
index 91ff88db..ea2b417c 100755
--- a/setup.py
+++ b/setup.py
@@ -138,6 +138,7 @@ setup(
scripts=['manage.py'],
include_package_data=True,
install_requires=[
+ 'caldav',
'django >= 3.2, <3.3',
'django-model-utils<4.3',
'requests',
diff --git a/tests/test_caldav.py b/tests/test_caldav.py
new file mode 100644
index 00000000..6565366f
--- /dev/null
+++ b/tests/test_caldav.py
@@ -0,0 +1,438 @@
+import datetime
+import urllib
+from unittest.mock import Mock, patch
+
+import caldav
+import icalendar
+import pytest
+import responses
+from django.contrib.contenttypes.models import ContentType
+from requests.exceptions import ConnectionError, ConnectTimeout, ReadTimeout
+
+import tests.utils
+from passerelle.apps.caldav.models import CalDAV
+from passerelle.base.models import AccessRight, ApiUser
+
+DAV_URL = 'http://test.caldav.notld/somedav/'
+EVENT_PATH_FMT = '%(username)s/calendar/%(uid)s.ics'
+
+PROPFIND_REPLY = '''
+
+
+ /somedave/
+
+
+ Test Caldav
+
+
+ httpd/unix-directory
+ /somedav/principals/users/apiaccess/
+ /somedav/principals/
+ "none"
+
+
+ /somedav/apiaccess/
+
+ /somedav/addressbook/
+
+ HTTP/1.1 200 OK
+
+
+
+'''
+
+
+SOME_EVENTS = [
+ {'DTSTART': '2020-02-20T20:02', 'DTEND': '2020-02-22T22:43', 'SUMMARY': 'Test'},
+ {
+ 'DTSTART': '2020-02-20',
+ 'DTEND': '2020-02-22',
+ 'SUMMARY': 'Test',
+ 'RRULE': {'FREQ': 'MONTHLY', 'BYDAY': ['FR', 'MO']},
+ },
+ {
+ 'DTSTART': '2020-02-20',
+ 'DTEND': '2020-02-22',
+ 'SUMMARY': 'Test',
+ 'LOCATION': 'Foo bar',
+ 'TRANSP': True,
+ 'DESCRIPTION': 'test',
+ },
+]
+
+
+@pytest.fixture()
+def caldav_conn(db):
+ user = ApiUser.objects.create(username='all', keytype='', key='')
+ cdav = CalDAV.objects.create(dav_url=DAV_URL, slug='test', dav_login='foo', dav_password='hackmeplz')
+ content_type = ContentType.objects.get_for_model(cdav)
+ AccessRight.objects.create(
+ codename='can_access', apiuser=user, resource_type=content_type, resource_pk=cdav.pk
+ )
+ return cdav
+
+
+def get_fake_event(caldav_conn):
+ vevent = icalendar.Event()
+ for k, v in {
+ 'DTSTART': datetime.date(2023, 1, 1),
+ 'DTEND': datetime.date(2023, 2, 1),
+ 'SUMMARY': 'Test',
+ 'UID': '12345678-abcd',
+ }.items():
+ vevent.add(k, v)
+ ical = icalendar.Calendar()
+ ical.add_component(vevent)
+
+ event = caldav.Event(client=caldav_conn.dav_client, data=ical)
+ event.url = caldav.lib.url.URL(DAV_URL + 'foo/calendar/12345678-abcd.ics')
+ calendar = caldav.Calendar(client=caldav_conn.dav_client)
+ calendar.url = caldav.lib.url.URL(DAV_URL + 'foo/calendar')
+ event.parent = calendar
+ return event
+
+
+def get_calendar_mock(event_path, event_uid):
+ '''Return a mock configured to be usable as a Calendar instance in
+ the caldav connector
+ '''
+ event_url = DAV_URL + event_path
+ url = caldav.lib.url.URL(event_url)
+ evt_conf = {'url': url, 'id': event_uid}
+ mock_event = Mock(**evt_conf)
+
+ ical = icalendar.Calendar()
+ event = icalendar.Event()
+
+ conf = {
+ 'url': DAV_URL,
+ 'id': '1337',
+ 'props': {
+ 'dtstart': datetime.date.fromisoformat('2023-02-02'),
+ 'dtend': datetime.date.fromisoformat('2023-02-28'),
+ 'summary': 'test',
+ },
+ }
+
+ for k, v in conf.items():
+ event.add(k, v)
+ ical.add_component(event)
+
+ get_evt_conf = {
+ 'url': url,
+ 'id': event_uid,
+ 'save': Mock(),
+ 'delete': Mock(),
+ 'icalendar_instance': ical,
+ }
+ mock_get_event = Mock(**get_evt_conf)
+ cal_conf = {'save_event.return_value': mock_event, 'event_by_url.return_value': mock_get_event}
+ return Mock(**cal_conf)
+
+
+def qs_url(url, params):
+ '''Append query string to URL'''
+ return url + '?' + urllib.parse.urlencode(params)
+
+
+def app_post(app, url, params, data, status=None):
+ '''Allows to post data on an URL with query string params and JSON data'''
+ return app.post_json(qs_url(url, params), params=data, status=status)
+
+
+def get_event_path(caldav_conn, username, uid):
+ return EVENT_PATH_FMT % {
+ 'username': urllib.parse.quote(username),
+ 'uid': urllib.parse.quote(str(uid)),
+ }
+
+
+#
+# Tests
+#
+
+
+@responses.activate
+def test_caldav_check_status_ok(app, caldav_conn):
+ responses.add('PROPFIND', DAV_URL, headers={'Content-Type': 'text/xml'}, body=PROPFIND_REPLY)
+ caldav_conn.check_status()
+ assert len(responses.calls) == 1
+ assert responses.calls[0].request.method == 'PROPFIND'
+ assert responses.calls[0].request.url == DAV_URL
+
+
+@responses.activate
+def test_caldav_check_status_login_failed(app, caldav_conn):
+ responses.add(
+ 'PROPFIND',
+ DAV_URL,
+ headers={'Content-Type': 'text/xml'},
+ body=PROPFIND_REPLY,
+ status=401,
+ )
+ with pytest.raises(Exception) as expt:
+ caldav_conn.check_status()
+ assert str(expt) == 'Not authorized: bad login/password ?'
+ assert len(responses.calls) == 1
+ assert responses.calls[0].request.method == 'PROPFIND'
+ assert responses.calls[0].request.url == DAV_URL
+
+
+@responses.activate
+def test_caldav_check_status_fails(app, caldav_conn):
+ responses.add('PROPFIND', DAV_URL, status=500)
+ with pytest.raises(Exception):
+ caldav_conn.check_status()
+ assert len(responses.calls) == 1
+ assert responses.calls[0].request.method == 'PROPFIND'
+ assert responses.calls[0].request.url == DAV_URL
+
+
+@pytest.mark.parametrize('requests_expt', (ConnectTimeout, ReadTimeout, ConnectionError))
+@responses.activate
+def test_caldav_check_status_timeout(app, caldav_conn, requests_expt):
+ propfind_mock = responses.add( # pylint: disable=assignment-from-none
+ 'PROPFIND', DAV_URL, body=requests_expt('Do not work as expected')
+ )
+ get_mock = responses.add( # pylint: disable=assignment-from-none
+ 'GET', DAV_URL, body=requests_expt('Do not work as expected')
+ )
+ with pytest.raises(Exception):
+ caldav_conn.check_status()
+ assert len(responses.calls) == 2
+ assert len(propfind_mock.calls) == 1
+ assert len(get_mock.calls) == 1
+
+
+@responses.activate
+def test_caldav_event_create_allday_ok(app, caldav_conn):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/create')
+ username = 'foo'
+ evt_id = 42
+ evt_path = get_event_path(caldav_conn, username, evt_id)
+ cal_mock = get_calendar_mock(evt_path, evt_id)
+
+ event = {'DTSTART': '2020-02-20', 'DTEND': '2020-03-30', 'SUMMARY': 'foobar'}
+ qs_params = {'username': username}
+
+ with patch('caldav.Calendar', return_value=cal_mock):
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] == 0
+ assert resp.json['data']['event_id'] == evt_id
+
+ event_conv = {
+ k: v if k not in ('DTSTART', 'DTEND') else datetime.date.fromisoformat(v)
+ for k, v in event.items()
+ }
+ cal_mock.save_event.assert_called_once_with(**event_conv)
+
+
+@pytest.mark.parametrize(
+ 'event',
+ SOME_EVENTS,
+)
+@responses.activate
+def test_caldav_event_create_ok(app, caldav_conn, event):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/create')
+ username = 'foo'
+ evt_id = '12345678-abcd'
+ evt_path = get_event_path(caldav_conn, username, evt_id)
+ cal_mock = get_calendar_mock(evt_path, evt_id)
+
+ qs_params = {'username': username}
+
+ with patch('caldav.Calendar', return_value=cal_mock):
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] == 0
+ assert resp.json['data']['event_id'] == evt_id
+
+ save_args = event.copy()
+ caldav_conn._process_event_properties(save_args)
+ cal_mock.save_event.assert_called_once_with(**save_args)
+
+
+@pytest.mark.parametrize(
+ 'event',
+ SOME_EVENTS,
+)
+@responses.activate
+def test_caldav_event_create_ok_nomock(app, caldav_conn, event):
+ '''Testing that caldav is able to create the event instance before sending it'''
+ url = tests.utils.generic_endpoint_url('caldav', 'event/create')
+ username = 'toto'
+ qs_params = {'username': username}
+
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] != 0
+ assert resp.json['err_desc'] == 'Error sending creation request to caldav server'
+
+
+@pytest.mark.parametrize(
+ 'event',
+ SOME_EVENTS,
+)
+@responses.activate
+def test_caldav_event_create_ok_net_err(app, caldav_conn, event):
+ with patch('passerelle.apps.caldav.models.CalDAV.get_event', return_value=get_fake_event(caldav_conn)):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/create')
+ username = 'toto'
+ qs_params = {'username': username}
+
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] != 0
+ assert resp.json['err_desc'] == 'Error sending creation request to caldav server'
+
+
+@pytest.mark.parametrize(
+ 'dtstart,dtend',
+ [
+ ('2020-13-13', '2021-01-01'),
+ ('2020-01-01T01:02:03', '2020-01-01T02:64:02'),
+ ('23-01-12', '2023-01-13'),
+ ('13:30', '2023-01-01T14:00'),
+ ],
+)
+def test_caldav_event_create_bad_dates(app, caldav_conn, dtstart, dtend):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/create')
+ username = 'toto'
+ event = {'DTSTART': dtstart, 'DTEND': dtend, 'SUMMARY': 'hello'}
+ qs_params = {'username': username}
+
+ resp = app_post(app, url, qs_params, event, status=400)
+
+ assert resp.status_code == 400
+ assert resp.json['err'] != 0
+
+
+@pytest.mark.parametrize('transp', [True, False])
+@responses.activate
+def test_caldav_event_update_ok(app, transp, caldav_conn):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/update')
+ username = 'toto'
+ evt_id = '01234567-abcd'
+ evt_path = get_event_path(caldav_conn, username, evt_id)
+ cal_mock = get_calendar_mock(evt_path, evt_id)
+
+ event = {'DTSTART': '2020-02-20', 'DTEND': '2020-03-30', 'SUMMARY': 'foobar', 'TRANSP': transp}
+ qs_params = {'username': username, 'event_id': evt_id}
+
+ with patch('caldav.Calendar', return_value=cal_mock):
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] == 0
+ assert resp.json['data']['event_id'] == evt_id
+
+ cal_mock.event_by_url.assert_called_once_with(evt_path)
+ cal_mock.event_by_url().save.assert_called_once_with()
+
+ ical = cal_mock.event_by_url().icalendar_instance
+ evt = ical.walk('VEVENT')[0]
+ for k, v in event.items():
+ if k == 'TRANSP':
+ if v:
+ assert str(evt[k]) == 'TRANSPARENT'
+ else:
+ assert str(evt[k]) == 'OPAQUE'
+ else:
+ assert str(evt[k]) == v
+
+
+@responses.activate
+def test_caldav_event_update_notfound(app, caldav_conn):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/update')
+ username = 'foo-user'
+ evt_id = '42'
+ evt_path = get_event_path(caldav_conn, username, evt_id)
+ cal_mock = get_calendar_mock(evt_path, evt_id)
+
+ cal_mock.event_by_url().icalendar_instance = icalendar.Calendar() # empty evt
+
+ event = {'DTSTART': '2020-02-20', 'DTEND': '2020-03-30', 'SUMMARY': 'foobar'}
+ qs_params = {'username': username, 'event_id': evt_id}
+
+ with patch('caldav.Calendar', return_value=cal_mock):
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] == 1
+ assert resp.json['err_desc'] == 'Given event (user:%r uid:%r) do not contains VEVENT component' % (
+ username,
+ evt_id,
+ )
+
+
+@responses.activate
+def test_caldav_event_update_bad_evt(app, caldav_conn):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/update')
+
+ username = 'toto'
+ evt_id = '012345678'
+ evt_path = get_event_path(caldav_conn, username, evt_id)
+ cal_mock = get_calendar_mock(evt_path, evt_id)
+
+ cal_mock.event_by_url.side_effect = caldav.lib.error.NotFoundError('NotFoundError at \'404 Not Found')
+
+ event = {'DTSTART': '2020-02-20', 'DTEND': '2020-03-30', 'SUMMARY': 'foobar'}
+ qs_params = {'username': username, 'event_id': evt_id}
+ with patch('caldav.Calendar', return_value=cal_mock):
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] == 1
+
+ cal_mock.event_by_url.assert_called_once_with(evt_path)
+ assert cal_mock.event_by_url.save.call_count == 0
+
+
+@responses.activate
+def test_caldav_event_update_net_err(app, caldav_conn):
+ with patch('passerelle.apps.caldav.models.CalDAV.get_event', return_value=get_fake_event(caldav_conn)):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/update')
+ username = 'toto'
+ evt_id = '012345678'
+ event = {'DTSTART': '2020-02-20', 'DTEND': '2020-03-30', 'SUMMARY': 'foobar'}
+ qs_params = {'username': username, 'event_id': evt_id}
+ resp = app_post(app, url, qs_params, event)
+ assert resp.json['err'] == 1
+ assert resp.json['err_desc'] == 'Error sending update request to caldav server'
+
+
+@responses.activate
+def test_caldav_event_delete_ok(app, caldav_conn):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/delete')
+
+ username = 'toto'
+ evt_id = '012345678-dcba'
+ evt_path = get_event_path(caldav_conn, username, evt_id)
+ cal_mock = get_calendar_mock(evt_path, evt_id)
+
+ qs_params = {'username': username, 'event_id': evt_id}
+
+ with patch('caldav.Calendar', return_value=cal_mock):
+ resp = app.delete(qs_url(url, qs_params))
+ assert resp.json['err'] == 0
+
+ cal_mock.event_by_url.assert_called_once_with(evt_path)
+ cal_mock.event_by_url().delete.assert_called_once_with()
+
+
+@responses.activate
+def test_caldav_event_delete_net_err(app, caldav_conn):
+ with patch('passerelle.apps.caldav.models.CalDAV.get_event', return_value=get_fake_event(caldav_conn)):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/delete')
+
+ username = 'toto'
+ evt_id = '012345678-dcba'
+
+ qs_params = {'username': username, 'event_id': evt_id}
+ resp = app.delete(qs_url(url, qs_params))
+ assert resp.json['err'] != 0
+ assert resp.json['err_desc'] == 'Error sending deletion request to caldav server'
+
+
+@responses.activate
+def test_caldav_event_delete_get_event_fail(app, caldav_conn):
+ url = tests.utils.generic_endpoint_url('caldav', 'event/delete')
+
+ username = 'toto'
+ evt_id = '012345678-dcba'
+
+ qs_params = {'username': username, 'event_id': evt_id}
+ resp = app.delete(qs_url(url, qs_params))
+ assert resp.json['err'] != 0
+ assert resp.json['err_desc'] == 'Unable to communicate with caldav server while fetching event'