From ba3e619963ba8b405864c9c00348d98ba7e03dd5 Mon Sep 17 00:00:00 2001 From: Stefan Hacker Date: Tue, 14 Apr 2026 15:07:06 +0200 Subject: [PATCH] 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//tl-/ - 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) --- backend/app/api/__init__.py | 2 +- backend/app/api/tasks.py | 587 ++++++++++++++++++++++++++++ backend/app/dav/caldav.py | 35 +- backend/app/dav/taskdav.py | 368 ++++++++++++++++++ backend/app/models/__init__.py | 2 + backend/app/models/task.py | 86 +++++ backend/app/services/events.py | 10 + frontend/src/router/index.js | 5 + frontend/src/views/AppLayout.vue | 5 + frontend/src/views/TasksView.vue | 632 +++++++++++++++++++++++++++++++ 10 files changed, 1727 insertions(+), 5 deletions(-) create mode 100644 backend/app/api/tasks.py create mode 100644 backend/app/dav/taskdav.py create mode 100644 backend/app/models/task.py create mode 100644 frontend/src/views/TasksView.vue diff --git a/backend/app/api/__init__.py b/backend/app/api/__init__.py index 1fa22eb..c4be013 100644 --- a/backend/app/api/__init__.py +++ b/backend/app/api/__init__.py @@ -2,4 +2,4 @@ from flask import Blueprint api_bp = Blueprint('api', __name__, url_prefix='/api') -from app.api import auth, users, files, calendar, contacts, email, office, passwords, backup, client_downloads # noqa: E402, F401 +from app.api import auth, users, files, calendar, contacts, tasks, email, office, passwords, backup, client_downloads # noqa: E402, F401 diff --git a/backend/app/api/tasks.py b/backend/app/api/tasks.py new file mode 100644 index 0000000..8e912c4 --- /dev/null +++ b/backend/app/api/tasks.py @@ -0,0 +1,587 @@ +"""REST API for task lists / tasks (VTODO). + +Mirror der calendar.py-Architektur: TaskList = Calendar-aehnliche Sammlung, +Task = VTODO. CalDAV-Anbindung erfolgt in app/dav/caldav.py: TaskLists +erscheinen als Kalender-Collection mit supported-calendar-component-set +auf VTODO und unter URL /dav//tl-/. +""" +from __future__ import annotations + +import re +import uuid +from datetime import datetime, timezone + +from flask import request, jsonify, Response + +from app.api import api_bp +from app.api.auth import token_required +from app.extensions import db +from app.models.task import TaskList, Task, TaskListShare +from app.models.user import User +from app.services.events import notify_tasklist_change + + +# --------------------------------------------------------------------------- +# Access helpers +# --------------------------------------------------------------------------- + +def _list_recipients(tl: TaskList): + return [s.shared_with_id for s in + TaskListShare.query.filter_by(task_list_id=tl.id).all()] + + +def _get_list_or_err(list_id, user, need_write=False): + tl = db.session.get(TaskList, list_id) + if not tl: + return None, (jsonify({'error': 'Aufgabenliste nicht gefunden'}), 404) + if tl.owner_id == user.id: + return tl, None + share = TaskListShare.query.filter_by( + task_list_id=list_id, shared_with_id=user.id + ).first() + if not share: + return None, (jsonify({'error': 'Zugriff verweigert'}), 403) + if need_write and share.permission != 'readwrite': + return None, (jsonify({'error': 'Schreibzugriff verweigert'}), 403) + return tl, None + + +# --------------------------------------------------------------------------- +# VTODO build / parse +# --------------------------------------------------------------------------- + +def _fmt_dt(dt: datetime | None) -> str | None: + if not dt: + return None + if dt.tzinfo is None: + dt = dt.replace(tzinfo=timezone.utc) + return dt.astimezone(timezone.utc).strftime('%Y%m%dT%H%M%SZ') + + +def build_vtodo(task: Task) -> str: + lines = ['BEGIN:VTODO', f'UID:{task.uid}', + f'DTSTAMP:{_fmt_dt(datetime.now(timezone.utc))}', + f'SUMMARY:{(task.summary or "").replace(chr(10), " ")}'] + if task.description: + lines.append(f'DESCRIPTION:{task.description.replace(chr(10), chr(92) + "n")}') + if task.status: + lines.append(f'STATUS:{task.status}') + if task.priority is not None: + lines.append(f'PRIORITY:{task.priority}') + if task.percent_complete is not None: + lines.append(f'PERCENT-COMPLETE:{task.percent_complete}') + if task.due: + lines.append(f'DUE:{_fmt_dt(task.due)}') + if task.dtstart: + lines.append(f'DTSTART:{_fmt_dt(task.dtstart)}') + if task.completed_at: + lines.append(f'COMPLETED:{_fmt_dt(task.completed_at)}') + if task.categories: + lines.append(f'CATEGORIES:{task.categories}') + lines.append('END:VTODO') + return '\r\n'.join(lines) + + +def _unfold(text: str): + out, current = [], '' + for line in text.replace('\r\n', '\n').split('\n'): + if line.startswith((' ', '\t')) and current: + current += line[1:] + else: + if current: + out.append(current) + current = line + if current: + out.append(current) + return out + + +def _parse_dt(value: str) -> datetime | None: + value = value.strip() + try: + if value.endswith('Z'): + return datetime.strptime(value, '%Y%m%dT%H%M%SZ').replace(tzinfo=timezone.utc) + if 'T' in value: + return datetime.strptime(value, '%Y%m%dT%H%M%S') + return datetime.strptime(value, '%Y%m%d') + except ValueError: + try: + return datetime.fromisoformat(value) + except ValueError: + return None + + +def parse_vtodo(raw: str) -> dict | None: + if 'BEGIN:VTODO' not in raw.upper(): + return None + result: dict = {} + in_block = False + for line in _unfold(raw): + upper = line.upper() + if upper.startswith('BEGIN:VTODO'): + in_block = True + continue + if upper.startswith('END:VTODO'): + break + if not in_block or ':' not in line: + continue + key, _, value = line.partition(':') + name = key.split(';')[0].upper() + if name == 'UID': + result['uid'] = value.strip() + elif name == 'SUMMARY': + result['summary'] = value.strip() + elif name == 'DESCRIPTION': + result['description'] = value.replace('\\n', '\n').replace('\\,', ',').strip() + elif name == 'STATUS': + result['status'] = value.strip().upper() + elif name == 'PRIORITY': + try: + result['priority'] = int(value.strip()) + except ValueError: + pass + elif name == 'PERCENT-COMPLETE': + try: + result['percent_complete'] = int(value.strip()) + except ValueError: + pass + elif name == 'DUE': + result['due'] = _parse_dt(value) + elif name == 'DTSTART': + result['dtstart'] = _parse_dt(value) + elif name == 'COMPLETED': + result['completed_at'] = _parse_dt(value) + elif name == 'CATEGORIES': + result['categories'] = value.strip() + return result if result.get('summary') or result.get('uid') else None + + +def _apply(task: Task, data: dict): + if 'summary' in data: + task.summary = (data.get('summary') or '').strip() or None + if 'description' in data: + task.description = (data.get('description') or '').strip() or None + if 'status' in data: + s = (data.get('status') or '').upper().strip() or None + task.status = s + if s == 'COMPLETED' and not task.completed_at: + task.completed_at = datetime.now(timezone.utc) + task.percent_complete = 100 + elif s != 'COMPLETED': + task.completed_at = None + if 'priority' in data: + task.priority = data['priority'] if data['priority'] is not None else None + if 'percent_complete' in data: + task.percent_complete = data['percent_complete'] + if 'due' in data: + v = data['due'] + task.due = datetime.fromisoformat(v) if v else None + if 'dtstart' in data: + v = data['dtstart'] + task.dtstart = datetime.fromisoformat(v) if v else None + if 'completed_at' in data: + v = data['completed_at'] + task.completed_at = datetime.fromisoformat(v) if v else None + if 'categories' in data: + cats = data['categories'] + if isinstance(cats, list): + task.categories = ','.join(c.strip() for c in cats if c and c.strip()) or None + else: + task.categories = (cats or '').strip() or None + + +# --------------------------------------------------------------------------- +# REST endpoints - lists +# --------------------------------------------------------------------------- + +@api_bp.route('/tasklists', methods=['GET']) +@token_required +def list_tasklists(): + user = request.current_user + own = TaskList.query.filter_by(owner_id=user.id).all() + shared = TaskListShare.query.filter_by(shared_with_id=user.id).all() + out = [] + for tl in own: + d = tl.to_dict() + d['permission'] = 'owner' + d['task_count'] = tl.tasks.count() + out.append(d) + for s in shared: + tl = s.task_list + if not tl: + continue + d = tl.to_dict() + d['permission'] = s.permission + d['owner_name'] = tl.owner.username if tl.owner else '' + d['task_count'] = tl.tasks.count() + if s.color: + d['color'] = s.color + out.append(d) + return jsonify(out), 200 + + +@api_bp.route('/tasklists', methods=['POST']) +@token_required +def create_tasklist(): + user = request.current_user + data = request.get_json() or {} + name = (data.get('name') or '').strip() + if not name: + return jsonify({'error': 'Name erforderlich'}), 400 + tl = TaskList(owner_id=user.id, name=name, + color=data.get('color') or '#10b981', + description=(data.get('description') or '').strip() or None) + db.session.add(tl) + db.session.commit() + notify_tasklist_change(user.id, tl.id, 'created') + return jsonify(tl.to_dict()), 201 + + +@api_bp.route('/tasklists/', methods=['PUT']) +@token_required +def update_tasklist(list_id): + user = request.current_user + tl, err = _get_list_or_err(list_id, user, need_write=True) + if err: + return err + if tl.owner_id != user.id: + return jsonify({'error': 'Nur Eigentuemer kann die Liste umbenennen'}), 403 + data = request.get_json() or {} + if 'name' in data: + tl.name = data['name'].strip() + if 'color' in data: + tl.color = data['color'] + if 'description' in data: + tl.description = (data['description'] or '').strip() or None + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'updated', shared_with=_list_recipients(tl)) + return jsonify(tl.to_dict()), 200 + + +@api_bp.route('/tasklists//my-color', methods=['PUT']) +@token_required +def set_my_tasklist_color(list_id): + user = request.current_user + tl = db.session.get(TaskList, list_id) + if not tl: + return jsonify({'error': 'Nicht gefunden'}), 404 + color = (request.get_json() or {}).get('color') + if not color: + return jsonify({'error': 'color erforderlich'}), 400 + if tl.owner_id == user.id: + tl.color = color + db.session.commit() + return jsonify({'color': tl.color}), 200 + share = TaskListShare.query.filter_by(task_list_id=list_id, shared_with_id=user.id).first() + if not share: + return jsonify({'error': 'Zugriff verweigert'}), 403 + share.color = color + db.session.commit() + return jsonify({'color': share.color}), 200 + + +@api_bp.route('/tasklists/', methods=['DELETE']) +@token_required +def delete_tasklist(list_id): + user = request.current_user + tl = db.session.get(TaskList, list_id) + if not tl or tl.owner_id != user.id: + return jsonify({'error': 'Nur Eigentuemer kann loeschen'}), 403 + recipients = _list_recipients(tl) + db.session.delete(tl) + db.session.commit() + notify_tasklist_change(user.id, list_id, 'deleted', shared_with=recipients) + return jsonify({'message': 'Aufgabenliste geloescht'}), 200 + + +# --------------------------------------------------------------------------- +# REST endpoints - tasks +# --------------------------------------------------------------------------- + +@api_bp.route('/tasklists//tasks', methods=['GET']) +@token_required +def list_tasks(list_id): + user = request.current_user + tl, err = _get_list_or_err(list_id, user) + if err: + return err + show_done = (request.args.get('include_done') or 'true').lower() != 'false' + q = Task.query.filter_by(task_list_id=list_id) + if not show_done: + q = q.filter((Task.status.is_(None)) | (Task.status != 'COMPLETED')) + tasks = q.order_by(Task.due.asc().nullslast(), Task.priority.desc().nullslast(), Task.id).all() + return jsonify([t.to_dict() for t in tasks]), 200 + + +@api_bp.route('/tasklists//tasks', methods=['POST']) +@token_required +def create_task(list_id): + user = request.current_user + tl, err = _get_list_or_err(list_id, user, need_write=True) + if err: + return err + data = request.get_json() or {} + if not (data.get('summary') or '').strip(): + return jsonify({'error': 'Titel erforderlich'}), 400 + task = Task(task_list_id=list_id, uid=str(uuid.uuid4()), ical_data='') + _apply(task, data) + if not task.status: + task.status = 'NEEDS-ACTION' + task.ical_data = build_vtodo(task) + db.session.add(task) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl)) + return jsonify(task.to_dict()), 201 + + +@api_bp.route('/tasks/', methods=['GET']) +@token_required +def get_task(task_id): + user = request.current_user + task = db.session.get(Task, task_id) + if not task: + return jsonify({'error': 'Aufgabe nicht gefunden'}), 404 + tl, err = _get_list_or_err(task.task_list_id, user) + if err: + return err + return jsonify(task.to_dict()), 200 + + +@api_bp.route('/tasks/', methods=['PUT']) +@token_required +def update_task(task_id): + user = request.current_user + task = db.session.get(Task, task_id) + if not task: + return jsonify({'error': 'Aufgabe nicht gefunden'}), 404 + tl, err = _get_list_or_err(task.task_list_id, user, need_write=True) + if err: + return err + data = request.get_json() or {} + if 'task_list_id' in data and data['task_list_id'] != task.task_list_id: + new_tl, e2 = _get_list_or_err(data['task_list_id'], user, need_write=True) + if e2: + return e2 + task.task_list_id = data['task_list_id'] + _apply(task, data) + task.ical_data = build_vtodo(task) + task.updated_at = datetime.now(timezone.utc) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl)) + return jsonify(task.to_dict()), 200 + + +@api_bp.route('/tasks/', methods=['DELETE']) +@token_required +def delete_task(task_id): + user = request.current_user + task = db.session.get(Task, task_id) + if not task: + return jsonify({'error': 'Aufgabe nicht gefunden'}), 404 + tl, err = _get_list_or_err(task.task_list_id, user, need_write=True) + if err: + return err + db.session.delete(task) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl)) + return jsonify({'message': 'Aufgabe geloescht'}), 200 + + +# --------------------------------------------------------------------------- +# Sharing +# --------------------------------------------------------------------------- + +@api_bp.route('/tasklists//share', methods=['POST']) +@token_required +def share_tasklist(list_id): + user = request.current_user + tl = db.session.get(TaskList, list_id) + if not tl or tl.owner_id != user.id: + return jsonify({'error': 'Nur Eigentuemer kann teilen'}), 403 + data = request.get_json() or {} + username = (data.get('username') or '').strip() + permission = data.get('permission', 'read') + if permission not in ('read', 'readwrite'): + return jsonify({'error': 'Ungueltige Berechtigung'}), 400 + target = User.query.filter_by(username=username).first() + if not target: + return jsonify({'error': 'Benutzer nicht gefunden'}), 404 + if target.id == user.id: + return jsonify({'error': 'Kann nicht mit sich selbst teilen'}), 400 + existing = TaskListShare.query.filter_by(task_list_id=list_id, shared_with_id=target.id).first() + if existing: + existing.permission = permission + else: + db.session.add(TaskListShare(task_list_id=list_id, shared_with_id=target.id, + permission=permission)) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'share', + shared_with=[target.id, *_list_recipients(tl)]) + return jsonify({'message': f'Geteilt mit {username}'}), 200 + + +@api_bp.route('/tasklists//shares', methods=['GET']) +@token_required +def list_tasklist_shares(list_id): + user = request.current_user + tl = db.session.get(TaskList, list_id) + if not tl or tl.owner_id != user.id: + return jsonify({'error': 'Nicht gefunden'}), 404 + shares = TaskListShare.query.filter_by(task_list_id=list_id).all() + return jsonify([{ + 'id': s.id, 'user_id': s.shared_with_id, + 'username': s.shared_with.username, 'permission': s.permission, + } for s in shares]), 200 + + +@api_bp.route('/tasklists//shares/', methods=['DELETE']) +@token_required +def remove_tasklist_share(list_id, share_id): + user = request.current_user + tl = db.session.get(TaskList, list_id) + if not tl or tl.owner_id != user.id: + return jsonify({'error': 'Nicht gefunden'}), 404 + share = db.session.get(TaskListShare, share_id) + if not share or share.task_list_id != list_id: + return jsonify({'error': 'Freigabe nicht gefunden'}), 404 + target_id = share.shared_with_id + db.session.delete(share) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'share', + shared_with=[target_id, *_list_recipients(tl)]) + return jsonify({'message': 'Freigabe entfernt'}), 200 + + +# --------------------------------------------------------------------------- +# Import / Export (.ics with VTODO; CSV) +# --------------------------------------------------------------------------- + +@api_bp.route('/tasklists//export', methods=['GET']) +@token_required +def export_tasklist(list_id): + import csv + import io + user = request.current_user + tl, err = _get_list_or_err(list_id, user) + if err: + return err + fmt = (request.args.get('format') or 'ics').lower() + tasks = Task.query.filter_by(task_list_id=list_id).all() + safe = re.sub(r'[^A-Za-z0-9._-]+', '_', tl.name or 'aufgaben') or 'aufgaben' + + if fmt == 'ics': + lines = ['BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', 'CALSCALE:GREGORIAN'] + for t in tasks: + block = (t.ical_data or '').strip() or build_vtodo(t) + lines.append(block) + lines.append('END:VCALENDAR') + return Response( + '\r\n'.join(lines) + '\r\n', + mimetype='text/calendar; charset=utf-8', + headers={'Content-Disposition': f'attachment; filename="{safe}.ics"'}, + ) + if fmt == 'csv': + out = io.StringIO() + w = csv.writer(out, delimiter=';', quoting=csv.QUOTE_ALL) + w.writerow(['summary', 'status', 'priority', 'percent_complete', + 'due', 'dtstart', 'completed_at', 'categories', 'description', 'uid']) + for t in tasks: + w.writerow([ + t.summary or '', t.status or '', + t.priority if t.priority is not None else '', + t.percent_complete if t.percent_complete is not None else '', + t.due.isoformat() if t.due else '', + t.dtstart.isoformat() if t.dtstart else '', + t.completed_at.isoformat() if t.completed_at else '', + t.categories or '', + (t.description or '').replace('\r\n', ' ').replace('\n', ' '), + t.uid or '', + ]) + return Response( + '\ufeff' + out.getvalue(), mimetype='text/csv; charset=utf-8', + headers={'Content-Disposition': f'attachment; filename="{safe}.csv"'}, + ) + return jsonify({'error': 'Unbekanntes Format'}), 400 + + +@api_bp.route('/tasklists//import', methods=['POST']) +@token_required +def import_tasklist(list_id): + import csv + import io + user = request.current_user + tl, err = _get_list_or_err(list_id, user, need_write=True) + if err: + return err + file = request.files.get('file') + if not file: + return jsonify({'error': 'Keine Datei'}), 400 + raw = file.read() + try: + text = raw.decode('utf-8-sig') + except UnicodeDecodeError: + text = raw.decode('latin-1', errors='replace') + name = (file.filename or '').lower() + imported, skipped = 0, 0 + + def _save(parsed: dict, ical_block: str | None = None): + nonlocal imported, skipped + if not parsed.get('summary'): + skipped += 1 + return + uid = parsed.get('uid') or str(uuid.uuid4()) + existing = Task.query.filter_by(task_list_id=list_id, uid=uid).first() + t = existing or Task(task_list_id=list_id, uid=uid, ical_data='') + t.summary = parsed.get('summary') + t.description = parsed.get('description') + t.status = parsed.get('status') or 'NEEDS-ACTION' + t.priority = parsed.get('priority') + t.percent_complete = parsed.get('percent_complete') + t.due = parsed.get('due') + t.dtstart = parsed.get('dtstart') + t.completed_at = parsed.get('completed_at') + cats = parsed.get('categories') + if isinstance(cats, list): + t.categories = ','.join(cats) + elif isinstance(cats, str): + t.categories = cats or None + t.ical_data = (ical_block or '').strip() or build_vtodo(t) + if not existing: + db.session.add(t) + imported += 1 + + if name.endswith('.csv') or (b';' in raw[:200] and b'BEGIN:VCALENDAR' not in raw[:200]): + reader = csv.DictReader(__import__('io').StringIO(text), delimiter=';') + if not reader.fieldnames or len(reader.fieldnames) < 2: + reader = csv.DictReader(__import__('io').StringIO(text), delimiter=',') + for row in reader: + row = {k.strip().lower(): (v or '').strip() for k, v in row.items() if k} + try: + due = datetime.fromisoformat(row['due']) if row.get('due') else None + except ValueError: + due = None + _save({ + 'uid': row.get('uid'), + 'summary': row.get('summary') or row.get('titel'), + 'description': row.get('description') or row.get('beschreibung'), + 'status': (row.get('status') or '').upper() or None, + 'priority': int(row['priority']) if row.get('priority', '').isdigit() else None, + 'percent_complete': int(row['percent_complete']) if row.get('percent_complete', '').isdigit() else None, + 'due': due, + 'categories': row.get('categories') or row.get('kategorien'), + }) + else: + blocks = re.findall(r'BEGIN:VTODO.*?END:VTODO', text, flags=re.DOTALL | re.IGNORECASE) + if not blocks: + return jsonify({'error': 'Keine VTODO-Daten gefunden'}), 400 + for block in blocks: + parsed = parse_vtodo(block) + if not parsed: + skipped += 1 + continue + _save(parsed, ical_block=block) + + db.session.commit() + if imported: + notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl)) + return jsonify({'imported': imported, 'skipped': skipped}), 200 diff --git a/backend/app/dav/caldav.py b/backend/app/dav/caldav.py index 7857288..4f85033 100644 --- a/backend/app/dav/caldav.py +++ b/backend/app/dav/caldav.py @@ -284,11 +284,11 @@ def propfind(subpath=''): multistatus.append(_principal_response(user)) return _xml_response(multistatus) - # /dav//calendars/ : only calendar collections + # /dav//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//addressbooks/ : only addressbook collections @@ -322,10 +325,13 @@ def propfind(subpath=''): multistatus.append(_addressbook_response(user, ab)) return _xml_response(multistatus) - # /dav//cal-/ : calendar + events + # /dav//cal-/ : 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//cal-/.ics : single event + # /dav//cal-/.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('//', 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) diff --git a/backend/app/dav/taskdav.py b/backend/app/dav/taskdav.py new file mode 100644 index 0000000..10eb2bf --- /dev/null +++ b/backend/app/dav/taskdav.py @@ -0,0 +1,368 @@ +"""CalDAV Task-List Handler (VTODO). + +TaskLists werden parallel zu Calendars als Calendar-Collection +ausgeliefert, jedoch mit `` = VTODO +(statt VEVENT). DAVx5/OpenTasks erkennen sie dadurch automatisch als +Aufgabenliste. + +URL-Schema: + /dav//tl-/ Collection + /dav//tl-/.ics VTODO-Resource + +Diese Funktionen werden aus caldav.py heraus aufgerufen, sobald der +URL-Bestandteil mit `tl-` beginnt - parallel zur ab-/CardDAV-Delegation. +""" +from __future__ import annotations + +import re +import xml.etree.ElementTree as ET +from datetime import datetime, timezone + +from flask import Response, request + +from app.extensions import db +from app.models.task import TaskList, Task +from app.models.user import User +from app.api.tasks import build_vtodo, parse_vtodo, _list_recipients +from app.services.events import notify_tasklist_change + + +# Re-use XML helpers from caldav.py +def _import_caldav_helpers(): + from . import caldav + return caldav + + +def _qn(prefix, name): + return _import_caldav_helpers()._qn(prefix, name) + + +def _xml_response(elem): + return _import_caldav_helpers()._xml_response(elem) + + +def _make_response(href, populate): + return _import_caldav_helpers()._make_response(href, populate) + + +# --------------------------------------------------------------------------- +# Path / URL helpers +# --------------------------------------------------------------------------- + +def parse_tl_path(part: str): + m = re.match(r'tl-(\d+)$', part) + return int(m.group(1)) if m else None + + +def href_list(username, lid): + return f'/dav/{username}/tl-{lid}/' + + +def href_task(username, lid, uid): + return f'/dav/{username}/tl-{lid}/{uid}.ics' + + +def user_lists(user: User): + return TaskList.query.filter_by(owner_id=user.id).all() + + +def list_for(user: User, lid: int): + tl = db.session.get(TaskList, lid) + if not tl or tl.owner_id != user.id: + return None + return tl + + +def _ctag(tl: TaskList) -> str: + last = db.session.query(db.func.max(Task.updated_at)).filter_by(task_list_id=tl.id).scalar() + ts = int((last or tl.updated_at or datetime.now(timezone.utc)).timestamp()) + return f'"tl{tl.id}-{ts}"' + + +def _etag(t: Task) -> str: + ts = int((t.updated_at or t.created_at or datetime.now(timezone.utc)).timestamp() * 1000) + return f'"{t.id}-{ts}"' + + +def _wrap_vcalendar(t: Task) -> str: + block = (t.ical_data or '').strip() or build_vtodo(t) + return '\r\n'.join([ + 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', + 'CALSCALE:GREGORIAN', block, 'END:VCALENDAR', + ]) + + +# --------------------------------------------------------------------------- +# PROPFIND building blocks +# --------------------------------------------------------------------------- + +def list_response(user: User, tl: TaskList) -> ET.Element: + href = href_list(user.username, tl.id) + + def populate(prop): + rt = ET.SubElement(prop, _qn('d', 'resourcetype')) + ET.SubElement(rt, _qn('d', 'collection')) + ET.SubElement(rt, _qn('c', 'calendar')) + ET.SubElement(prop, _qn('d', 'displayname')).text = tl.name + ET.SubElement(prop, _qn('c', 'calendar-description')).text = tl.description or '' + supported = ET.SubElement(prop, _qn('c', 'supported-calendar-component-set')) + comp = ET.SubElement(supported, _qn('c', 'comp')) + comp.set('name', 'VTODO') + srs = ET.SubElement(prop, _qn('d', 'supported-report-set')) + for r in ('calendar-query', 'calendar-multiget'): + sup = ET.SubElement(srs, _qn('d', 'supported-report')) + rep = ET.SubElement(sup, _qn('d', 'report')) + ET.SubElement(rep, _qn('c', r)) + ET.SubElement(prop, _qn('ic', 'calendar-color')).text = tl.color or '#10b981' + ET.SubElement(prop, _qn('cs', 'getctag')).text = _ctag(tl) + cups = ET.SubElement(prop, _qn('d', 'current-user-privilege-set')) + for priv in ('read', 'write', 'write-properties', 'write-content', 'bind', 'unbind'): + p = ET.SubElement(cups, _qn('d', 'privilege')) + ET.SubElement(p, _qn('d', priv)) + return _make_response(href, populate) + + +def task_response(user: User, tl: TaskList, t: Task, include_data=False) -> ET.Element: + href = href_task(user.username, tl.id, t.uid) + + def populate(prop): + ET.SubElement(prop, _qn('d', 'getetag')).text = _etag(t) + ET.SubElement(prop, _qn('d', 'getcontenttype')).text = \ + 'text/calendar; charset=utf-8; component=VTODO' + ET.SubElement(prop, _qn('d', 'resourcetype')) + if include_data: + ET.SubElement(prop, _qn('c', 'calendar-data')).text = _wrap_vcalendar(t) + return _make_response(href, populate) + + +# --------------------------------------------------------------------------- +# Handlers (entered from caldav.py when path starts with tl-) +# --------------------------------------------------------------------------- + +def tl_propfind(username, tl_part): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + depth = request.headers.get('Depth', '0') + multi = ET.Element(_qn('d', 'multistatus')) + multi.append(list_response(user, tl)) + if depth != '0': + for t in tl.tasks.all(): + multi.append(task_response(user, tl, t)) + return _xml_response(multi) + + +def tl_task_propfind(username, tl_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + uid = filename.removesuffix('.ics') + t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first() + if not t: + return Response('Not found', 404) + multi = ET.Element(_qn('d', 'multistatus')) + multi.append(task_response(user, tl, t, include_data=True)) + return _xml_response(multi) + + +def tl_report(username, tl_part): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + try: + root = ET.fromstring(request.data or b'') + except ET.ParseError: + return Response('Malformed XML', 400) + wants_data = root.find(f".//{_qn('c', 'calendar-data')}") is not None + multi = ET.Element(_qn('d', 'multistatus')) + if root.tag == _qn('c', 'calendar-multiget'): + hrefs = [h.text for h in root.findall(_qn('d', 'href')) if h.text] + for href in hrefs: + uid = href.rsplit('/', 1)[-1].removesuffix('.ics') + t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first() + if t: + multi.append(task_response(user, tl, t, include_data=True)) + return _xml_response(multi) + if root.tag == _qn('c', 'calendar-query'): + for t in tl.tasks.all(): + multi.append(task_response(user, tl, t, include_data=wants_data)) + return _xml_response(multi) + return _xml_response(multi) + + +def tl_get(username, tl_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + uid = filename.removesuffix('.ics') + t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first() + if not t: + return Response('Not found', 404) + return Response(_wrap_vcalendar(t), + mimetype='text/calendar; charset=utf-8', + headers={'ETag': _etag(t)}) + + +def tl_put(username, tl_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + uid = filename.removesuffix('.ics') + raw = request.get_data(as_text=True) or '' + parsed = parse_vtodo(raw) + if not parsed: + return Response('Cannot parse VTODO', 400) + body_uid = parsed.get('uid') or uid + existing = Task.query.filter_by(task_list_id=tl.id, uid=body_uid).first() + if_match = request.headers.get('If-Match') + if_none_match = request.headers.get('If-None-Match') + if existing and if_none_match == '*': + return Response('', 412) + if if_match and existing and if_match.strip() != _etag(existing): + return Response('', 412) + is_new = existing is None + if is_new: + existing = Task(task_list_id=tl.id, uid=body_uid, ical_data=raw) + db.session.add(existing) + existing.summary = parsed.get('summary') or '(ohne Titel)' + existing.description = parsed.get('description') + existing.status = parsed.get('status') or 'NEEDS-ACTION' + existing.priority = parsed.get('priority') + existing.percent_complete = parsed.get('percent_complete') + existing.due = parsed.get('due') + existing.dtstart = parsed.get('dtstart') + existing.completed_at = parsed.get('completed_at') + cats = parsed.get('categories') + if isinstance(cats, str): + existing.categories = cats or None + elif isinstance(cats, list): + existing.categories = ','.join(cats) or None + # Roh-Block sichern fuer Round-Trip + block = re.search(r'BEGIN:VTODO.*?END:VTODO', raw, flags=re.DOTALL | re.IGNORECASE) + existing.ical_data = (block.group(0).strip() if block else raw.strip()) or build_vtodo(existing) + existing.updated_at = datetime.now(timezone.utc) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl)) + return Response('', 201 if is_new else 204, {'ETag': _etag(existing)}) + + +def tl_delete(username, tl_part, filename): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + uid = filename.removesuffix('.ics') + t = Task.query.filter_by(task_list_id=tl.id, uid=uid).first() + if not t: + return Response('', 404) + db.session.delete(t) + db.session.commit() + notify_tasklist_change(tl.owner_id, tl.id, 'task', shared_with=_list_recipients(tl)) + return Response('', 204) + + +def tl_delete_collection(username, tl_part): + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('', 404) + recipients = _list_recipients(tl) + owner_id = tl.owner_id + list_id = tl.id + db.session.delete(tl) + db.session.commit() + notify_tasklist_change(owner_id, list_id, 'deleted', shared_with=recipients) + return Response('', 204) + + +def tl_options(username, tl_part): + return Response('', 200, { + 'DAV': '1, 2, 3, calendar-access, addressbook', + 'Allow': 'OPTIONS, PROPFIND, REPORT, GET, PUT, DELETE, MKCALENDAR, PROPPATCH', + }) + + +def tl_proppatch(username, tl_part): + """Bestaetige Property-Updates damit Clients zufrieden sind. Wir + persistieren Displayname + Color, alles andere wird stillschweigend + akzeptiert.""" + user: User = request.dav_user + if username != user.username: + return Response('', 403) + lid = parse_tl_path(tl_part) + tl = list_for(user, lid) if lid else None + if not tl: + return Response('Not found', 404) + try: + root = ET.fromstring(request.data or b'') + except ET.ParseError: + return Response('Malformed XML', 400) + changed = False + for el in root.iter(): + tag = (el.tag.split('}', 1)[1] if '}' in el.tag else el.tag).lower() + if tag == 'displayname' and el.text: + tl.name = el.text + changed = True + elif tag == 'calendar-color' and el.text: + tl.color = el.text[:7] + changed = True + if changed: + db.session.commit() + multi = ET.Element(_qn('d', 'multistatus')) + resp = ET.SubElement(multi, _qn('d', 'response')) + ET.SubElement(resp, _qn('d', 'href')).text = href_list(user.username, tl.id) + ps = ET.SubElement(resp, _qn('d', 'propstat')) + ET.SubElement(ps, _qn('d', 'status')).text = 'HTTP/1.1 200 OK' + return _xml_response(multi) + + +def tl_mkcol(username, tl_part): + """Erstelle eine neue TaskList per MKCOL/MKCALENDAR. Der Pfadteil + `tl-N` ist bei MKCOL aber unbekannt - DAVx5 schickt einen frei + gewaehlten Namen wie `mein-task-uuid`. Daher: wir akzeptieren jeden + Pfadteil und legen eine TaskList an.""" + user: User = request.dav_user + if username != user.username: + return Response('', 403) + name = 'Neue Aufgabenliste' + try: + body = request.get_data() + if body: + root = ET.fromstring(body) + for el in root.iter(): + tag = (el.tag.split('}', 1)[1] if '}' in el.tag else el.tag).lower() + if tag == 'displayname' and el.text: + name = el.text + except ET.ParseError: + pass + tl = TaskList(owner_id=user.id, name=name) + db.session.add(tl) + db.session.commit() + notify_tasklist_change(user.id, tl.id, 'created') + return Response('', 201, {'Location': href_list(user.username, tl.id)}) diff --git a/backend/app/models/__init__.py b/backend/app/models/__init__.py index b8df9a8..4b39db4 100644 --- a/backend/app/models/__init__.py +++ b/backend/app/models/__init__.py @@ -2,6 +2,7 @@ from app.models.user import User from app.models.file import File, FilePermission, ShareLink from app.models.calendar import Calendar, CalendarEvent, CalendarShare from app.models.contact import AddressBook, Contact, AddressBookShare +from app.models.task import TaskList, Task, TaskListShare from app.models.email_account import EmailAccount from app.models.password_vault import PasswordFolder, PasswordEntry, PasswordShare from app.models.settings import AppSettings @@ -13,6 +14,7 @@ __all__ = [ 'File', 'FilePermission', 'ShareLink', 'Calendar', 'CalendarEvent', 'CalendarShare', 'AddressBook', 'Contact', 'AddressBookShare', + 'TaskList', 'Task', 'TaskListShare', 'EmailAccount', 'PasswordFolder', 'PasswordEntry', 'PasswordShare', 'AppSettings', diff --git a/backend/app/models/task.py b/backend/app/models/task.py new file mode 100644 index 0000000..07ab152 --- /dev/null +++ b/backend/app/models/task.py @@ -0,0 +1,86 @@ +from datetime import datetime, timezone + +from app.extensions import db + + +class TaskList(db.Model): + __tablename__ = 'task_lists' + + id = db.Column(db.Integer, primary_key=True) + owner_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + name = db.Column(db.String(255), nullable=False) + color = db.Column(db.String(7), default='#10b981') + description = db.Column(db.Text, nullable=True) + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + tasks = db.relationship('Task', backref='task_list', lazy='dynamic', + cascade='all, delete-orphan') + shares = db.relationship('TaskListShare', backref='task_list', lazy='dynamic', + cascade='all, delete-orphan') + + def to_dict(self): + return { + 'id': self.id, + 'owner_id': self.owner_id, + 'name': self.name, + 'color': self.color, + 'description': self.description, + 'created_at': self.created_at.isoformat() if self.created_at else None, + } + + +class Task(db.Model): + __tablename__ = 'tasks' + + id = db.Column(db.Integer, primary_key=True) + task_list_id = db.Column(db.Integer, db.ForeignKey('task_lists.id'), nullable=False, index=True) + uid = db.Column(db.String(255), unique=True, nullable=False) + ical_data = db.Column(db.Text, nullable=False, default='') # Full VTODO block + summary = db.Column(db.String(500), nullable=True) + description = db.Column(db.Text, nullable=True) + status = db.Column(db.String(32), nullable=True) # NEEDS-ACTION | IN-PROCESS | COMPLETED | CANCELLED + priority = db.Column(db.Integer, nullable=True) # 0 (keine) - 9 + percent_complete = db.Column(db.Integer, nullable=True) # 0..100 + due = db.Column(db.DateTime, nullable=True, index=True) + dtstart = db.Column(db.DateTime, nullable=True) + completed_at = db.Column(db.DateTime, nullable=True) + categories = db.Column(db.Text, nullable=True) # kommagetrennt + created_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc)) + updated_at = db.Column(db.DateTime, default=lambda: datetime.now(timezone.utc), + onupdate=lambda: datetime.now(timezone.utc)) + + def to_dict(self): + return { + 'id': self.id, + 'task_list_id': self.task_list_id, + 'uid': self.uid, + 'summary': self.summary, + 'description': self.description, + 'status': self.status or 'NEEDS-ACTION', + 'priority': self.priority, + 'percent_complete': self.percent_complete, + 'due': self.due.isoformat() if self.due else None, + 'dtstart': self.dtstart.isoformat() if self.dtstart else None, + 'completed_at': self.completed_at.isoformat() if self.completed_at else None, + 'categories': self.categories.split(',') if self.categories else [], + 'created_at': self.created_at.isoformat() if self.created_at else None, + 'updated_at': self.updated_at.isoformat() if self.updated_at else None, + } + + +class TaskListShare(db.Model): + __tablename__ = 'task_list_shares' + + id = db.Column(db.Integer, primary_key=True) + task_list_id = db.Column(db.Integer, db.ForeignKey('task_lists.id'), nullable=False, index=True) + shared_with_id = db.Column(db.Integer, db.ForeignKey('users.id'), nullable=False, index=True) + permission = db.Column(db.String(20), nullable=False, default='read') + color = db.Column(db.String(7), nullable=True) + + shared_with = db.relationship('User', backref='shared_task_lists') + + __table_args__ = ( + db.UniqueConstraint('task_list_id', 'shared_with_id', name='uq_task_list_share'), + ) diff --git a/backend/app/services/events.py b/backend/app/services/events.py index 6552de5..e3286b4 100644 --- a/backend/app/services/events.py +++ b/backend/app/services/events.py @@ -92,3 +92,13 @@ def notify_calendar_change(owner_id: int, calendar_id: int, change: str, 'change': change, # 'event'|'share'|'deleted' 'calendar_id': calendar_id, }) + + +def notify_tasklist_change(owner_id: int, list_id: int, change: str, + shared_with: Iterable[int] = ()) -> None: + recipients = [owner_id, *shared_with] + broadcaster.publish(recipients, { + 'type': 'tasklist', + 'change': change, # 'task'|'share'|'deleted'|'created' + 'task_list_id': list_id, + }) diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index e7b7576..c56d33b 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -48,6 +48,11 @@ const routes = [ name: 'Contacts', component: () => import('../views/ContactsView.vue'), }, + { + path: 'tasks', + name: 'Tasks', + component: () => import('../views/TasksView.vue'), + }, { path: 'email', name: 'Email', diff --git a/frontend/src/views/AppLayout.vue b/frontend/src/views/AppLayout.vue index 0d09c91..bfb6e6c 100644 --- a/frontend/src/views/AppLayout.vue +++ b/frontend/src/views/AppLayout.vue @@ -22,6 +22,11 @@ Kontakte + + + Aufgaben + + +
+
+

Aufgaben

+
+
+
+ +
+ + +
+
+ + +
+ +
+ {{ selectedTaskIds.length }} ausgewaehlt +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + TitelFaelligPrioStatus
+ + + + + {{ t.summary || '(ohne Titel)' }} + {{ shortDesc(t.description) }} + {{ formatDue(t.due) }}{{ formatPrio(t.priority) }}{{ statusLabel(t.status) }} +
Keine Aufgaben.
+
+
+ + + +
+ + +
+
+ + +
+ +
+ + + +
+

{{ menuList.name }}

+
+ + +
+
+ +