feat: Aufgaben (Tasks) mit CalDAV VTODO-Sync

Neuer Menuepunkt "Aufgaben" unterhalb Kontakte.

Backend:
- TaskList + Task + TaskListShare Models
- REST-API: CRUD, Teilen, my-color, Import/Export (.ics mit VTODO, CSV)
- CalDAV: Task-Listen tauchen als Calendar-Collection mit
  supported-calendar-component-set=VTODO im autodiscovery auf
- PROPFIND/REPORT/GET/PUT/DELETE/PROPPATCH/MKCOL fuer /dav/<user>/tl-<id>/
- SSE-Notifications bei Aenderungen

Frontend:
- TasksView mit Listen-Sidebar, Suche, "Erledigte ausblenden"
- Mehrfachauswahl + Bulk-Loeschen, Status-Toggle per Checkbox
- Editor mit Titel/Beschreibung/Faellig/Prioritaet/Status/Fortschritt
- Teilen, Farbe persoenlich anpassen, Import/Export

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Stefan Hacker
2026-04-14 15:07:06 +02:00
parent 2ce088e96b
commit ba3e619963
10 changed files with 1727 additions and 5 deletions
+31 -4
View File
@@ -284,11 +284,11 @@ def propfind(subpath=''):
multistatus.append(_principal_response(user))
return _xml_response(multistatus)
# /dav/<username>/calendars/ : only calendar collections
# /dav/<username>/calendars/ : Kalender + Aufgabenlisten (DAVx5 erkennt
# VTODO-Listen automatisch an supported-calendar-component-set).
if len(parts) == 2 and parts[1] == 'calendars':
if parts[0] != user.username:
return Response('', 403)
# A plain collection container
container = ET.Element(_qn('d', 'response'))
ET.SubElement(container, _qn('d', 'href')).text = f'/dav/{user.username}/calendars/'
propstat = ET.SubElement(container, _qn('d', 'propstat'))
@@ -301,6 +301,9 @@ def propfind(subpath=''):
if depth != '0':
for cal in _user_calendars(user):
multistatus.append(_calendar_response(user, cal))
from .taskdav import user_lists, list_response
for tl in user_lists(user):
multistatus.append(list_response(user, tl))
return _xml_response(multistatus)
# /dav/<username>/addressbooks/ : only addressbook collections
@@ -322,10 +325,13 @@ def propfind(subpath=''):
multistatus.append(_addressbook_response(user, ab))
return _xml_response(multistatus)
# /dav/<username>/cal-<id>/ : calendar + events
# /dav/<username>/cal-<id>/ : calendar + events (auch tl-N delegieren)
if len(parts) == 2:
if parts[0] != user.username:
return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_propfind
return tl_propfind(username=parts[0], tl_part=parts[1])
cal_id = _parse_calendar_path(parts[1])
if cal_id is None:
return Response('Not found', 404)
@@ -338,10 +344,13 @@ def propfind(subpath=''):
multistatus.append(_event_response(user, cal, ev))
return _xml_response(multistatus)
# /dav/<username>/cal-<id>/<uid>.ics : single event
# /dav/<username>/cal-<id>/<uid>.ics : single event (tl-N delegieren)
if len(parts) == 3:
if parts[0] != user.username:
return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_task_propfind
return tl_task_propfind(username=parts[0], tl_part=parts[1], filename=parts[2])
cal_id = _parse_calendar_path(parts[1])
cal = _calendar_for(user, cal_id) if cal_id else None
if not cal:
@@ -367,6 +376,9 @@ def report(subpath):
parts = [p for p in subpath.split('/') if p]
if len(parts) < 2 or parts[0] != user.username:
return Response('', 403)
if parts[1].startswith('tl-'):
from .taskdav import tl_report
return tl_report(username=parts[0], tl_part=parts[1])
cal_id = _parse_calendar_path(parts[1])
cal = _calendar_for(user, cal_id) if cal_id else None
if not cal:
@@ -449,6 +461,9 @@ def get_event(username, cal_part, filename):
if cal_part.startswith('ab-'):
from .carddav import ab_get
return ab_get(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_get
return tl_get(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -477,6 +492,9 @@ def put_event(username, cal_part, filename):
if cal_part.startswith('ab-'):
from .carddav import ab_put
return ab_put(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_put
return tl_put(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -536,6 +554,9 @@ def delete_event(username, cal_part, filename):
if cal_part.startswith('ab-'):
from .carddav import ab_delete
return ab_delete(username=username, ab_part=cal_part, filename=filename)
if cal_part.startswith('tl-'):
from .taskdav import tl_delete
return tl_delete(username=username, tl_part=cal_part, filename=filename)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -561,6 +582,9 @@ def delete_calendar(username, cal_part):
if cal_part.startswith('ab-'):
from .carddav import ab_delete_collection
return ab_delete_collection(username=username, ab_part=cal_part)
if cal_part.startswith('tl-'):
from .taskdav import tl_delete_collection
return tl_delete_collection(username=username, tl_part=cal_part)
user: User = request.dav_user
if username != user.username:
return Response('', 403)
@@ -587,6 +611,9 @@ def delete_calendar(username, cal_part):
@dav_bp.route('/<username>/<cal_part>', methods=['PROPPATCH'])
@basic_auth
def proppatch_calendar(username, cal_part):
if cal_part.startswith('tl-'):
from .taskdav import tl_proppatch
return tl_proppatch(username=username, tl_part=cal_part)
user: User = request.dav_user
if username != user.username:
return Response('', 403)