caldav: add event creation/update/deletion (#87227)
gitea/passerelle/pipeline/head This commit looks good
Details
gitea/passerelle/pipeline/head This commit looks good
Details
This commit is contained in:
parent
5f86102711
commit
665c16bca2
|
@ -16,6 +16,7 @@ Architecture: all
|
||||||
Depends: ghostscript,
|
Depends: ghostscript,
|
||||||
pdftk,
|
pdftk,
|
||||||
poppler-utils,
|
poppler-utils,
|
||||||
|
python3-caldav,
|
||||||
python3-cmislib,
|
python3-cmislib,
|
||||||
python3-cryptography,
|
python3-cryptography,
|
||||||
python3-dateutil,
|
python3-dateutil,
|
||||||
|
|
|
@ -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',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
]
|
|
@ -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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
|
@ -143,6 +143,7 @@ INSTALLED_APPS = (
|
||||||
'passerelle.apps.base_adresse',
|
'passerelle.apps.base_adresse',
|
||||||
'passerelle.apps.bbb',
|
'passerelle.apps.bbb',
|
||||||
'passerelle.apps.bdp',
|
'passerelle.apps.bdp',
|
||||||
|
'passerelle.apps.caldav',
|
||||||
'passerelle.apps.cartads_cs',
|
'passerelle.apps.cartads_cs',
|
||||||
'passerelle.apps.choosit',
|
'passerelle.apps.choosit',
|
||||||
'passerelle.apps.cityweb',
|
'passerelle.apps.cityweb',
|
||||||
|
|
1
setup.py
1
setup.py
|
@ -138,6 +138,7 @@ setup(
|
||||||
scripts=['manage.py'],
|
scripts=['manage.py'],
|
||||||
include_package_data=True,
|
include_package_data=True,
|
||||||
install_requires=[
|
install_requires=[
|
||||||
|
'caldav',
|
||||||
'django >= 3.2, <3.3',
|
'django >= 3.2, <3.3',
|
||||||
'django-model-utils<4.3',
|
'django-model-utils<4.3',
|
||||||
'requests',
|
'requests',
|
||||||
|
|
|
@ -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 = '''<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<D:multistatus xmlns:D="DAV:">
|
||||||
|
<D:response xmlns:ns0="urn:uuid:02481234-abcd-4321-abcd-00aa00bb00cc/" xmlns:ns2="urn:ietf:params:xml:ns:carddav">
|
||||||
|
<D:href>/somedave/</D:href>
|
||||||
|
<D:propstat>
|
||||||
|
<D:prop>
|
||||||
|
<D:displayname>Test Caldav</D:displayname>
|
||||||
|
<D:owner/>
|
||||||
|
<D:resourcetype><D:collection /></D:resourcetype>
|
||||||
|
<D:getcontenttype>httpd/unix-directory</D:getcontenttype>
|
||||||
|
<D:current-user-principal><D:href>/somedav/principals/users/apiaccess/</D:href></D:current-user-principal>
|
||||||
|
<D:principal-collection-set><D:href>/somedav/principals/</D:href></D:principal-collection-set>
|
||||||
|
<D:getetag>"none"</D:getetag>
|
||||||
|
<D:getcontentlength/>
|
||||||
|
<D:getlastmodified/>
|
||||||
|
<ns2:addressbook-home-set><D:href>/somedav/apiaccess/</D:href></ns2:addressbook-home-set>
|
||||||
|
<ns2:principal-address/>
|
||||||
|
<ns2:directory-gateway><D:href>/somedav/addressbook/</D:href></ns2:directory-gateway>
|
||||||
|
</D:prop>
|
||||||
|
<D:status>HTTP/1.1 200 OK</D:status>
|
||||||
|
</D:propstat>
|
||||||
|
</D:response>
|
||||||
|
</D:multistatus>
|
||||||
|
'''
|
||||||
|
|
||||||
|
|
||||||
|
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'
|
Loading…
Reference in New Issue