feat: Kalender - Autocomplete + Privat-Flag + Share-Liste + Bugfix
Sharing-Fix:
Calendar-Model hatte keine owner-Relation zu User - list_calendars
stuerzte beim Listen geteilter Kalender ab (c.owner.username ->
AttributeError). Jetzt mit explizitem foreign_keys Relationship.
Benutzer-Autocomplete:
"Kalender teilen" nutzt jetzt /users/search wie bei Dateien.
Tippt man 2+ Zeichen, erscheint ein Dropdown mit passenden
Benutzernamen. Klick uebernimmt den Namen.
Bestehende Freigaben werden im Menue angezeigt mit Muelleimer
zum Entfernen.
Privat-Flag fuer Termine:
CalendarEvent bekommt is_private-Spalte. Checkbox im Termin-
Dialog "🔒 Privat (Teilnehmer sehen nur den Zeitblock)".
Redaction greift an drei Stellen:
* GET /events: Nicht-Owner sehen summary="Privat", description
und location = null. Zeitfenster bleibt voll sichtbar.
* iCal-Export (/ical/<token>): Privat-Events werden mit
CLASS:PRIVATE ausgegeben und SUMMARY/DESCRIPTION/LOCATION
werden gestrippt.
* CalDAV: aktuell werden eh nur eigene Kalender exportiert,
also keine Redaction noetig. Kommt bei Share-Support rein.
Der Eigentuemer sieht natuerlich in seiner eigenen Ansicht alle
Details seines privaten Termins.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
5797a7b738
commit
a143325bbe
|
|
@ -11,6 +11,52 @@ from app.models.calendar import Calendar, CalendarEvent, CalendarShare
|
|||
from app.models.user import User
|
||||
|
||||
|
||||
def _redact_if_private(event_dict: dict, is_owner: bool) -> dict:
|
||||
"""For shared viewers, strip summary/description/location from private
|
||||
events so only the time slot remains visible."""
|
||||
if is_owner or not event_dict.get('is_private'):
|
||||
return event_dict
|
||||
d = dict(event_dict)
|
||||
d['summary'] = 'Privat'
|
||||
d['description'] = None
|
||||
d['location'] = None
|
||||
return d
|
||||
|
||||
|
||||
def _redact_vevent(raw: str) -> str:
|
||||
"""Strip SUMMARY/DESCRIPTION/LOCATION from a VEVENT block and set
|
||||
CLASS:PRIVATE. Used for shared iCal exports and CalDAV responses."""
|
||||
if not raw:
|
||||
return raw
|
||||
import re as _re
|
||||
out_lines = []
|
||||
has_class = False
|
||||
for line in raw.split('\n'):
|
||||
stripped = line.rstrip('\r')
|
||||
upper = stripped.split(':', 1)[0].split(';', 1)[0].upper()
|
||||
if upper == 'SUMMARY':
|
||||
out_lines.append('SUMMARY:Privat')
|
||||
elif upper in ('DESCRIPTION', 'LOCATION'):
|
||||
continue
|
||||
elif upper == 'CLASS':
|
||||
has_class = True
|
||||
out_lines.append('CLASS:PRIVATE')
|
||||
else:
|
||||
out_lines.append(stripped)
|
||||
if not has_class:
|
||||
# Inject CLASS right after UID if possible, else before END:VEVENT
|
||||
for i, l in enumerate(out_lines):
|
||||
if l.startswith('UID:'):
|
||||
out_lines.insert(i + 1, 'CLASS:PRIVATE')
|
||||
break
|
||||
else:
|
||||
for i, l in enumerate(out_lines):
|
||||
if l.upper().startswith('END:VEVENT'):
|
||||
out_lines.insert(i, 'CLASS:PRIVATE')
|
||||
break
|
||||
return '\r\n'.join(out_lines)
|
||||
|
||||
|
||||
def _get_calendar_or_err(cal_id, user, need_write=False):
|
||||
cal = db.session.get(Calendar, cal_id)
|
||||
if not cal:
|
||||
|
|
@ -136,7 +182,8 @@ def list_events(cal_id):
|
|||
pass
|
||||
|
||||
events = query.order_by(CalendarEvent.dtstart).all()
|
||||
return jsonify([e.to_dict() for e in events]), 200
|
||||
is_owner = (cal.owner_id == user.id)
|
||||
return jsonify([_redact_if_private(e.to_dict(), is_owner) for e in events]), 200
|
||||
|
||||
|
||||
@api_bp.route('/calendars/<int:cal_id>/events', methods=['POST'])
|
||||
|
|
@ -184,6 +231,7 @@ def create_event(cal_id):
|
|||
dtend=dtend_dt,
|
||||
all_day=all_day,
|
||||
recurrence_rule=rrule or None,
|
||||
is_private=bool(data.get('is_private', False)),
|
||||
)
|
||||
db.session.add(event)
|
||||
db.session.commit()
|
||||
|
|
@ -217,6 +265,8 @@ def update_event(event_id):
|
|||
event.all_day = data['all_day']
|
||||
if 'recurrence_rule' in data:
|
||||
event.recurrence_rule = (data['recurrence_rule'] or '').strip() or None
|
||||
if 'is_private' in data:
|
||||
event.is_private = bool(data['is_private'])
|
||||
if 'calendar_id' in data:
|
||||
new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True)
|
||||
if cerr:
|
||||
|
|
@ -477,7 +527,10 @@ def ical_export(token):
|
|||
]
|
||||
for e in events:
|
||||
if e.ical_data:
|
||||
lines.append(e.ical_data)
|
||||
block = _redact_vevent(e.ical_data) if e.is_private else e.ical_data
|
||||
lines.append(block)
|
||||
elif e.is_private:
|
||||
lines.append(_build_vevent(e.uid, 'Privat', e.dtstart, e.dtend, e.all_day))
|
||||
else:
|
||||
lines.append(_build_vevent(e.uid, e.summary, e.dtstart, e.dtend, e.all_day))
|
||||
lines.append('END:VCALENDAR')
|
||||
|
|
|
|||
|
|
@ -21,6 +21,7 @@ class Calendar(db.Model):
|
|||
cascade='all, delete-orphan')
|
||||
shares = db.relationship('CalendarShare', backref='calendar', lazy='dynamic',
|
||||
cascade='all, delete-orphan')
|
||||
owner = db.relationship('User', foreign_keys=[owner_id])
|
||||
|
||||
def to_dict(self):
|
||||
return {
|
||||
|
|
@ -50,6 +51,7 @@ class CalendarEvent(db.Model):
|
|||
all_day = db.Column(db.Boolean, default=False)
|
||||
recurrence_rule = db.Column(db.Text, nullable=True)
|
||||
exdates = db.Column(db.Text, nullable=True) # Komma-separiert, ISO-Datum (YYYY-MM-DD)
|
||||
is_private = db.Column(db.Boolean, default=False, nullable=False)
|
||||
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))
|
||||
|
|
@ -67,6 +69,7 @@ class CalendarEvent(db.Model):
|
|||
'all_day': self.all_day,
|
||||
'recurrence_rule': self.recurrence_rule,
|
||||
'exdates': self.exdates.split(',') if self.exdates else [],
|
||||
'is_private': bool(self.is_private),
|
||||
'created_at': self.created_at.isoformat() if self.created_at else None,
|
||||
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
||||
}
|
||||
|
|
|
|||
|
|
@ -74,6 +74,9 @@
|
|||
<div class="field">
|
||||
<label><input type="checkbox" v-model="eventForm.all_day" /> Ganztaegig</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label><input type="checkbox" v-model="eventForm.is_private" /> 🔒 Privat (Teilnehmer sehen nur den Zeitblock)</label>
|
||||
</div>
|
||||
<div class="field">
|
||||
<label>Ort</label>
|
||||
<InputText v-model="eventForm.location" fluid />
|
||||
|
|
@ -146,10 +149,27 @@
|
|||
<div v-if="selectedCal.permission === 'owner'" class="field">
|
||||
<label>Mit Benutzer teilen</label>
|
||||
<div class="share-row">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername" fluid />
|
||||
<div style="position: relative; flex: 1;">
|
||||
<InputText v-model="shareUsername" placeholder="Benutzername suchen..."
|
||||
fluid @input="onShareSearch" />
|
||||
<div v-if="shareSearchResults.length" class="user-search-popup">
|
||||
<div v-for="u in shareSearchResults" :key="u.id" class="user-result"
|
||||
@click="shareUsername = u.username; shareSearchResults = []">
|
||||
<i class="pi pi-user"></i> {{ u.username }}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<Select v-model="sharePermission" :options="permOptions" optionLabel="label" optionValue="value" />
|
||||
<Button label="Teilen" size="small" @click="shareCalendar" />
|
||||
</div>
|
||||
<div v-if="calendarShares.length" class="existing-shares">
|
||||
<div v-for="s in calendarShares" :key="s.id" class="share-perm-item">
|
||||
<i class="pi pi-user"></i>
|
||||
<span>{{ s.username }}</span>
|
||||
<span class="perm-label">{{ s.permission === 'readwrite' ? 'Lesen+Schreiben' : 'Lesen' }}</span>
|
||||
<Button icon="pi pi-trash" text size="small" severity="danger" @click="removeShare(s.id)" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="selectedCal.permission === 'owner'" class="field ical-block">
|
||||
|
|
@ -235,7 +255,7 @@ const showEventDialog = ref(false)
|
|||
const editingEvent = ref(null)
|
||||
const eventForm = ref({
|
||||
summary: '', description: '', location: '',
|
||||
calendar_id: null, dtstart: '', dtend: '', all_day: false,
|
||||
calendar_id: null, dtstart: '', dtend: '', all_day: false, is_private: false,
|
||||
})
|
||||
|
||||
// Recurrence editor state
|
||||
|
|
@ -278,6 +298,9 @@ const showCalMenu = ref(false)
|
|||
const selectedCal = ref(null)
|
||||
const shareUsername = ref('')
|
||||
const sharePermission = ref('read')
|
||||
const shareSearchResults = ref([])
|
||||
const calendarShares = ref([])
|
||||
let shareSearchTimer = null
|
||||
const icalPassword = ref('')
|
||||
const confirmDeleteEvent = ref(false)
|
||||
const confirmDeleteCal = ref(false)
|
||||
|
|
@ -560,6 +583,7 @@ function openNewEvent(start, end, allDay = false) {
|
|||
dtstart: toLocalISO(now, allDay),
|
||||
dtend: toLocalISO(later, allDay),
|
||||
all_day: allDay,
|
||||
is_private: false,
|
||||
}
|
||||
loadRRuleIntoForm('')
|
||||
showEventDialog.value = true
|
||||
|
|
@ -575,6 +599,7 @@ function openEditEvent(evt) {
|
|||
dtstart: toLocalISO(new Date(evt.dtstart), evt.all_day),
|
||||
dtend: evt.dtend ? toLocalISO(new Date(evt.dtend), evt.all_day) : '',
|
||||
all_day: evt.all_day,
|
||||
is_private: !!evt.is_private,
|
||||
}
|
||||
loadRRuleIntoForm(evt.recurrence_rule)
|
||||
showEventDialog.value = true
|
||||
|
|
@ -589,6 +614,7 @@ async function saveEvent() {
|
|||
dtstart: fromLocalISO(eventForm.value.dtstart, eventForm.value.all_day),
|
||||
dtend: eventForm.value.dtend ? fromLocalISO(eventForm.value.dtend, eventForm.value.all_day) : null,
|
||||
all_day: eventForm.value.all_day,
|
||||
is_private: eventForm.value.is_private,
|
||||
recurrence_rule: buildRRule(),
|
||||
calendar_id: eventForm.value.calendar_id,
|
||||
}
|
||||
|
|
@ -636,7 +662,22 @@ async function deleteEvent() {
|
|||
function openCalendarMenu(cal) {
|
||||
selectedCal.value = cal
|
||||
icalPassword.value = ''
|
||||
shareUsername.value = ''
|
||||
shareSearchResults.value = []
|
||||
showCalMenu.value = true
|
||||
loadShares()
|
||||
}
|
||||
|
||||
function onShareSearch() {
|
||||
clearTimeout(shareSearchTimer)
|
||||
const q = shareUsername.value.trim()
|
||||
if (q.length < 2) { shareSearchResults.value = []; return }
|
||||
shareSearchTimer = setTimeout(async () => {
|
||||
try {
|
||||
const res = await apiClient.get('/users/search', { params: { q } })
|
||||
shareSearchResults.value = res.data
|
||||
} catch { shareSearchResults.value = [] }
|
||||
}, 250)
|
||||
}
|
||||
|
||||
async function shareCalendar() {
|
||||
|
|
@ -647,6 +688,30 @@ async function shareCalendar() {
|
|||
})
|
||||
toast.add({ severity: 'success', summary: 'Kalender geteilt', life: 3000 })
|
||||
shareUsername.value = ''
|
||||
shareSearchResults.value = []
|
||||
await loadShares()
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error || err.message, life: 5000 })
|
||||
}
|
||||
}
|
||||
|
||||
async function loadShares() {
|
||||
if (!selectedCal.value || selectedCal.value.permission !== 'owner') {
|
||||
calendarShares.value = []
|
||||
return
|
||||
}
|
||||
try {
|
||||
const res = await apiClient.get(`/calendars/${selectedCal.value.id}/shares`)
|
||||
calendarShares.value = res.data
|
||||
} catch { calendarShares.value = [] }
|
||||
}
|
||||
|
||||
async function removeShare(shareId) {
|
||||
if (!selectedCal.value) return
|
||||
try {
|
||||
await apiClient.delete(`/calendars/${selectedCal.value.id}/shares/${shareId}`)
|
||||
await loadShares()
|
||||
toast.add({ severity: 'success', summary: 'Freigabe entfernt', life: 2500 })
|
||||
} catch (err) {
|
||||
toast.add({ severity: 'error', summary: 'Fehler', detail: err.response?.data?.error, life: 5000 })
|
||||
}
|
||||
|
|
@ -759,6 +824,14 @@ onMounted(async () => {
|
|||
background: var(--p-surface-100); font-size: 0.85rem; cursor: pointer; }
|
||||
.weekday-btn.active { background: var(--p-primary-100); }
|
||||
.share-row { display: flex; gap: 0.5rem; align-items: center; }
|
||||
.user-search-popup { position: absolute; top: 100%; left: 0; right: 0; z-index: 10;
|
||||
background: white; border: 1px solid var(--p-surface-200); border-radius: 4px;
|
||||
max-height: 160px; overflow-y: auto; box-shadow: 0 4px 12px rgba(0,0,0,0.1); }
|
||||
.user-result { padding: 0.5rem 0.75rem; cursor: pointer; font-size: 0.875rem; display: flex; gap: 0.5rem; align-items: center; }
|
||||
.user-result:hover { background: var(--p-primary-50); }
|
||||
.existing-shares { margin-top: 0.5rem; }
|
||||
.share-perm-item { display: flex; align-items: center; gap: 0.5rem; padding: 0.375rem 0; font-size: 0.875rem; }
|
||||
.perm-label { color: var(--p-text-muted-color); font-size: 0.75rem; }
|
||||
.ical-block { border-top: 1px solid var(--p-surface-200); padding-top: 1rem; margin-top: 1rem; }
|
||||
.ical-url { font-size: 0.8rem; display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap; }
|
||||
.ical-url code { background: var(--p-surface-100); padding: 0.25rem 0.5rem; border-radius: 4px; word-break: break-all; }
|
||||
|
|
|
|||
Loading…
Reference in New Issue