472 lines
17 KiB
Python
472 lines
17 KiB
Python
"""Calendar management: ICS upload, subscription, groups, reminders.
|
|
|
|
Every function is scoped to an explicit `user_id` so each user only ever
|
|
sees/mutates their own data (defense in depth: rows are filtered by user_id
|
|
even though they also join user-scoped groups/sources).
|
|
"""
|
|
import os
|
|
import hashlib
|
|
import tempfile
|
|
from datetime import datetime, timedelta, date
|
|
from backend.time_util import now as tz_now
|
|
from flask import current_app
|
|
from backend.models import get_db
|
|
|
|
|
|
def _uid() -> int:
|
|
"""Resolve the current user for scheduler-thread calls."""
|
|
try:
|
|
from backend.auth import current_user_id
|
|
return current_user_id() or 0
|
|
except Exception:
|
|
return 0
|
|
|
|
|
|
def _require_group_owner(db, user_id: int, group_id: int):
|
|
"""Return the group row only if it belongs to the user (else None).
|
|
Prevents cross-user writes through unvalidated group ids."""
|
|
return db.execute(
|
|
"SELECT id FROM calendar_groups WHERE id=? AND user_id=?",
|
|
(group_id, user_id)
|
|
).fetchone()
|
|
|
|
|
|
def get_groups(user_id: int = None):
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
groups = [dict(r) for r in db.execute(
|
|
"SELECT * FROM calendar_groups WHERE user_id=? ORDER BY id", (user_id,)
|
|
).fetchall()]
|
|
for g in groups:
|
|
g["sources"] = [dict(r) for r in db.execute(
|
|
"SELECT id, type, name, url, last_sync FROM calendar_sources "
|
|
"WHERE group_id=? AND user_id=?", (g["id"], user_id)
|
|
).fetchall()]
|
|
db.close()
|
|
return groups
|
|
|
|
|
|
def create_group(name: str, color: str = "#4A90D9", user_id: int = None) -> dict:
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
cur = db.execute(
|
|
"INSERT INTO calendar_groups (user_id, name, color) VALUES (?, ?, ?)",
|
|
(user_id, name, color)
|
|
)
|
|
db.commit()
|
|
gid = cur.lastrowid
|
|
row = dict(db.execute("SELECT * FROM calendar_groups WHERE id=?", (gid,)).fetchone())
|
|
db.close()
|
|
return row
|
|
|
|
|
|
def update_group(group_id: int, name: str = None, color: str = None,
|
|
enabled: bool = None, user_id: int = None):
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
fields = []
|
|
vals = []
|
|
if name is not None:
|
|
fields.append("name=?")
|
|
vals.append(name)
|
|
if color is not None:
|
|
fields.append("color=?")
|
|
vals.append(color)
|
|
if enabled is not None:
|
|
fields.append("enabled=?")
|
|
vals.append(1 if enabled else 0)
|
|
ok = False
|
|
if fields:
|
|
vals.append(user_id)
|
|
vals.append(group_id)
|
|
cur = db.execute(f"UPDATE calendar_groups SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
|
|
db.commit()
|
|
ok = cur.rowcount > 0
|
|
db.close()
|
|
return ok
|
|
|
|
|
|
def delete_group(group_id: int, user_id: int = None) -> bool:
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id IN "
|
|
"(SELECT id FROM calendar_sources WHERE user_id=? AND group_id=?)",
|
|
(user_id, user_id, group_id))
|
|
db.execute("DELETE FROM calendar_sources WHERE user_id=? AND group_id=?", (user_id, group_id))
|
|
db.execute("DELETE FROM reminders WHERE user_id=? AND calendar_group_id=?", (user_id, group_id))
|
|
cur = db.execute("DELETE FROM calendar_groups WHERE user_id=? AND id=?", (user_id, group_id))
|
|
db.commit()
|
|
db.close()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def upload_ics(group_id: int, name: str, ics_content: str, user_id: int = None):
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
if not _require_group_owner(db, user_id, group_id):
|
|
db.close()
|
|
raise ValueError("分组不存在或无权访问")
|
|
cur = db.execute(
|
|
"INSERT INTO calendar_sources (user_id, group_id, type, name, ics_data) "
|
|
"VALUES (?, ?, 'upload', ?, ?)",
|
|
(user_id, group_id, name, ics_content)
|
|
)
|
|
source_id = cur.lastrowid
|
|
db.commit()
|
|
_parse_and_store_events(db, user_id, source_id, ics_content)
|
|
db.close()
|
|
current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id)
|
|
|
|
|
|
def subscribe_ics(group_id: int, name: str, url: str, user_id: int = None):
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
from backend.url_util import is_safe_public_url
|
|
if not is_safe_public_url(url):
|
|
raise ValueError("订阅地址无效或不安全(仅允许公网 http/https 地址)")
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
if not _require_group_owner(db, user_id, group_id):
|
|
db.close()
|
|
raise ValueError("分组不存在或无权访问")
|
|
cur = db.execute(
|
|
"INSERT INTO calendar_sources (user_id, group_id, type, name, url) "
|
|
"VALUES (?, ?, 'subscription', ?, ?)",
|
|
(user_id, group_id, name, url)
|
|
)
|
|
source_id = cur.lastrowid
|
|
db.commit()
|
|
db.close()
|
|
sync_subscription(source_id, user_id)
|
|
|
|
|
|
def sync_subscription(source_id: int, user_id: int = None):
|
|
"""Pull and parse a remote ICS subscription."""
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
row = db.execute("SELECT * FROM calendar_sources WHERE id=? AND user_id=?",
|
|
(source_id, user_id)).fetchone()
|
|
if not row or row["type"] != "subscription" or not row["url"]:
|
|
db.close()
|
|
return
|
|
try:
|
|
from backend.url_util import safe_http_get
|
|
resp = safe_http_get(row["url"], timeout=15)
|
|
if resp is None:
|
|
current_app.logger.warning(f"Sync skipped: unsafe URL for source {source_id}")
|
|
return
|
|
ics_data = resp.text
|
|
# Clear old events for this source
|
|
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?",
|
|
(user_id, source_id))
|
|
_parse_and_store_events(db, user_id, source_id, ics_data)
|
|
db.execute("UPDATE calendar_sources SET last_sync=datetime('now') WHERE id=? AND user_id=?",
|
|
(source_id, user_id))
|
|
db.commit()
|
|
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
|
|
except Exception as e:
|
|
current_app.logger.error(f"Sync failed for source {source_id}: {e}")
|
|
finally:
|
|
db.close()
|
|
|
|
|
|
def sync_all_subscriptions(user_id: int = None):
|
|
"""Sync all subscription sources for one user (or all users when user_id is None)."""
|
|
if user_id is None:
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
rows = db.execute("SELECT user_id, id FROM calendar_sources WHERE type='subscription' AND url IS NOT NULL").fetchall()
|
|
db.close()
|
|
for r in rows:
|
|
sync_subscription(r["id"], r["user_id"])
|
|
return
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
sources = db.execute(
|
|
"SELECT id FROM calendar_sources WHERE user_id=? AND type='subscription' AND url IS NOT NULL",
|
|
(user_id,)
|
|
).fetchall()
|
|
db.close()
|
|
for s in sources:
|
|
sync_subscription(s["id"], user_id)
|
|
|
|
|
|
def delete_source(source_id: int, user_id: int = None) -> bool:
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
db.execute("DELETE FROM calendar_events WHERE user_id=? AND source_id=?", (user_id, source_id))
|
|
cur = db.execute("DELETE FROM calendar_sources WHERE user_id=? AND id=?", (user_id, source_id))
|
|
db.commit()
|
|
db.close()
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def get_events(group_ids: list = None, days: int = 7, user_id: int = None):
|
|
"""Get events for enabled groups, limited to N days ahead."""
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
now = tz_now().strftime("%Y%m%dT000000")
|
|
end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959")
|
|
|
|
if group_ids:
|
|
placeholders = ','.join('?' * len(group_ids))
|
|
rows = db.execute(f"""
|
|
SELECT e.*, s.group_id FROM calendar_events e
|
|
JOIN calendar_sources s ON e.source_id = s.id
|
|
JOIN calendar_groups g ON s.group_id = g.id
|
|
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1
|
|
AND s.group_id IN ({placeholders})
|
|
AND e.dtend >= ? AND e.dtstart <= ?
|
|
ORDER BY e.dtstart
|
|
""", (user_id, user_id, *group_ids, now, end)).fetchall()
|
|
else:
|
|
rows = db.execute("""
|
|
SELECT e.*, s.group_id FROM calendar_events e
|
|
JOIN calendar_sources s ON e.source_id = s.id
|
|
JOIN calendar_groups g ON s.group_id = g.id
|
|
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1
|
|
AND e.dtend >= ? AND e.dtstart <= ?
|
|
ORDER BY e.dtstart
|
|
""", (user_id, user_id, now, end)).fetchall()
|
|
|
|
db.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def get_upcoming_reminders(user_id: int = None):
|
|
"""Check for calendar events whose reminders are due."""
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
now = tz_now()
|
|
rows = db.execute("""
|
|
SELECT e.*, r.minutes_before, g.name as group_name FROM calendar_events e
|
|
JOIN calendar_sources s ON e.source_id = s.id
|
|
JOIN calendar_groups g ON s.group_id = g.id
|
|
JOIN reminders r ON r.calendar_group_id = g.id
|
|
WHERE e.user_id=? AND g.user_id=? AND g.enabled=1 AND r.enabled=1
|
|
""", (user_id, user_id)).fetchall()
|
|
db.close()
|
|
|
|
due = []
|
|
for r in rows:
|
|
try:
|
|
dt = datetime.strptime(r["dtstart"], "%Y%m%dT%H%M%S")
|
|
remind_at = dt - timedelta(minutes=r["minutes_before"])
|
|
if remind_at <= now < dt:
|
|
due.append(dict(r))
|
|
except (ValueError, TypeError):
|
|
pass
|
|
return due
|
|
|
|
|
|
def set_reminder(group_id: int, minutes_before: int, enabled: bool = True, user_id: int = None):
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
if not _require_group_owner(db, user_id, group_id):
|
|
db.close()
|
|
raise ValueError("分组不存在或无权访问")
|
|
existing = db.execute("SELECT id FROM reminders WHERE user_id=? AND calendar_group_id=?",
|
|
(user_id, group_id)).fetchone()
|
|
if existing:
|
|
db.execute("UPDATE reminders SET minutes_before=?, enabled=? WHERE user_id=? AND calendar_group_id=?",
|
|
(minutes_before, 1 if enabled else 0, user_id, group_id))
|
|
else:
|
|
db.execute("INSERT INTO reminders (user_id, calendar_group_id, minutes_before, enabled) "
|
|
"VALUES (?, ?, ?, ?)",
|
|
(user_id, group_id, minutes_before, 1 if enabled else 0))
|
|
db.commit()
|
|
db.close()
|
|
|
|
|
|
def get_or_create_manual_source(group_id: int, user_id: int = None) -> int:
|
|
"""Get or create a 'manual' source for the group."""
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
row = db.execute(
|
|
"SELECT id FROM calendar_sources WHERE user_id=? AND group_id=? AND type='manual'",
|
|
(user_id, group_id)
|
|
).fetchone()
|
|
if row:
|
|
db.close()
|
|
return row["id"]
|
|
cur = db.execute(
|
|
"INSERT INTO calendar_sources (user_id, group_id, type, name) VALUES (?, ?, 'manual', '手动添加')",
|
|
(user_id, group_id)
|
|
)
|
|
db.commit()
|
|
sid = cur.lastrowid
|
|
db.close()
|
|
return sid
|
|
|
|
|
|
def _normalize_dt(dt_str: str) -> str:
|
|
"""Ensure date string has the 'T' separator if it looks like YYYYMMDDHHMMSS."""
|
|
if not dt_str or 'T' in dt_str:
|
|
return dt_str
|
|
if len(dt_str) >= 14:
|
|
return dt_str[:8] + 'T' + dt_str[8:]
|
|
return dt_str
|
|
|
|
|
|
def add_event(group_id: int, summary: str, dtstart: str, dtend: str = None,
|
|
description: str = "", location: str = "", is_all_day: bool = False,
|
|
user_id: int = None) -> dict:
|
|
"""Manually add a single event to a group."""
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
dtstart = _normalize_dt(dtstart)
|
|
dtend = _normalize_dt(dtend)
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
if not _require_group_owner(db, user_id, group_id):
|
|
db.close()
|
|
raise ValueError("分组不存在或无权访问")
|
|
source_id = get_or_create_manual_source(group_id, user_id)
|
|
if dtend is None:
|
|
dtend = dtstart
|
|
cur = db.execute("""
|
|
INSERT INTO calendar_events (user_id, source_id, summary, dtstart, dtend, description, location, is_all_day)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (user_id, source_id, summary, dtstart, dtend, description, location, 1 if is_all_day else 0))
|
|
db.commit()
|
|
eid = cur.lastrowid
|
|
row = dict(db.execute("SELECT * FROM calendar_events WHERE id=?", (eid,)).fetchone())
|
|
db.close()
|
|
current_app.event_bus.publish("calendar_updated", {"group_id": group_id}, user_id=user_id)
|
|
return row
|
|
|
|
|
|
def get_events_by_group(group_id: int, days: int = 30, user_id: int = None) -> list:
|
|
"""Get all events for a specific group."""
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
now = tz_now().strftime("%Y%m%dT000000")
|
|
end = (tz_now() + timedelta(days=days)).strftime("%Y%m%dT235959")
|
|
rows = db.execute("""
|
|
SELECT e.*, s.group_id FROM calendar_events e
|
|
JOIN calendar_sources s ON e.source_id = s.id
|
|
WHERE e.user_id=? AND s.user_id=? AND s.group_id=? AND e.dtend >= ? AND e.dtstart <= ?
|
|
ORDER BY e.dtstart
|
|
""", (user_id, user_id, group_id, now, end)).fetchall()
|
|
db.close()
|
|
return [dict(r) for r in rows]
|
|
|
|
|
|
def update_event(event_id: int, summary: str = None, dtstart: str = None,
|
|
dtend: str = None, description: str = None, location: str = None,
|
|
user_id: int = None) -> bool:
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
dtstart = _normalize_dt(dtstart)
|
|
dtend = _normalize_dt(dtend)
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
fields = []
|
|
vals = []
|
|
for key, val in [("summary", summary), ("dtstart", dtstart), ("dtend", dtend),
|
|
("description", description), ("location", location)]:
|
|
if val is not None:
|
|
fields.append(f"{key}=?")
|
|
vals.append(val)
|
|
if not fields:
|
|
db.close()
|
|
return False
|
|
vals.append(user_id)
|
|
vals.append(event_id)
|
|
cur = db.execute(f"UPDATE calendar_events SET {', '.join(fields)} WHERE user_id=? AND id=?", vals)
|
|
db.commit()
|
|
ok = cur.rowcount > 0
|
|
# Get group_id for event broadcast
|
|
row = db.execute("""
|
|
SELECT s.group_id FROM calendar_events e
|
|
JOIN calendar_sources s ON e.source_id = s.id
|
|
WHERE e.user_id=? AND e.id=?
|
|
""", (user_id, event_id)).fetchone()
|
|
db.close()
|
|
if ok and row:
|
|
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
|
|
return ok
|
|
|
|
|
|
def delete_event(event_id: int, user_id: int = None) -> bool:
|
|
if user_id is None:
|
|
user_id = _uid()
|
|
db = get_db(current_app.config["DB_PATH"])
|
|
row = db.execute("""
|
|
SELECT s.group_id FROM calendar_events e
|
|
JOIN calendar_sources s ON e.source_id = s.id
|
|
WHERE e.user_id=? AND e.id=?
|
|
""", (user_id, event_id)).fetchone()
|
|
cur = db.execute("DELETE FROM calendar_events WHERE user_id=? AND id=?", (user_id, event_id))
|
|
db.commit()
|
|
db.close()
|
|
if cur.rowcount > 0 and row:
|
|
current_app.event_bus.publish("calendar_updated", {"group_id": row["group_id"]}, user_id=user_id)
|
|
return cur.rowcount > 0
|
|
|
|
|
|
def _parse_and_store_events(db, user_id: int, source_id: int, ics_data: str):
|
|
"""Parse ICS content and store events in DB, expanding recurring events."""
|
|
from icalendar import Calendar
|
|
import recurring_ical_events
|
|
try:
|
|
cal = Calendar.from_ical(ics_data)
|
|
except Exception:
|
|
return
|
|
|
|
# Determine the expansion window: from start of today up to 365 days ahead
|
|
today = tz_now().date()
|
|
window_start = datetime(today.year, today.month, today.day)
|
|
window_end = window_start + timedelta(days=365)
|
|
|
|
try:
|
|
expanded = recurring_ical_events.of(cal).between(window_start, window_end)
|
|
except Exception:
|
|
expanded = []
|
|
|
|
for comp in expanded:
|
|
if comp.name != "VEVENT":
|
|
continue
|
|
uid = str(comp.get("uid", ""))
|
|
summary = str(comp.get("summary", ""))
|
|
description = str(comp.get("description", ""))
|
|
location = str(comp.get("location", ""))
|
|
|
|
dtstart = comp.get("dtstart")
|
|
dtend = comp.get("dtend")
|
|
|
|
if dtstart is None:
|
|
continue
|
|
|
|
dtstart_str = _format_dt(dtstart.dt)
|
|
if dtend:
|
|
dtend_str = _format_dt(dtend.dt)
|
|
else:
|
|
dtend_str = dtstart_str
|
|
|
|
is_all_day = 1 if (hasattr(dtstart.dt, 'date') and not hasattr(dtstart.dt, 'time')) else 0
|
|
|
|
db.execute("""
|
|
INSERT INTO calendar_events (user_id, source_id, uid, summary, description, location, dtstart, dtend, is_all_day)
|
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
|
""", (user_id, source_id, uid, summary, description, location, dtstart_str, dtend_str, is_all_day))
|
|
|
|
db.commit()
|
|
|
|
|
|
def _format_dt(dt_val) -> str:
|
|
"""Format a datetime/date to the 'YYYYMMDDTHHMMSS' string."""
|
|
from datetime import date, datetime as dt_cls
|
|
if isinstance(dt_val, dt_cls):
|
|
return dt_val.strftime("%Y%m%dT%H%M%S")
|
|
elif isinstance(dt_val, date):
|
|
return dt_val.strftime("%Y%m%dT000000")
|
|
return str(dt_val)
|