306 lines
10 KiB
Python
306 lines
10 KiB
Python
# 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,
|
|
)
|