feat: Kalender mit FullCalendar - Woche/Monat/Tag, Drag&Drop, Wiederholungen
Kalender-UI komplett neu aufgesetzt mit FullCalendar:
* Drei Ansichten: Monat, Woche, Tag - ueber Toolbar wechselbar
* Drag & Drop: Termine zwischen Tagen verschieben
* Resize: Termindauer direkt am Rand ziehen
* Sidebar mit aktiven Kalendern (Checkbox fuers Ein-/Ausblenden)
* Deutsch lokalisiert, Woche startet Mo, Wochennummern
* Heute-Marker + Jetzt-Linie in Woche/Tag
Terminbearbeitung:
* Titel, Ort, Beschreibung, Zeitraum (oder ganztaegig)
* Wiederholungs-Editor: taeglich, woechentlich (mit Wochentagen),
monatlich (auch "jeden 2. Mittwoch"), jaehrlich - jeweils mit
Intervall, Enddatum oder Wiederholungsanzahl
* RRULE-Feld (RFC 5545) wird generiert und vom rrule-Plugin fuer
die Anzeige im Kalender gerendert
Backend:
* CalendarEvent: description + location Spalten ergaenzt
* Calendar: ical_password_hash fuer passwortgeschuetzte Abo-Links
* /calendars/<id>/ical-link unterstuetzt password + clear_password
* DELETE /calendars/<id>/ical-link zum Zurueckziehen
* ical_export erzwingt HTTP Basic Auth wenn Passwort gesetzt -
DAVx5, Apple Cal, Thunderbird verstehen das out-of-the-box
Frontend-Deps: @fullcalendar/{core,daygrid,timegrid,interaction,
rrule,vue3}, rrule - ca. 150KB Bundle-Overhead.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
+58
-12
@@ -2,11 +2,11 @@ import secrets
|
||||
import uuid
|
||||
from datetime import datetime, timezone
|
||||
|
||||
from flask import request, jsonify
|
||||
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.extensions import db, bcrypt
|
||||
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
||||
from app.models.user import User
|
||||
|
||||
@@ -166,21 +166,24 @@ def create_event(cal_id):
|
||||
return jsonify({'error': 'Ungueltiges Datumsformat'}), 400
|
||||
|
||||
event_uid = str(uuid.uuid4())
|
||||
description = (data.get('description') or '').strip()
|
||||
location = (data.get('location') or '').strip()
|
||||
rrule = (data.get('recurrence_rule') or '').strip()
|
||||
|
||||
# Build simple iCal data
|
||||
ical_data = _build_ical(event_uid, summary, dtstart_dt, dtend_dt, all_day,
|
||||
data.get('description', ''), data.get('location', ''),
|
||||
data.get('recurrence_rule', ''))
|
||||
description, location, rrule)
|
||||
|
||||
event = CalendarEvent(
|
||||
calendar_id=cal_id,
|
||||
uid=event_uid,
|
||||
ical_data=ical_data,
|
||||
summary=summary,
|
||||
description=description or None,
|
||||
location=location or None,
|
||||
dtstart=dtstart_dt,
|
||||
dtend=dtend_dt,
|
||||
all_day=all_day,
|
||||
recurrence_rule=data.get('recurrence_rule'),
|
||||
recurrence_rule=rrule or None,
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
@@ -202,14 +205,18 @@ def update_event(event_id):
|
||||
data = request.get_json()
|
||||
if 'summary' in data:
|
||||
event.summary = data['summary'].strip()
|
||||
if 'description' in data:
|
||||
event.description = (data['description'] or '').strip() or None
|
||||
if 'location' in data:
|
||||
event.location = (data['location'] or '').strip() or None
|
||||
if 'dtstart' in data:
|
||||
event.dtstart = datetime.fromisoformat(data['dtstart'])
|
||||
if 'dtend' in data:
|
||||
event.dtend = datetime.fromisoformat(data['dtend'])
|
||||
event.dtend = datetime.fromisoformat(data['dtend']) if data['dtend'] else None
|
||||
if 'all_day' in data:
|
||||
event.all_day = data['all_day']
|
||||
if 'recurrence_rule' in data:
|
||||
event.recurrence_rule = data['recurrence_rule']
|
||||
event.recurrence_rule = (data['recurrence_rule'] or '').strip() or None
|
||||
if 'calendar_id' in data:
|
||||
new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True)
|
||||
if cerr:
|
||||
@@ -218,7 +225,7 @@ def update_event(event_id):
|
||||
|
||||
event.ical_data = _build_ical(
|
||||
event.uid, event.summary, event.dtstart, event.dtend,
|
||||
event.all_day, data.get('description', ''), data.get('location', ''),
|
||||
event.all_day, event.description or '', event.location or '',
|
||||
event.recurrence_rule or ''
|
||||
)
|
||||
event.updated_at = datetime.now(timezone.utc)
|
||||
@@ -334,19 +341,58 @@ def generate_ical_link(cal_id):
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
cal.ical_token = secrets.token_urlsafe(32)
|
||||
data = request.get_json(silent=True) or {}
|
||||
password = (data.get('password') or '').strip()
|
||||
|
||||
if not cal.ical_token:
|
||||
cal.ical_token = secrets.token_urlsafe(32)
|
||||
if password:
|
||||
cal.ical_password_hash = bcrypt.generate_password_hash(password).decode('utf-8')
|
||||
elif data.get('clear_password'):
|
||||
cal.ical_password_hash = None
|
||||
|
||||
db.session.commit()
|
||||
return jsonify({
|
||||
'ical_url': f'/ical/{cal.ical_token}',
|
||||
'token': cal.ical_token,
|
||||
'has_password': bool(cal.ical_password_hash),
|
||||
}), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/ical-link', methods=['DELETE'])
|
||||
@token_required
|
||||
def revoke_ical_link(cal_id):
|
||||
user = request.current_user
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal or cal.owner_id != user.id:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
cal.ical_token = None
|
||||
cal.ical_password_hash = None
|
||||
db.session.commit()
|
||||
return jsonify({'message': 'Link zurueckgezogen'}), 200
|
||||
|
||||
|
||||
def _basic_auth_challenge():
|
||||
return Response(
|
||||
'Kalender erfordert Passwort', 401,
|
||||
{'WWW-Authenticate': 'Basic realm="Mini-Cloud Kalender"'}
|
||||
)
|
||||
|
||||
|
||||
def ical_export(token):
|
||||
cal = Calendar.query.filter_by(ical_token=token).first()
|
||||
if not cal:
|
||||
return jsonify({'error': 'Nicht gefunden'}), 404
|
||||
|
||||
# Password protection via HTTP Basic (compatible with DAVx5, Apple Cal,
|
||||
# Thunderbird, curl, etc.). Username is ignored.
|
||||
if cal.ical_password_hash:
|
||||
auth = request.authorization
|
||||
if not auth or not auth.password:
|
||||
return _basic_auth_challenge()
|
||||
if not bcrypt.check_password_hash(cal.ical_password_hash, auth.password):
|
||||
return _basic_auth_challenge()
|
||||
|
||||
events = CalendarEvent.query.filter_by(calendar_id=cal.id).all()
|
||||
|
||||
lines = [
|
||||
@@ -357,13 +403,11 @@ def ical_export(token):
|
||||
]
|
||||
for e in events:
|
||||
if e.ical_data:
|
||||
# Extract VEVENT from stored ical_data
|
||||
lines.append(e.ical_data)
|
||||
else:
|
||||
lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day))
|
||||
lines.append('END:VCALENDAR')
|
||||
|
||||
from flask import Response
|
||||
return Response(
|
||||
'\r\n'.join(lines),
|
||||
mimetype='text/calendar',
|
||||
@@ -380,6 +424,8 @@ def _format_dt(dt, all_day=False):
|
||||
|
||||
|
||||
def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''):
|
||||
if not dtend:
|
||||
dtend = dtstart
|
||||
lines = [
|
||||
'BEGIN:VEVENT',
|
||||
f'UID:{uid}',
|
||||
|
||||
@@ -12,6 +12,7 @@ class Calendar(db.Model):
|
||||
color = db.Column(db.String(7), default='#3788d8')
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
ical_token = db.Column(db.String(64), unique=True, nullable=True, index=True)
|
||||
ical_password_hash = db.Column(db.String(255), 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))
|
||||
@@ -29,6 +30,7 @@ class Calendar(db.Model):
|
||||
'color': self.color,
|
||||
'description': self.description,
|
||||
'ical_token': self.ical_token,
|
||||
'ical_has_password': bool(self.ical_password_hash),
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
}
|
||||
|
||||
@@ -41,6 +43,8 @@ class CalendarEvent(db.Model):
|
||||
uid = db.Column(db.String(255), unique=True, nullable=False)
|
||||
ical_data = db.Column(db.Text, nullable=False) # Full VCALENDAR component
|
||||
summary = db.Column(db.String(500), nullable=True)
|
||||
description = db.Column(db.Text, nullable=True)
|
||||
location = db.Column(db.String(500), nullable=True)
|
||||
dtstart = db.Column(db.DateTime, nullable=True, index=True)
|
||||
dtend = db.Column(db.DateTime, nullable=True)
|
||||
all_day = db.Column(db.Boolean, default=False)
|
||||
@@ -55,6 +59,8 @@ class CalendarEvent(db.Model):
|
||||
'calendar_id': self.calendar_id,
|
||||
'uid': self.uid,
|
||||
'summary': self.summary,
|
||||
'description': self.description,
|
||||
'location': self.location,
|
||||
'dtstart': self.dtstart.isoformat() if self.dtstart else None,
|
||||
'dtend': self.dtend.isoformat() if self.dtend else None,
|
||||
'all_day': self.all_day,
|
||||
|
||||
Reference in New Issue
Block a user