passerelle/passerelle/apps/caldav/models.py

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,
)