[verified] feat: add external calendar subscriptions
This commit is contained in:
@@ -152,6 +152,18 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
|
||||
content_names = set(names) - {"manifest.json"}
|
||||
if set(checksums) != content_names:
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 与 ZIP 条目不一致")
|
||||
# Calendar subscriptions are an additive backup-v2 entity. Accept archives
|
||||
# produced by older helpers that checksum the new file but omit its count.
|
||||
optional_entities = {"calendar_subscriptions"}
|
||||
undeclared_optional = {
|
||||
f"data/{name}.json" for name in optional_entities - set(declared_entities)
|
||||
}
|
||||
if set(declared_entities) != {
|
||||
name[5:-5]
|
||||
for name in content_names - undeclared_optional
|
||||
if name.startswith("data/") and name.endswith(".json")
|
||||
}:
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
|
||||
entities: dict[str, list[dict]] = {}
|
||||
files: dict[str, StagedBlob] = {}
|
||||
for index, name in enumerate(sorted(content_names)):
|
||||
@@ -170,7 +182,11 @@ def parse_archive_path(path: Path, *, max_archive_bytes: int = MAX_ARCHIVE_BYTES
|
||||
if not isinstance(checksums[name], str) or actual_digest != checksums[name]:
|
||||
raise backup_error("backup_checksum_mismatch", "备份校验和不匹配")
|
||||
if set(declared_entities) != set(entities):
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
|
||||
required_declared = set(entities) - optional_entities
|
||||
if set(declared_entities) != required_declared or any(
|
||||
entities.get(name) for name in optional_entities - set(declared_entities)
|
||||
):
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 实体清单不一致")
|
||||
if any(type(count) is not int or count < 0 or count != len(entities[name]) for name, count in declared_entities.items()):
|
||||
raise backup_error("backup_manifest_mismatch", "manifest 实体数量不一致")
|
||||
except HTTPException:
|
||||
|
||||
@@ -19,6 +19,7 @@ from backend.models import (
|
||||
BackupImport,
|
||||
BackupImportEntity,
|
||||
BackupPreflight,
|
||||
CalendarSubscription,
|
||||
Countdown,
|
||||
Folder,
|
||||
Habit,
|
||||
@@ -65,6 +66,7 @@ ENTITY_MODELS = {
|
||||
"habit_pauses": HabitPause,
|
||||
"countdowns": Countdown,
|
||||
"memos": Memo,
|
||||
"calendar_subscriptions": CalendarSubscription,
|
||||
"attachments": Attachment,
|
||||
}
|
||||
RELATIONS = {
|
||||
@@ -392,6 +394,9 @@ def _validate_recurrence_graph(parsed: ParsedArchive) -> None:
|
||||
|
||||
def validate_archive(parsed: ParsedArchive) -> None:
|
||||
unknown = set(parsed.entities) - set(ENTITY_MODELS)
|
||||
# Calendar subscriptions were introduced after backup v2. Treat their
|
||||
# absence as an empty collection so archives from older Dodo releases remain restorable.
|
||||
parsed.entities.setdefault("calendar_subscriptions", [])
|
||||
missing = set(ENTITY_MODELS) - set(parsed.entities)
|
||||
if unknown:
|
||||
raise backup_error("backup_entity_unknown", "备份包含未知实体")
|
||||
@@ -611,7 +616,16 @@ async def restore_v2(
|
||||
await db.execute(delete(HabitPause).where(
|
||||
HabitPause.habit_id.in_(select(Habit.id).where(Habit.user_id == user.id))
|
||||
))
|
||||
for model in (Attachment, Memo, Countdown, Task, Habit, TaskList, Folder):
|
||||
for model in (
|
||||
Attachment,
|
||||
CalendarSubscription,
|
||||
Memo,
|
||||
Countdown,
|
||||
Task,
|
||||
Habit,
|
||||
TaskList,
|
||||
Folder,
|
||||
):
|
||||
await db.execute(delete(model).where(model.user_id == user.id))
|
||||
await db.execute(delete(BackupImportEntity).where(BackupImportEntity.user_id == user.id))
|
||||
await db.execute(delete(BackupImport).where(BackupImport.user_id == user.id))
|
||||
|
||||
@@ -0,0 +1,233 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import http.client
|
||||
import ipaddress
|
||||
import socket
|
||||
import ssl
|
||||
import urllib.parse
|
||||
from dataclasses import dataclass
|
||||
from datetime import UTC, date, datetime, time, timedelta
|
||||
from itertools import islice
|
||||
from typing import Any
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from dateutil.rrule import rrulestr
|
||||
from fastapi import HTTPException
|
||||
from icalendar import Calendar
|
||||
|
||||
MAX_ICS_BYTES = 2_000_000
|
||||
MAX_REDIRECTS = 3
|
||||
DEFAULT_RECURRENCE_LIMIT = 10_000
|
||||
TIMEOUT_SECONDS = 10
|
||||
_ALLOWED_CONTENT_TYPES = {"text/calendar", "text/plain", "application/octet-stream"}
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class FetchResult:
|
||||
content: bytes | None
|
||||
etag: str | None
|
||||
last_modified: str | None
|
||||
not_modified: bool
|
||||
|
||||
|
||||
def _is_global(value: str) -> bool:
|
||||
return ipaddress.ip_address(value.split("%", 1)[0]).is_global
|
||||
|
||||
|
||||
def validate_calendar_url(url: str) -> tuple[str, str, int]:
|
||||
try:
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
port = parsed.port or (443 if parsed.scheme == "https" else 80)
|
||||
except ValueError as exc:
|
||||
raise HTTPException(422, "invalid calendar URL") from exc
|
||||
if (
|
||||
parsed.scheme not in {"http", "https"}
|
||||
or not parsed.hostname
|
||||
or parsed.username is not None
|
||||
or parsed.password is not None
|
||||
or parsed.fragment
|
||||
):
|
||||
raise HTTPException(422, "calendar URL must be HTTP(S) without userinfo or fragment")
|
||||
try:
|
||||
infos = socket.getaddrinfo(parsed.hostname, port, type=socket.SOCK_STREAM)
|
||||
except socket.gaierror as exc:
|
||||
raise HTTPException(422, "calendar host cannot be resolved") from exc
|
||||
addresses = {item[4][0] for item in infos}
|
||||
if not addresses or not all(_is_global(address) for address in addresses):
|
||||
raise HTTPException(422, "calendar host must resolve only to public addresses")
|
||||
return url, min(addresses), port
|
||||
|
||||
|
||||
def _request(url: str, headers: dict[str, str]) -> tuple[int, list[tuple[str, str]], str, bytes]:
|
||||
_, ip, port = validate_calendar_url(url)
|
||||
parsed = urllib.parse.urlsplit(url)
|
||||
target = f"[{ip}]" if ":" in ip else ip
|
||||
host = parsed.hostname or ""
|
||||
if parsed.port:
|
||||
host = f"{host}:{parsed.port}"
|
||||
request_headers = {"Host": host, "User-Agent": "dodo-calendar-fetch/1.0", **headers}
|
||||
path = urllib.parse.urlunsplit(("", "", parsed.path or "/", parsed.query, ""))
|
||||
connection: http.client.HTTPConnection
|
||||
if parsed.scheme == "https":
|
||||
connection = http.client.HTTPSConnection(target, port=port, timeout=TIMEOUT_SECONDS)
|
||||
else:
|
||||
connection = http.client.HTTPConnection(target, port=port, timeout=TIMEOUT_SECONDS)
|
||||
try:
|
||||
if parsed.scheme == "https":
|
||||
raw = socket.create_connection((ip, port), timeout=TIMEOUT_SECONDS)
|
||||
connection.sock = ssl.create_default_context().wrap_socket(
|
||||
raw, server_hostname=parsed.hostname
|
||||
)
|
||||
connection.request("GET", path, headers=request_headers)
|
||||
response = connection.getresponse()
|
||||
length = response.getheader("Content-Length")
|
||||
if length and int(length) > MAX_ICS_BYTES:
|
||||
raise HTTPException(413, "calendar exceeds 2MB")
|
||||
body = response.read(MAX_ICS_BYTES + 1)
|
||||
return response.status, response.getheaders(), response.getheader("Content-Type") or "", body
|
||||
except HTTPException:
|
||||
raise
|
||||
except (OSError, http.client.HTTPException, ValueError) as exc:
|
||||
raise HTTPException(502, "calendar upstream unavailable") from exc
|
||||
finally:
|
||||
connection.close()
|
||||
|
||||
|
||||
def fetch_calendar(url: str, *, etag: str | None = None, last_modified: str | None = None) -> FetchResult:
|
||||
headers = {}
|
||||
if etag:
|
||||
headers["If-None-Match"] = etag
|
||||
if last_modified:
|
||||
headers["If-Modified-Since"] = last_modified
|
||||
current = url
|
||||
for redirect_count in range(MAX_REDIRECTS + 1):
|
||||
status, response_headers, content_type, body = _request(current, headers)
|
||||
header_map = {key.lower(): value for key, value in response_headers}
|
||||
if status in {301, 302, 303, 307, 308}:
|
||||
if redirect_count == MAX_REDIRECTS or not header_map.get("location"):
|
||||
raise HTTPException(502, "calendar redirect limit exceeded")
|
||||
current = urllib.parse.urljoin(current, header_map["location"])
|
||||
validate_calendar_url(current)
|
||||
continue
|
||||
if status == 304:
|
||||
return FetchResult(None, etag, last_modified, True)
|
||||
if status >= 400:
|
||||
raise HTTPException(502, f"calendar upstream returned HTTP {status}")
|
||||
if len(body) > MAX_ICS_BYTES:
|
||||
raise HTTPException(413, "calendar exceeds 2MB")
|
||||
if content_type.split(";", 1)[0].lower() not in _ALLOWED_CONTENT_TYPES:
|
||||
raise HTTPException(422, "URL did not return an iCalendar document")
|
||||
return FetchResult(body, header_map.get("etag"), header_map.get("last-modified"), False)
|
||||
raise HTTPException(502, "calendar redirect limit exceeded")
|
||||
|
||||
|
||||
def _localize(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]:
|
||||
if isinstance(value, datetime):
|
||||
return (value if value.tzinfo else value.replace(tzinfo=timezone)), False
|
||||
return datetime.combine(value, time.min, tzinfo=timezone), True
|
||||
|
||||
|
||||
def _utc(value: date | datetime, timezone: ZoneInfo) -> tuple[datetime, bool]:
|
||||
localized, all_day = _localize(value, timezone)
|
||||
return localized.astimezone(UTC), all_day
|
||||
|
||||
|
||||
def _duration(event: Any, starts_at: datetime, all_day: bool, timezone: ZoneInfo) -> timedelta:
|
||||
if event.get("dtend"):
|
||||
ends_at, _ = _utc(event.decoded("dtend"), timezone)
|
||||
return max(ends_at - starts_at, timedelta())
|
||||
if event.get("duration"):
|
||||
return event.decoded("duration")
|
||||
return timedelta(days=1) if all_day else timedelta(hours=1)
|
||||
|
||||
|
||||
def _exdates(event: Any, timezone: ZoneInfo) -> set[datetime]:
|
||||
values = event.get("exdate")
|
||||
if not values:
|
||||
return set()
|
||||
excluded = set()
|
||||
for item in values if isinstance(values, list) else [values]:
|
||||
for value in getattr(item, "dts", []):
|
||||
excluded.add(_utc(value.dt, timezone)[0])
|
||||
return excluded
|
||||
|
||||
|
||||
def _overlaps(start: datetime, end: datetime, window_start: datetime, window_end: datetime) -> bool:
|
||||
return start < window_end and end > window_start
|
||||
|
||||
|
||||
def _event_dict(event: Any, source: str, color: str, start: datetime, end: datetime, all_day: bool) -> dict:
|
||||
uid = str(event.get("uid") or "")
|
||||
title = str(event.get("summary") or "Untitled event").strip() or "Untitled event"
|
||||
return {
|
||||
"id": f"{uid or title}:{start.isoformat()}",
|
||||
"title": title,
|
||||
"starts_at": start,
|
||||
"ends_at": end,
|
||||
"all_day": all_day,
|
||||
"source_name": source,
|
||||
"color": color,
|
||||
}
|
||||
|
||||
|
||||
def parse_ics_events(
|
||||
content: bytes | str,
|
||||
source_name: str,
|
||||
color: str,
|
||||
window_start: datetime,
|
||||
window_end: datetime,
|
||||
timezone_name: str,
|
||||
*,
|
||||
recurrence_limit: int = DEFAULT_RECURRENCE_LIMIT,
|
||||
) -> list[dict]:
|
||||
try:
|
||||
timezone = ZoneInfo(timezone_name)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise ValueError("invalid timezone") from exc
|
||||
try:
|
||||
calendar = Calendar.from_ical(content)
|
||||
except Exception as exc:
|
||||
raise ValueError("invalid iCalendar document") from exc
|
||||
components = list(calendar.walk("VEVENT"))
|
||||
overrides = {}
|
||||
for event in components:
|
||||
recurrence_id = event.get("recurrence-id")
|
||||
if recurrence_id:
|
||||
overrides[(str(event.get("uid") or ""), _utc(event.decoded("recurrence-id"), timezone)[0])] = event
|
||||
events = []
|
||||
for event in components:
|
||||
if not event.get("dtstart") or event.get("recurrence-id") or str(event.get("status") or "").upper() == "CANCELLED":
|
||||
continue
|
||||
local_start, all_day = _localize(event.decoded("dtstart"), timezone)
|
||||
start = local_start.astimezone(UTC)
|
||||
duration = _duration(event, start, all_day, timezone)
|
||||
uid = str(event.get("uid") or "")
|
||||
if event.get("rrule"):
|
||||
try:
|
||||
rule = rrulestr(event.get("rrule").to_ical().decode(), dtstart=local_start)
|
||||
bounded = rule.xafter(window_start - duration, count=recurrence_limit + 1, inc=True)
|
||||
occurrences = [item for item in islice(bounded, recurrence_limit + 1) if item < window_end]
|
||||
except Exception as exc:
|
||||
raise ValueError("invalid recurrence rule") from exc
|
||||
if len(occurrences) > recurrence_limit:
|
||||
raise ValueError("recurrence limit exceeded")
|
||||
excluded = _exdates(event, timezone)
|
||||
for occurrence in occurrences:
|
||||
occurrence = (occurrence if occurrence.tzinfo else occurrence.replace(tzinfo=timezone)).astimezone(UTC)
|
||||
if occurrence in excluded or (uid, occurrence) in overrides:
|
||||
continue
|
||||
end = occurrence + duration
|
||||
if _overlaps(occurrence, end, window_start, window_end):
|
||||
events.append(_event_dict(event, source_name, color, occurrence, end, all_day))
|
||||
else:
|
||||
end = start + duration
|
||||
if _overlaps(start, end, window_start, window_end):
|
||||
events.append(_event_dict(event, source_name, color, start, end, all_day))
|
||||
for event in overrides.values():
|
||||
if not event.get("dtstart") or str(event.get("status") or "").upper() == "CANCELLED":
|
||||
continue
|
||||
start, all_day = _utc(event.decoded("dtstart"), timezone)
|
||||
end = start + _duration(event, start, all_day, timezone)
|
||||
if _overlaps(start, end, window_start, window_end):
|
||||
events.append(_event_dict(event, source_name, color, start, end, all_day))
|
||||
return sorted(events, key=lambda item: (item["starts_at"], item["title"], item["id"]))
|
||||
@@ -0,0 +1,223 @@
|
||||
from datetime import UTC, datetime, timedelta
|
||||
from uuid import UUID
|
||||
from zoneinfo import ZoneInfo, ZoneInfoNotFoundError
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, Response
|
||||
from pydantic import BaseModel, ConfigDict, Field, field_validator, model_validator
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.ext.asyncio import AsyncSession
|
||||
|
||||
from . import calendar as calendar_service
|
||||
from .auth import current_user
|
||||
from .db import get_db
|
||||
from .models import CalendarSubscription, User, utcnow
|
||||
|
||||
router = APIRouter(prefix="/api/v1", tags=["calendar"])
|
||||
MAX_WINDOW = timedelta(days=366)
|
||||
|
||||
|
||||
class SubscriptionCreate(BaseModel):
|
||||
name: str = Field(min_length=1, max_length=120)
|
||||
url: str = Field(min_length=1, max_length=2000)
|
||||
color: str = Field(default="#f15a29", pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
enabled: bool = True
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def clean_name(cls, value: str) -> str:
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("name cannot be blank")
|
||||
return value
|
||||
|
||||
|
||||
class SubscriptionUpdate(BaseModel):
|
||||
name: str | None = Field(default=None, min_length=1, max_length=120)
|
||||
url: str | None = Field(default=None, min_length=1, max_length=2000)
|
||||
color: str | None = Field(default=None, pattern=r"^#[0-9A-Fa-f]{6}$")
|
||||
enabled: bool | None = None
|
||||
|
||||
@field_validator("name")
|
||||
@classmethod
|
||||
def clean_name(cls, value: str | None) -> str | None:
|
||||
if value is None:
|
||||
return None
|
||||
value = value.strip()
|
||||
if not value:
|
||||
raise ValueError("name cannot be blank")
|
||||
return value
|
||||
|
||||
@model_validator(mode="after")
|
||||
def reject_nulls(self):
|
||||
for field in self.model_fields_set:
|
||||
if getattr(self, field) is None:
|
||||
raise ValueError(f"{field} cannot be null")
|
||||
return self
|
||||
|
||||
|
||||
class SubscriptionOut(BaseModel):
|
||||
model_config = ConfigDict(from_attributes=True)
|
||||
id: UUID
|
||||
name: str
|
||||
url: str
|
||||
color: str
|
||||
enabled: bool
|
||||
refreshed_at: datetime | None
|
||||
last_error: str | None
|
||||
stale: bool
|
||||
|
||||
|
||||
def _out(row: CalendarSubscription) -> dict:
|
||||
return {
|
||||
"id": row.id,
|
||||
"name": row.name,
|
||||
"url": row.url,
|
||||
"color": row.color,
|
||||
"enabled": row.enabled,
|
||||
"refreshed_at": row.refreshed_at,
|
||||
"last_error": row.last_error,
|
||||
"stale": bool(row.last_error and row.ics_cache),
|
||||
}
|
||||
|
||||
|
||||
async def _owned(db: AsyncSession, user_id: UUID, subscription_id: UUID) -> CalendarSubscription:
|
||||
row = await db.scalar(select(CalendarSubscription).where(
|
||||
CalendarSubscription.id == subscription_id,
|
||||
CalendarSubscription.user_id == user_id,
|
||||
))
|
||||
if row is None:
|
||||
raise HTTPException(404, "calendar subscription not found")
|
||||
return row
|
||||
|
||||
|
||||
async def _refresh(db: AsyncSession, row: CalendarSubscription) -> None:
|
||||
try:
|
||||
result = await __import__("asyncio").to_thread(
|
||||
calendar_service.fetch_calendar, row.url, etag=row.etag, last_modified=row.last_modified
|
||||
)
|
||||
if result.not_modified:
|
||||
if not row.ics_cache:
|
||||
raise HTTPException(502, "calendar returned not modified without cache")
|
||||
elif result.content is not None:
|
||||
# Parse before replacing a known-good cache.
|
||||
calendar_service.parse_ics_events(
|
||||
result.content,
|
||||
row.name,
|
||||
row.color,
|
||||
datetime.now(UTC) - timedelta(days=1),
|
||||
datetime.now(UTC) + timedelta(days=1),
|
||||
"UTC",
|
||||
)
|
||||
row.ics_cache = result.content.decode("utf-8-sig")
|
||||
row.etag = result.etag
|
||||
row.last_modified = result.last_modified
|
||||
row.refreshed_at = utcnow()
|
||||
row.last_error = None
|
||||
except Exception as exc:
|
||||
row.last_error = exc.detail if isinstance(exc, HTTPException) else str(exc)
|
||||
if not row.ics_cache:
|
||||
await db.rollback()
|
||||
raise HTTPException(502, row.last_error) from exc
|
||||
await db.commit()
|
||||
|
||||
|
||||
@router.get("/calendar-subscriptions", response_model=list[SubscriptionOut])
|
||||
async def list_subscriptions(user: User = Depends(current_user), db: AsyncSession = Depends(get_db)):
|
||||
rows = (await db.scalars(select(CalendarSubscription).where(
|
||||
CalendarSubscription.user_id == user.id
|
||||
).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all()
|
||||
return [_out(row) for row in rows]
|
||||
|
||||
|
||||
@router.post("/calendar-subscriptions", response_model=SubscriptionOut, status_code=201)
|
||||
async def create_subscription(
|
||||
payload: SubscriptionCreate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
calendar_service.validate_calendar_url(payload.url)
|
||||
row = CalendarSubscription(user_id=user.id, **payload.model_dump())
|
||||
db.add(row)
|
||||
await db.flush()
|
||||
await _refresh(db, row)
|
||||
await db.refresh(row)
|
||||
return _out(row)
|
||||
|
||||
|
||||
@router.patch("/calendar-subscriptions/{subscription_id}", response_model=SubscriptionOut)
|
||||
async def update_subscription(
|
||||
subscription_id: UUID,
|
||||
payload: SubscriptionUpdate,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await _owned(db, user.id, subscription_id)
|
||||
changes = payload.model_dump(exclude_unset=True)
|
||||
if "url" in changes:
|
||||
calendar_service.validate_calendar_url(changes["url"])
|
||||
if changes["url"] != row.url:
|
||||
row.ics_cache = row.etag = row.last_modified = row.refreshed_at = row.last_error = None
|
||||
for key, value in changes.items():
|
||||
setattr(row, key, value)
|
||||
await db.commit()
|
||||
await db.refresh(row)
|
||||
return _out(row)
|
||||
|
||||
|
||||
@router.delete("/calendar-subscriptions/{subscription_id}", status_code=204)
|
||||
async def delete_subscription(
|
||||
subscription_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await _owned(db, user.id, subscription_id)
|
||||
await db.delete(row)
|
||||
await db.commit()
|
||||
return Response(status_code=204)
|
||||
|
||||
|
||||
@router.post("/calendar-subscriptions/{subscription_id}/refresh", response_model=SubscriptionOut)
|
||||
async def refresh_subscription(
|
||||
subscription_id: UUID,
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
row = await _owned(db, user.id, subscription_id)
|
||||
await _refresh(db, row)
|
||||
await db.refresh(row)
|
||||
return _out(row)
|
||||
|
||||
|
||||
@router.get("/calendar-events")
|
||||
async def calendar_events(
|
||||
start: datetime = Query(),
|
||||
end: datetime = Query(),
|
||||
user: User = Depends(current_user),
|
||||
db: AsyncSession = Depends(get_db),
|
||||
):
|
||||
if start.tzinfo is None or end.tzinfo is None or end <= start or end - start > MAX_WINDOW:
|
||||
raise HTTPException(422, "start/end must be timezone-aware and span at most 366 days")
|
||||
try:
|
||||
ZoneInfo(user.timezone)
|
||||
except ZoneInfoNotFoundError as exc:
|
||||
raise HTTPException(422, "user timezone is invalid") from exc
|
||||
rows = (await db.scalars(select(CalendarSubscription).where(
|
||||
CalendarSubscription.user_id == user.id,
|
||||
CalendarSubscription.enabled.is_(True),
|
||||
).order_by(CalendarSubscription.created_at, CalendarSubscription.id))).all()
|
||||
events = []
|
||||
sources = []
|
||||
for row in rows:
|
||||
if not row.ics_cache:
|
||||
await _refresh(db, row)
|
||||
try:
|
||||
parsed = calendar_service.parse_ics_events(
|
||||
row.ics_cache or "", row.name, row.color, start, end, user.timezone
|
||||
)
|
||||
events.extend(parsed)
|
||||
except ValueError as exc:
|
||||
row.last_error = str(exc)
|
||||
await db.commit()
|
||||
sources.append({"id": row.id, "name": row.name, "stale": bool(row.last_error)})
|
||||
events.sort(key=lambda item: (item["starts_at"], item["title"], item["id"]))
|
||||
return {"events": events, "sources": sources}
|
||||
@@ -30,6 +30,7 @@ from .auth import (
|
||||
verify_password,
|
||||
)
|
||||
from .backup import router as backup_router
|
||||
from .calendar_router import router as calendar_router
|
||||
from .db import create_schema, get_db
|
||||
from .models import (
|
||||
AppState,
|
||||
@@ -120,6 +121,7 @@ async def openapi(_: User = Depends(current_user)):
|
||||
|
||||
app.include_router(mvp_router)
|
||||
app.include_router(backup_router)
|
||||
app.include_router(calendar_router)
|
||||
logger = logging.getLogger(__name__)
|
||||
_login_attempts: dict[tuple[str, str], deque[float]] = defaultdict(deque)
|
||||
|
||||
|
||||
@@ -301,6 +301,23 @@ class BackupImportEntity(Base):
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
|
||||
|
||||
class CalendarSubscription(Base):
|
||||
__tablename__ = "calendar_subscriptions"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
user_id: Mapped[UUID] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"), index=True)
|
||||
name: Mapped[str] = mapped_column(String(120))
|
||||
url: Mapped[str] = mapped_column(Text)
|
||||
color: Mapped[str] = mapped_column(String(32), default="#f15a29")
|
||||
enabled: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
ics_cache: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
etag: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
last_modified: Mapped[str | None] = mapped_column(String(512), nullable=True)
|
||||
refreshed_at: Mapped[datetime | None] = mapped_column(UTCDateTime(), nullable=True)
|
||||
last_error: Mapped[str | None] = mapped_column(Text, nullable=True)
|
||||
created_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow)
|
||||
updated_at: Mapped[datetime] = mapped_column(UTCDateTime(), default=utcnow, onupdate=utcnow)
|
||||
|
||||
|
||||
class AuditLog(Base):
|
||||
__tablename__ = "audit_logs"
|
||||
id: Mapped[UUID] = mapped_column(primary_key=True, default=new_id)
|
||||
|
||||
Reference in New Issue
Block a user