import secrets import uuid from datetime import datetime, timezone from flask import request, jsonify from app.api import api_bp from app.api.auth import token_required from app.extensions import db from app.models.calendar import Calendar, CalendarEvent, CalendarShare from app.models.user import User def _get_calendar_or_err(cal_id, user, need_write=False): cal = db.session.get(Calendar, cal_id) if not cal: return None, (jsonify({'error': 'Kalender nicht gefunden'}), 404) if cal.owner_id == user.id: return cal, None share = CalendarShare.query.filter_by( calendar_id=cal_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 cal, None # --- Calendars --- @api_bp.route('/calendars', methods=['GET']) @token_required def list_calendars(): user = request.current_user own = Calendar.query.filter_by(owner_id=user.id).all() shared_ids = [s.calendar_id for s in CalendarShare.query.filter_by(shared_with_id=user.id).all()] shared = Calendar.query.filter(Calendar.id.in_(shared_ids)).all() if shared_ids else [] result = [] for c in own: d = c.to_dict() d['permission'] = 'owner' result.append(d) for c in shared: d = c.to_dict() share = CalendarShare.query.filter_by( calendar_id=c.id, shared_with_id=user.id ).first() d['permission'] = share.permission if share else 'read' d['owner_name'] = c.owner.username result.append(d) return jsonify(result), 200 @api_bp.route('/calendars', methods=['POST']) @token_required def create_calendar(): user = request.current_user data = request.get_json() name = data.get('name', '').strip() if not name: return jsonify({'error': 'Name erforderlich'}), 400 cal = Calendar( owner_id=user.id, name=name, color=data.get('color', '#3788d8'), description=data.get('description', ''), ) db.session.add(cal) db.session.commit() return jsonify(cal.to_dict()), 201 @api_bp.route('/calendars/', methods=['PUT']) @token_required def update_calendar(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 oder keine Berechtigung'}), 404 data = request.get_json() if 'name' in data: cal.name = data['name'].strip() if 'color' in data: cal.color = data['color'] if 'description' in data: cal.description = data['description'] db.session.commit() return jsonify(cal.to_dict()), 200 @api_bp.route('/calendars/', methods=['DELETE']) @token_required def delete_calendar(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 oder keine Berechtigung'}), 404 db.session.delete(cal) db.session.commit() return jsonify({'message': 'Kalender geloescht'}), 200 # --- Events --- @api_bp.route('/calendars//events', methods=['GET']) @token_required def list_events(cal_id): user = request.current_user cal, err = _get_calendar_or_err(cal_id, user) if err: return err start = request.args.get('start') end = request.args.get('end') query = CalendarEvent.query.filter_by(calendar_id=cal_id) if start: try: start_dt = datetime.fromisoformat(start) query = query.filter(CalendarEvent.dtend >= start_dt) except ValueError: pass if end: try: end_dt = datetime.fromisoformat(end) query = query.filter(CalendarEvent.dtstart <= end_dt) except ValueError: pass events = query.order_by(CalendarEvent.dtstart).all() return jsonify([e.to_dict() for e in events]), 200 @api_bp.route('/calendars//events', methods=['POST']) @token_required def create_event(cal_id): user = request.current_user cal, err = _get_calendar_or_err(cal_id, user, need_write=True) if err: return err data = request.get_json() summary = data.get('summary', '').strip() if not summary: return jsonify({'error': 'Zusammenfassung erforderlich'}), 400 dtstart = data.get('dtstart') dtend = data.get('dtend') all_day = data.get('all_day', False) if not dtstart: return jsonify({'error': 'Startdatum erforderlich'}), 400 try: dtstart_dt = datetime.fromisoformat(dtstart) dtend_dt = datetime.fromisoformat(dtend) if dtend else dtstart_dt except ValueError: return jsonify({'error': 'Ungueltiges Datumsformat'}), 400 event_uid = str(uuid.uuid4()) # 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', '')) event = CalendarEvent( calendar_id=cal_id, uid=event_uid, ical_data=ical_data, summary=summary, dtstart=dtstart_dt, dtend=dtend_dt, all_day=all_day, recurrence_rule=data.get('recurrence_rule'), ) db.session.add(event) db.session.commit() return jsonify(event.to_dict()), 201 @api_bp.route('/events/', methods=['PUT']) @token_required def update_event(event_id): user = request.current_user event = db.session.get(CalendarEvent, event_id) if not event: return jsonify({'error': 'Event nicht gefunden'}), 404 cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True) if err: return err data = request.get_json() if 'summary' in data: event.summary = data['summary'].strip() if 'dtstart' in data: event.dtstart = datetime.fromisoformat(data['dtstart']) if 'dtend' in data: event.dtend = datetime.fromisoformat(data['dtend']) if 'all_day' in data: event.all_day = data['all_day'] if 'recurrence_rule' in data: event.recurrence_rule = data['recurrence_rule'] if 'calendar_id' in data: new_cal, cerr = _get_calendar_or_err(data['calendar_id'], user, need_write=True) if cerr: return cerr event.calendar_id = data['calendar_id'] event.ical_data = _build_ical( event.uid, event.summary, event.dtstart, event.dtend, event.all_day, data.get('description', ''), data.get('location', ''), event.recurrence_rule or '' ) event.updated_at = datetime.now(timezone.utc) db.session.commit() return jsonify(event.to_dict()), 200 @api_bp.route('/events/', methods=['DELETE']) @token_required def delete_event(event_id): user = request.current_user event = db.session.get(CalendarEvent, event_id) if not event: return jsonify({'error': 'Event nicht gefunden'}), 404 cal, err = _get_calendar_or_err(event.calendar_id, user, need_write=True) if err: return err db.session.delete(event) db.session.commit() return jsonify({'message': 'Event geloescht'}), 200 # --- Calendar sharing --- @api_bp.route('/calendars//share', methods=['POST']) @token_required def share_calendar(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': 'Nur der Eigentuemer kann teilen'}), 403 data = request.get_json() username = data.get('username', '').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 = CalendarShare.query.filter_by( calendar_id=cal_id, shared_with_id=target.id ).first() is_new = not existing if existing: existing.permission = permission else: share = CalendarShare( calendar_id=cal_id, shared_with_id=target.id, permission=permission ) db.session.add(share) db.session.commit() if is_new: try: from app.services.system_mail import notify_calendar_shared notify_calendar_shared(cal.name, user.username, target, permission) except Exception: pass return jsonify({'message': f'Kalender mit {username} geteilt'}), 200 @api_bp.route('/calendars//shares', methods=['GET']) @token_required def list_calendar_shares(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 shares = CalendarShare.query.filter_by(calendar_id=cal_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('/calendars//shares/', methods=['DELETE']) @token_required def remove_calendar_share(cal_id, share_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 share = db.session.get(CalendarShare, share_id) if not share or share.calendar_id != cal_id: return jsonify({'error': 'Freigabe nicht gefunden'}), 404 db.session.delete(share) db.session.commit() return jsonify({'message': 'Freigabe entfernt'}), 200 # --- iCal Export --- @api_bp.route('/calendars//ical-link', methods=['POST']) @token_required def generate_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 = secrets.token_urlsafe(32) db.session.commit() return jsonify({ 'ical_url': f'/ical/{cal.ical_token}', 'token': cal.ical_token, }), 200 def ical_export(token): cal = Calendar.query.filter_by(ical_token=token).first() if not cal: return jsonify({'error': 'Nicht gefunden'}), 404 events = CalendarEvent.query.filter_by(calendar_id=cal.id).all() lines = [ 'BEGIN:VCALENDAR', 'VERSION:2.0', 'PRODID:-//Mini-Cloud//DE', f'X-WR-CALNAME:{cal.name}', ] 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', headers={'Content-Disposition': f'attachment; filename="{cal.name}.ics"'}, ) # --- Helpers --- def _format_dt(dt, all_day=False): if all_day: return dt.strftime('%Y%m%d') return dt.strftime('%Y%m%dT%H%M%SZ') def _build_vevent(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''): lines = [ 'BEGIN:VEVENT', f'UID:{uid}', ] if all_day: lines.append(f'DTSTART;VALUE=DATE:{_format_dt(dtstart, True)}') lines.append(f'DTEND;VALUE=DATE:{_format_dt(dtend, True)}') else: lines.append(f'DTSTART:{_format_dt(dtstart)}') lines.append(f'DTEND:{_format_dt(dtend)}') lines.append(f'SUMMARY:{summary}') if description: lines.append(f'DESCRIPTION:{description}') if location: lines.append(f'LOCATION:{location}') if rrule: lines.append(f'RRULE:{rrule}') lines.append(f'DTSTAMP:{datetime.now(timezone.utc).strftime("%Y%m%dT%H%M%SZ")}') lines.append('END:VEVENT') return '\r\n'.join(lines) def _build_ical(uid, summary, dtstart, dtend, all_day, description='', location='', rrule=''): return _build_vevent(uid, summary, dtstart, dtend, all_day, description, location, rrule)