feat: Kalender-Ansicht aktualisiert sich live via SSE
Backend: Neuer Event-Typ 'calendar' im Broadcaster. Wird bei Event-CRUD, Serien-Ausnahmen, Freigaben hinzufuegen/entfernen und beim Loeschen ganzer Kalender emittiert. Empfaenger: Eigentuemer + alle User mit CalendarShare auf dem jeweiligen Kalender. Frontend: CalendarView oeffnet beim Mount eine EventSource zu /api/sync/events und reloaded Kalenderliste + Events bei jedem 'calendar'-Event (300ms debounced). Damit sehen beteiligte Nutzer Aenderungen in praktisch Echtzeit - kein F5 mehr noetig. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
ce4faedd88
commit
2170f4a7b1
|
|
@ -9,6 +9,11 @@ from app.api.auth import token_required
|
||||||
from app.extensions import db, bcrypt
|
from app.extensions import db, bcrypt
|
||||||
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
||||||
from app.models.user import User
|
from app.models.user import User
|
||||||
|
from app.services.events import notify_calendar_change
|
||||||
|
|
||||||
|
|
||||||
|
def _calendar_recipients(cal: Calendar):
|
||||||
|
return [s.shared_with_id for s in CalendarShare.query.filter_by(calendar_id=cal.id).all()]
|
||||||
|
|
||||||
|
|
||||||
def _redact_if_private(event_dict: dict, is_owner: bool) -> dict:
|
def _redact_if_private(event_dict: dict, is_owner: bool) -> dict:
|
||||||
|
|
@ -149,8 +154,12 @@ def delete_calendar(cal_id):
|
||||||
if not cal or cal.owner_id != user.id:
|
if not cal or cal.owner_id != user.id:
|
||||||
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
|
return jsonify({'error': 'Nicht gefunden oder keine Berechtigung'}), 404
|
||||||
|
|
||||||
|
recipients = _calendar_recipients(cal)
|
||||||
|
owner_id = cal.owner_id
|
||||||
|
cal_id = cal.id
|
||||||
db.session.delete(cal)
|
db.session.delete(cal)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
notify_calendar_change(owner_id, cal_id, 'deleted', shared_with=recipients)
|
||||||
return jsonify({'message': 'Kalender geloescht'}), 200
|
return jsonify({'message': 'Kalender geloescht'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -235,6 +244,8 @@ def create_event(cal_id):
|
||||||
)
|
)
|
||||||
db.session.add(event)
|
db.session.add(event)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
notify_calendar_change(cal.owner_id, cal.id, 'event',
|
||||||
|
shared_with=_calendar_recipients(cal))
|
||||||
return jsonify(event.to_dict()), 201
|
return jsonify(event.to_dict()), 201
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -281,6 +292,8 @@ def update_event(event_id):
|
||||||
)
|
)
|
||||||
event.updated_at = datetime.now(timezone.utc)
|
event.updated_at = datetime.now(timezone.utc)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
notify_calendar_change(cal.owner_id, cal.id, 'event',
|
||||||
|
shared_with=_calendar_recipients(cal))
|
||||||
return jsonify(event.to_dict()), 200
|
return jsonify(event.to_dict()), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -369,8 +382,12 @@ def delete_event(event_id):
|
||||||
if err:
|
if err:
|
||||||
return err
|
return err
|
||||||
|
|
||||||
|
cal = db.session.get(Calendar, event.calendar_id)
|
||||||
db.session.delete(event)
|
db.session.delete(event)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
if cal:
|
||||||
|
notify_calendar_change(cal.owner_id, cal.id, 'event',
|
||||||
|
shared_with=_calendar_recipients(cal))
|
||||||
return jsonify({'message': 'Event geloescht'}), 200
|
return jsonify({'message': 'Event geloescht'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -418,6 +435,9 @@ def share_calendar(cal_id):
|
||||||
except Exception:
|
except Exception:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
notify_calendar_change(cal.owner_id, cal.id, 'share',
|
||||||
|
shared_with=[target.id, *_calendar_recipients(cal)])
|
||||||
|
|
||||||
return jsonify({'message': f'Kalender mit {username} geteilt'}), 200
|
return jsonify({'message': f'Kalender mit {username} geteilt'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
@ -450,8 +470,11 @@ def remove_calendar_share(cal_id, share_id):
|
||||||
if not share or share.calendar_id != cal_id:
|
if not share or share.calendar_id != cal_id:
|
||||||
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
|
return jsonify({'error': 'Freigabe nicht gefunden'}), 404
|
||||||
|
|
||||||
|
target_id = share.shared_with_id
|
||||||
db.session.delete(share)
|
db.session.delete(share)
|
||||||
db.session.commit()
|
db.session.commit()
|
||||||
|
notify_calendar_change(cal.owner_id, cal.id, 'share',
|
||||||
|
shared_with=[target_id, *_calendar_recipients(cal)])
|
||||||
return jsonify({'message': 'Freigabe entfernt'}), 200
|
return jsonify({'message': 'Freigabe entfernt'}), 200
|
||||||
|
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -79,3 +79,16 @@ def notify_file_change(owner_id: int, file_id: int | None, change: str,
|
||||||
'change': change, # 'created' | 'updated' | 'deleted' | 'locked' | 'unlocked'
|
'change': change, # 'created' | 'updated' | 'deleted' | 'locked' | 'unlocked'
|
||||||
'file_id': file_id,
|
'file_id': file_id,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
||||||
|
def notify_calendar_change(owner_id: int, calendar_id: int, change: str,
|
||||||
|
shared_with: Iterable[int] = ()) -> None:
|
||||||
|
"""Emit a calendar-level change event (event added/changed/deleted or
|
||||||
|
share membership changed). Sent to owner + all users the calendar is
|
||||||
|
shared with."""
|
||||||
|
recipients = [owner_id, *shared_with]
|
||||||
|
broadcaster.publish(recipients, {
|
||||||
|
'type': 'calendar',
|
||||||
|
'change': change, # 'event'|'share'|'deleted'
|
||||||
|
'calendar_id': calendar_id,
|
||||||
|
})
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref, computed, onMounted, reactive, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, reactive, watch } from 'vue'
|
||||||
import { useToast } from 'primevue/usetoast'
|
import { useToast } from 'primevue/usetoast'
|
||||||
import { useAuthStore } from '../stores/auth'
|
import { useAuthStore } from '../stores/auth'
|
||||||
import apiClient from '../api/client'
|
import apiClient from '../api/client'
|
||||||
|
|
@ -858,9 +858,36 @@ watch(() => eventForm.value.all_day, (allDay) => {
|
||||||
eventForm.value.dtend = fix(eventForm.value.dtend)
|
eventForm.value.dtend = fix(eventForm.value.dtend)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// Live updates via SSE: refresh calendar list + events when anything
|
||||||
|
// relevant changes (own or shared).
|
||||||
|
let eventSource = null
|
||||||
|
let reloadTimer = null
|
||||||
|
|
||||||
|
function scheduleReload() {
|
||||||
|
if (reloadTimer) return
|
||||||
|
reloadTimer = setTimeout(async () => {
|
||||||
|
reloadTimer = null
|
||||||
|
await loadCalendars()
|
||||||
|
refreshEvents()
|
||||||
|
}, 300)
|
||||||
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
onMounted(async () => {
|
||||||
await loadCalendars()
|
await loadCalendars()
|
||||||
refreshEvents()
|
refreshEvents()
|
||||||
|
if (auth.accessToken) {
|
||||||
|
try {
|
||||||
|
eventSource = new EventSource(`/api/sync/events?token=${encodeURIComponent(auth.accessToken)}`)
|
||||||
|
eventSource.addEventListener('calendar', scheduleReload)
|
||||||
|
eventSource.addEventListener('message', scheduleReload)
|
||||||
|
eventSource.onerror = () => { /* auto-reconnects */ }
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
onUnmounted(() => {
|
||||||
|
if (reloadTimer) { clearTimeout(reloadTimer); reloadTimer = null }
|
||||||
|
if (eventSource) { eventSource.close(); eventSource = null }
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue