Move mode picker below device status bar; fix invisible calendar lines; add CalDAV calendar support
Build and push server image / build-and-push (push) Successful in 50s
Build and push server image / build-and-push (push) Successful in 50s
Calendar rule/grid lines were light gray, which dithers away to near-invisible on the 6-color e-ink palette -- now black. CalDAV accounts (Nextcloud, Fastmail, iCloud, ...) can now be linked alongside the existing single ICS subscription, since one account can expose several calendars. A frame's Calendar tab now lists calendars per person rather than one opt-in per person: your own row shows every calendar you have available with a full add/remove toggle, while other linked users' rows show only calendars they've included, toggleable off (mute) but not on -- only a calendar's owner can add it to a shared frame. FrameCalendar replaces the old single-boolean UserFrame.calendar_included; existing opt-ins are migrated forward.
This commit is contained in:
@@ -213,6 +213,17 @@ Pages: `/` (routing hub), `/setup`, `/login`, `/claim`, `/settings`,
|
||||
never vendored or modified, so this project's own code stays under its
|
||||
own license; LGPL's copyleft terms apply to that library itself, not
|
||||
to code that merely links against it dynamically.
|
||||
- CalDAV account support (`app/caldav_client.py`, alongside the plain ICS
|
||||
subscription) wraps the `caldav` PyPI package. `caldav` itself is
|
||||
dual-licensed GPL-3.0-or-later/Apache-2.0, but it hard-depends on
|
||||
`icalendar-searcher`, which is **AGPL-3.0-or-later** -- the strongest
|
||||
copyleft in this project's dependency tree, and the one whose
|
||||
network-use clause is written specifically for server applications
|
||||
like this one (not just "don't vendor/modify it," which was enough
|
||||
reasoning for the LGPL dependency above). Taking this on was an
|
||||
explicit, informed call by the project owner, not a default -- anyone
|
||||
redistributing this project (vs. just self-hosting it) should
|
||||
re-evaluate that tradeoff for their own situation before doing so.
|
||||
- The 6-color palette RGB values in `app/image_pipeline.py`
|
||||
(`DEFAULT_PALETTE_RGB`) are approximations, not measured values
|
||||
(Waveshare doesn't publish exact color primaries for this panel).
|
||||
|
||||
@@ -0,0 +1,108 @@
|
||||
"""CalDAV account support: discovering which calendars an account exposes,
|
||||
and fetching one calendar's events -- the second way (alongside
|
||||
calendar_feed.py's single-file ICS subscription) a user can link a
|
||||
calendar for calendar frame mode (Nextcloud, Fastmail, iCloud, Radicale,
|
||||
Baikal, ...).
|
||||
|
||||
Thin wrapper around the `caldav` PyPI package (RFC 4791 client). NOTE ON
|
||||
LICENSING: `caldav` itself is dual-licensed GPL-3.0-or-later / Apache-2.0,
|
||||
but it hard-depends on `icalendar-searcher`, which is AGPL-3.0-or-later --
|
||||
the strongest copyleft license in this project's dependency tree, and the
|
||||
one whose network-use clause is specifically written for server
|
||||
applications like this one. This was an explicit, informed call by the
|
||||
project owner to accept that exposure rather than hand-roll a CalDAV
|
||||
client -- see the server README's Notes section. Anyone redistributing
|
||||
this project (as opposed to just self-hosting it) should reread that
|
||||
tradeoff for their own situation.
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends -- same testability
|
||||
philosophy as calendar_feed.py. Event parsing/expansion reuses
|
||||
icalendar + recurring_ical_events directly (rather than trusting each
|
||||
CalDAV server's own possibly-inconsistent RRULE expansion) so a CalDAV
|
||||
calendar and an ICS subscription behave identically once fetched.
|
||||
"""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import date, datetime, time as dtime
|
||||
|
||||
import caldav
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
HTTP_TIMEOUT_S = 15
|
||||
|
||||
|
||||
class CalDavError(Exception):
|
||||
"""Discovery or fetch failed -- network, auth, or an unexpected
|
||||
server response. Raised loudly; callers (Settings' discover
|
||||
endpoint, calendar_feed.merge_events) decide what to do. Wraps
|
||||
whatever the caldav package/its transport raised, since that
|
||||
exception hierarchy isn't something call sites should need to know
|
||||
about directly."""
|
||||
|
||||
|
||||
def discover_calendars(base_url: str, username: str, password: str) -> list[dict]:
|
||||
"""[{"href": absolute_calendar_url, "display_name": str}, ...] for
|
||||
every calendar in this account. base_url is the server's CalDAV
|
||||
entry point (e.g. "https://cloud.example.com/remote.php/dav/" for
|
||||
Nextcloud) -- the caller supplies it directly, same idiom as the
|
||||
plain ICS subscription URL."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=base_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendars = client.principal().calendars()
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
result = []
|
||||
for cal in calendars:
|
||||
try:
|
||||
display_name = cal.get_display_name() or cal.name
|
||||
except Exception:
|
||||
display_name = None
|
||||
result.append({"href": str(cal.url), "display_name": display_name or str(cal.url)})
|
||||
return result
|
||||
|
||||
|
||||
def fetch_calendar_events(calendar_url: str, username: str, password: str,
|
||||
window_start: date, window_end: date) -> list[dict]:
|
||||
"""One CalDAV calendar's events in [window_start, window_end] -- same
|
||||
event dict shape as calendar_feed.fetch_source_events (no
|
||||
"owner_display_name"; the caller adds that). Fetches raw (unexpanded)
|
||||
calendar objects and runs them through the same icalendar +
|
||||
recurring_ical_events pipeline calendar_feed.py uses for ICS feeds,
|
||||
rather than relying on server-side expand (RFC 4791 leaves plenty of
|
||||
corner cases server implementations disagree on)."""
|
||||
try:
|
||||
client = caldav.DAVClient(url=calendar_url, username=username, password=password, timeout=HTTP_TIMEOUT_S)
|
||||
calendar = caldav.Calendar(client=client, url=calendar_url)
|
||||
objects = calendar.date_search(
|
||||
start=datetime.combine(window_start, dtime.min),
|
||||
end=datetime.combine(window_end, dtime.min),
|
||||
expand=False,
|
||||
)
|
||||
except Exception as e:
|
||||
raise CalDavError(str(e)) from e
|
||||
|
||||
events: list[dict] = []
|
||||
for obj in objects:
|
||||
try:
|
||||
ical = icalendar.Calendar.from_ical(obj.data)
|
||||
occurrences = recurring_ical_events.of(ical).between(window_start, window_end)
|
||||
except Exception: # one malformed resource shouldn't blank the whole calendar
|
||||
continue
|
||||
for occ in occurrences:
|
||||
dtstart = occ.get("DTSTART")
|
||||
dtend = occ.get("DTEND")
|
||||
if dtstart is None:
|
||||
continue
|
||||
start_dt = dtstart.dt
|
||||
end_dt = dtend.dt if dtend is not None else start_dt
|
||||
all_day = not isinstance(start_dt, datetime)
|
||||
events.append({
|
||||
"summary": str(occ.get("SUMMARY") or "(untitled)"),
|
||||
"start": start_dt.isoformat(),
|
||||
"end": end_dt.isoformat(),
|
||||
"all_day": all_day,
|
||||
})
|
||||
return events
|
||||
+41
-17
@@ -1,10 +1,11 @@
|
||||
"""Fetch, parse, and merge per-user ICS calendar feeds for calendar frame
|
||||
mode (see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
"""Fetch, parse, and merge per-user calendar feeds -- ICS subscriptions
|
||||
and (via caldav_client.py) CalDAV collections -- for calendar frame mode
|
||||
(see routers/device.py's RENDERERS["calendar"] and calendar_render.py).
|
||||
|
||||
Pure functions -- no ORM, no FastAPI Depends. Callers (routers/common.py's
|
||||
get_or_refresh_calendar_events) supply plain (owner_display_name, url)
|
||||
pairs, not ORM objects, so this module stays testable against fixture .ics
|
||||
text with no database or app involved.
|
||||
get_or_refresh_calendar_events) supply plain CalendarSource values, not
|
||||
ORM objects, so this module stays testable against fixture .ics text with
|
||||
no database or app involved.
|
||||
|
||||
Recurring events (RRULE/EXDATE/RDATE, DST-aware) are expanded via
|
||||
recurring-ical-events rather than hand-rolled -- that's genuinely fiddly
|
||||
@@ -16,12 +17,15 @@ code under LGPL terms).
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
from datetime import date, datetime
|
||||
|
||||
import httpx
|
||||
import icalendar
|
||||
import recurring_ical_events
|
||||
|
||||
from . import caldav_client
|
||||
|
||||
HTTP_TIMEOUT_S = 15.0
|
||||
FETCH_MAX_BYTES = 10 * 1024 * 1024 # sanity cap -- a real feed is KB, not MB
|
||||
|
||||
@@ -83,26 +87,46 @@ def fetch_source_events(url: str, window_start: date, window_end: date) -> list[
|
||||
return events
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class CalendarSource:
|
||||
"""One calendar to merge in: either a plain ICS subscription (kind
|
||||
"ics", url is the feed itself) or one CalDAV collection (kind
|
||||
"caldav", url is the calendar's own URL, username/password its
|
||||
account credentials) -- see caldav_client.py. owner_display_name
|
||||
tags every event pulled from this source so a merged agenda can show
|
||||
whose event is whose."""
|
||||
|
||||
owner_display_name: str
|
||||
kind: str
|
||||
url: str
|
||||
username: str = ""
|
||||
password: str = ""
|
||||
|
||||
|
||||
def merge_events(
|
||||
sources: list[tuple[str, str]], window_start: date, window_end: date
|
||||
sources: list[CalendarSource], window_start: date, window_end: date
|
||||
) -> tuple[list[dict], str]:
|
||||
"""sources: [(owner_display_name, ics_url), ...]. Fetches each
|
||||
independently -- one broken feed never blanks another's events.
|
||||
Returns (merged_time_sorted_events, fetch_summary); fetch_summary is
|
||||
"" when every source succeeded, else "N of M calendars unavailable"
|
||||
(never *which* source -- naming whose feed is down to everyone who
|
||||
looks at a shared household display is a bigger overshare than the
|
||||
outage itself)."""
|
||||
"""Fetches each source independently -- one broken feed never blanks
|
||||
another's events. Returns (merged_time_sorted_events, fetch_summary);
|
||||
fetch_summary is "" when every source succeeded, else "N of M
|
||||
calendars unavailable" (never *which* source -- naming whose feed is
|
||||
down to everyone who looks at a shared household display is a bigger
|
||||
overshare than the outage itself)."""
|
||||
merged: list[dict] = []
|
||||
failures = 0
|
||||
for owner_display_name, url in sources:
|
||||
for source in sources:
|
||||
try:
|
||||
events = fetch_source_events(url, window_start, window_end)
|
||||
except CalendarFetchError:
|
||||
if source.kind == "caldav":
|
||||
events = caldav_client.fetch_calendar_events(
|
||||
source.url, source.username, source.password, window_start, window_end
|
||||
)
|
||||
else:
|
||||
events = fetch_source_events(source.url, window_start, window_end)
|
||||
except (CalendarFetchError, caldav_client.CalDavError):
|
||||
failures += 1
|
||||
continue
|
||||
for event in events:
|
||||
event["owner_display_name"] = owner_display_name
|
||||
event["owner_display_name"] = source.owner_display_name
|
||||
merged.append(event)
|
||||
|
||||
merged.sort(key=lambda e: e["start"])
|
||||
|
||||
@@ -35,7 +35,9 @@ MARGIN = 20
|
||||
BG = (255, 255, 255)
|
||||
FG = (0, 0, 0)
|
||||
MUTED = (110, 110, 110)
|
||||
RULE = (200, 200, 200)
|
||||
# Was a light gray, but that dithers away to near-invisible once quantized
|
||||
# to the 6-color e-ink palette -- black reads as an actual line on-panel.
|
||||
RULE = (0, 0, 0)
|
||||
|
||||
# Cycled per distinct owner_display_name so a merged multi-person calendar
|
||||
# can visually tell whose event is whose -- the panel's own non-black/
|
||||
|
||||
@@ -110,6 +110,41 @@ def _migration_8(conn) -> None:
|
||||
|
||||
|
||||
|
||||
def _migration_9(conn) -> None:
|
||||
"""CalDAV support alongside the plain ICS subscription (see
|
||||
caldav_client.py), and the frame_calendars table that replaces
|
||||
user_frames.calendar_included now that one account (CalDAV) can
|
||||
expose more than one calendar -- see models.py's FrameCalendar.
|
||||
Existing single-calendar opt-ins are carried forward as "ics" rows
|
||||
before the old column is dropped, so nobody's frame goes silently
|
||||
calendar-less after this migration."""
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_url TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_username TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_password TEXT NOT NULL DEFAULT ''"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_calendars TEXT"))
|
||||
conn.execute(text("ALTER TABLE users ADD COLUMN calendar_caldav_checked_at REAL NOT NULL DEFAULT 0.0"))
|
||||
|
||||
conn.execute(text(
|
||||
"CREATE TABLE frame_calendars ("
|
||||
"id INTEGER PRIMARY KEY, "
|
||||
"frame_id INTEGER NOT NULL REFERENCES frames(id) ON DELETE CASCADE, "
|
||||
"user_id INTEGER NOT NULL REFERENCES users(id) ON DELETE CASCADE, "
|
||||
"calendar_key TEXT NOT NULL, "
|
||||
"calendar_label TEXT NOT NULL DEFAULT '', "
|
||||
"included INTEGER NOT NULL DEFAULT 1)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"CREATE UNIQUE INDEX ix_frame_calendars_unique ON frame_calendars (frame_id, user_id, calendar_key)"
|
||||
))
|
||||
conn.execute(text(
|
||||
"INSERT INTO frame_calendars (frame_id, user_id, calendar_key, calendar_label, included) "
|
||||
"SELECT uf.frame_id, uf.user_id, 'ics', 'My calendar', 1 "
|
||||
"FROM user_frames uf JOIN users u ON u.id = uf.user_id "
|
||||
"WHERE uf.calendar_included = 1 AND u.calendar_ics_url != ''"
|
||||
))
|
||||
conn.execute(text("ALTER TABLE user_frames DROP COLUMN calendar_included"))
|
||||
|
||||
|
||||
MIGRATIONS = [
|
||||
(1, _migration_1),
|
||||
(2, _migration_2),
|
||||
@@ -119,6 +154,7 @@ MIGRATIONS = [
|
||||
(6, _migration_6),
|
||||
(7, _migration_7),
|
||||
(8, _migration_8),
|
||||
(9, _migration_9),
|
||||
]
|
||||
|
||||
|
||||
|
||||
+51
-13
@@ -48,12 +48,26 @@ class User(Base):
|
||||
# email -- see routers/device.py's frame_battery) go here; blank = no
|
||||
# email configured, both features silently no-op for this user.
|
||||
email: Mapped[str] = mapped_column(String, default="")
|
||||
# Personal iCal/CalDAV .ics subscription URL (no OAuth) for calendar
|
||||
# frame mode -- see calendar_feed.py. Setting this alone shows up
|
||||
# nowhere: a linked frame only pulls this user's events in once
|
||||
# they've also opted in on that frame's own Configuration -> Calendar
|
||||
# card (UserFrame.calendar_included below).
|
||||
# Personal ICS subscription URL (no OAuth) for calendar frame mode --
|
||||
# see calendar_feed.py. Setting this alone shows up nowhere: a linked
|
||||
# frame only pulls this user's events in once they've also added it
|
||||
# on that frame's own Calendar tab (FrameCalendar below).
|
||||
calendar_ics_url: Mapped[str] = mapped_column(String, default="")
|
||||
# A CalDAV account (Nextcloud, Fastmail, iCloud, ...) alongside the
|
||||
# plain ICS subscription above -- see caldav_client.py. calendar_url
|
||||
# is the server's CalDAV entry point the user pasted in, not any one
|
||||
# calendar's own URL; the individual calendars it exposes are
|
||||
# discovered and cached below.
|
||||
calendar_caldav_url: Mapped[str] = mapped_column(String, default="")
|
||||
calendar_caldav_username: Mapped[str] = mapped_column(String, default="")
|
||||
calendar_caldav_password: Mapped[str] = mapped_column(String, default="")
|
||||
# [{"href", "display_name"}, ...] from the last successful
|
||||
# caldav_client.discover_calendars() call, refreshed by Settings'
|
||||
# "Discover calendars" button -- NULL until discovery has ever
|
||||
# succeeded. This is what a frame's Calendar tab offers the user to
|
||||
# add, without hitting the CalDAV server on every page load.
|
||||
calendar_caldav_calendars: Mapped[list | None] = mapped_column(JSON, nullable=True, default=None)
|
||||
calendar_caldav_checked_at: Mapped[float] = mapped_column(Float, default=0.0)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
|
||||
__table_args__ = (
|
||||
@@ -234,14 +248,38 @@ class UserFrame(Base):
|
||||
ForeignKey("frames.id", ondelete="CASCADE"), primary_key=True
|
||||
)
|
||||
created_at: Mapped[float] = mapped_column(Float, default=time.time)
|
||||
# Explicit per-(user,frame) opt-in for calendar frame mode -- being
|
||||
# linked to a frame does NOT by itself contribute this user's
|
||||
# calendar to it (deliberate choice, not an oversight: each person's
|
||||
# calendar is their own data to share or not, not something a
|
||||
# frame's controller decides on their behalf). Meaningless if the
|
||||
# user has no calendar_ics_url set. See routers/api_frames.py's
|
||||
# api_calendar_included.
|
||||
calendar_included: Mapped[bool] = mapped_column(Boolean, default=False)
|
||||
|
||||
|
||||
class FrameCalendar(Base):
|
||||
"""One calendar included on one frame -- calendar_key is "ics" (the
|
||||
owner's single calendar_ics_url) or "caldav:<href>" (one of the
|
||||
owner's CalDAV collections; href matches an entry in
|
||||
User.calendar_caldav_calendars). Replaces the old single
|
||||
UserFrame.calendar_included boolean now that a CalDAV account can
|
||||
expose more than one calendar.
|
||||
|
||||
A row only ever gets created by its own owner (adding a calendar to
|
||||
a frame is each person's own data-sharing choice, not something a
|
||||
frame's controller decides on their behalf) -- but once it exists,
|
||||
ANY user linked to the frame may flip included back to False, muting
|
||||
a calendar they'd rather not see on a shared display even though
|
||||
they don't own it. Only the owner may flip it back to True. See
|
||||
routers/api_frames.py's api_calendar_select."""
|
||||
|
||||
__tablename__ = "frame_calendars"
|
||||
|
||||
id: Mapped[int] = mapped_column(primary_key=True)
|
||||
frame_id: Mapped[int] = mapped_column(ForeignKey("frames.id", ondelete="CASCADE"))
|
||||
user_id: Mapped[int] = mapped_column(ForeignKey("users.id", ondelete="CASCADE"))
|
||||
calendar_key: Mapped[str] = mapped_column(String)
|
||||
# Snapshot label for display -- so the list still reads sensibly even
|
||||
# if the owner's CalDAV account later stops offering this calendar.
|
||||
calendar_label: Mapped[str] = mapped_column(String, default="")
|
||||
included: Mapped[bool] = mapped_column(Boolean, default=True)
|
||||
|
||||
__table_args__ = (
|
||||
Index("ix_frame_calendars_unique", "frame_id", "user_id", "calendar_key", unique=True),
|
||||
)
|
||||
|
||||
|
||||
class PendingClaim(Base):
|
||||
|
||||
@@ -36,7 +36,7 @@ from ..image_pipeline import (
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame, UserFrame
|
||||
from ..models import BatteryLog, Frame, FrameCalendar
|
||||
from .common import (
|
||||
FRAME_MODES,
|
||||
OVERDUE_FACTOR,
|
||||
@@ -409,33 +409,53 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class CalendarIncludedRequest(BaseModel):
|
||||
class CalendarSelectRequest(BaseModel):
|
||||
user_id: int
|
||||
calendar_key: str
|
||||
calendar_label: str = ""
|
||||
included: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/calendar-included")
|
||||
def api_calendar_included(
|
||||
body: CalendarIncludedRequest,
|
||||
@router.post("/api/frames/{frame_id}/calendar-select")
|
||||
def api_calendar_select(
|
||||
body: CalendarSelectRequest,
|
||||
request: Request,
|
||||
frame: Frame = Depends(require_frame_view), # view access only -- NOT require_frame_control
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
"""A user's own opt-in into this frame's merged calendar (see
|
||||
UserFrame.calendar_included). Deliberately not require_frame_control:
|
||||
this is the toggling user's own data-sharing preference about their
|
||||
own calendar, not a frame setting its controller manages on someone
|
||||
else's behalf -- there's no target user_id in the request body by
|
||||
design, it always toggles the calling session's own row."""
|
||||
"""Include/exclude one calendar (calendar_key "ics" or
|
||||
"caldav:<href>", see FrameCalendar) on this frame. Deliberately not
|
||||
require_frame_control: adding your own calendar, or muting anyone's
|
||||
(including your own), is each viewer's own call, not something a
|
||||
frame's controller manages on someone else's behalf. The one-sided
|
||||
permission split lives here: turning a calendar ON requires being its
|
||||
owner (nobody can add someone else's calendar to a shared frame for
|
||||
them); turning one OFF only requires being linked to the frame at
|
||||
all, so anyone sharing the display can mute a calendar they'd rather
|
||||
not see there even if they don't own it."""
|
||||
user = require_user_api(request, db)
|
||||
row = db.get(UserFrame, (user.id, frame.id))
|
||||
if body.included and body.user_id != user.id:
|
||||
raise HTTPException(403, "Only a calendar's owner can add it to a frame")
|
||||
row = db.execute(
|
||||
select(FrameCalendar).where(
|
||||
FrameCalendar.frame_id == frame.id,
|
||||
FrameCalendar.user_id == body.user_id,
|
||||
FrameCalendar.calendar_key == body.calendar_key,
|
||||
)
|
||||
).scalar_one_or_none()
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not linked to this frame")
|
||||
row.calendar_included = body.included
|
||||
if not body.included:
|
||||
raise HTTPException(404, "Not currently included on this frame")
|
||||
row = FrameCalendar(frame_id=frame.id, user_id=body.user_id, calendar_key=body.calendar_key)
|
||||
db.add(row)
|
||||
row.included = body.included
|
||||
if body.calendar_label:
|
||||
row.calendar_label = body.calendar_label
|
||||
# Force this frame's merged cache to pick up the change promptly
|
||||
# rather than waiting out the throttle.
|
||||
frame.calendar_checked_at = 0.0
|
||||
db.commit()
|
||||
return {"status": "saved", "included": row.calendar_included}
|
||||
return {"status": "saved", "included": row.included}
|
||||
|
||||
|
||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||
|
||||
@@ -19,7 +19,7 @@ from .. import calendar_feed, quiet_hours
|
||||
from ..db import frame_locked
|
||||
from ..image_pipeline import render_frame
|
||||
from ..immich_client import ImmichClient
|
||||
from ..models import BatteryLog, Frame, User, UserFrame
|
||||
from ..models import BatteryLog, Frame, FrameCalendar, User
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -373,18 +373,29 @@ def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
return content
|
||||
|
||||
|
||||
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[tuple[str, str]]:
|
||||
"""Every user linked to this frame with BOTH a calendar URL set AND
|
||||
explicit per-frame opt-in (UserFrame.calendar_included) -- the exact
|
||||
set calendar_feed.merge_events needs. [(display_name-or-username,
|
||||
ics_url), ...]."""
|
||||
def calendar_sources_for_frame(db: Session, frame: Frame) -> list[calendar_feed.CalendarSource]:
|
||||
"""Every calendar included on this frame (FrameCalendar.included) --
|
||||
the exact set calendar_feed.merge_events needs. A calendar_key of
|
||||
"ics" resolves against its owner's calendar_ics_url; "caldav:<href>"
|
||||
resolves against the href itself, authenticated with the owner's
|
||||
CalDAV account credentials (see caldav_client.py)."""
|
||||
rows = db.execute(
|
||||
select(User)
|
||||
.join(UserFrame, UserFrame.user_id == User.id)
|
||||
.where(UserFrame.frame_id == frame.id, UserFrame.calendar_included == True, # noqa: E712
|
||||
User.calendar_ics_url != "")
|
||||
).scalars().all()
|
||||
return [(u.display_name or u.username, u.calendar_ics_url) for u in rows]
|
||||
select(FrameCalendar, User)
|
||||
.join(User, User.id == FrameCalendar.user_id)
|
||||
.where(FrameCalendar.frame_id == frame.id, FrameCalendar.included == True) # noqa: E712
|
||||
).all()
|
||||
sources = []
|
||||
for fc, u in rows:
|
||||
name = u.display_name or u.username
|
||||
if fc.calendar_key == "ics":
|
||||
if u.calendar_ics_url:
|
||||
sources.append(calendar_feed.CalendarSource(name, "ics", u.calendar_ics_url))
|
||||
elif fc.calendar_key.startswith("caldav:") and u.calendar_caldav_username:
|
||||
href = fc.calendar_key[len("caldav:"):]
|
||||
sources.append(calendar_feed.CalendarSource(
|
||||
name, "caldav", href, u.calendar_caldav_username, u.calendar_caldav_password
|
||||
))
|
||||
return sources
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
||||
|
||||
@@ -20,7 +20,7 @@ from ..image_pipeline import (
|
||||
PALETTE_LABELS,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import Frame, User, UserFrame
|
||||
from ..models import Frame, FrameCalendar, User, UserFrame
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from .common import shell_context
|
||||
|
||||
@@ -45,23 +45,54 @@ def frame_photos_page(frame_id: int, request: Request, db: Session = Depends(get
|
||||
return _frame_page(request, db, frame_id, "frame_photos.html", "photos")
|
||||
|
||||
|
||||
def _calendar_users_for_frame(db: Session, frame_id: int) -> list[dict]:
|
||||
"""Every user linked to this frame, their calendar opt-in state, and
|
||||
whether they even have a calendar URL set -- what the Configuration
|
||||
tab's "Included calendars" list needs. Whether a given row is *this*
|
||||
viewer's own (and therefore editable) is decided in the template,
|
||||
using the `user` shell_context already provides."""
|
||||
rows = db.execute(
|
||||
select(User, UserFrame.calendar_included)
|
||||
.join(UserFrame, UserFrame.user_id == User.id)
|
||||
.where(UserFrame.frame_id == frame_id)
|
||||
.order_by(User.username)
|
||||
).all()
|
||||
return [
|
||||
{"user_id": u.id, "display_name": u.display_name or u.username,
|
||||
"has_url": bool(u.calendar_ics_url), "included": included}
|
||||
for u, included in rows
|
||||
]
|
||||
def _user_available_calendars(user: User) -> list[dict]:
|
||||
"""This user's full set of calendars available to add to any frame:
|
||||
the single ICS subscription (if set) plus every CalDAV calendar last
|
||||
discovered from Settings' "Discover calendars" button. Doesn't hit
|
||||
the network -- reads the cached list a user refreshes themselves."""
|
||||
calendars = []
|
||||
if user.calendar_ics_url:
|
||||
calendars.append({"key": "ics", "label": "My calendar (ICS)"})
|
||||
for c in (user.calendar_caldav_calendars or []):
|
||||
calendars.append({"key": f"caldav:{c['href']}", "label": c.get("display_name") or "Calendar"})
|
||||
return calendars
|
||||
|
||||
|
||||
def _calendar_users_for_frame(db: Session, frame_id: int, viewer_id: int | None) -> list[dict]:
|
||||
"""Per-linked-user calendar list for the Calendar tab's "Included
|
||||
calendars" section. The viewer's own row lists EVERY calendar they
|
||||
have available, each with a full add/remove toggle; every other
|
||||
linked user's row lists ONLY the calendars they've already included
|
||||
(mute-only for the viewer -- see api_frames.py's api_calendar_select:
|
||||
only a calendar's owner may turn it on, but anyone linked to the
|
||||
frame may turn one off)."""
|
||||
users = db.execute(
|
||||
select(User).join(UserFrame, UserFrame.user_id == User.id)
|
||||
.where(UserFrame.frame_id == frame_id).order_by(User.username)
|
||||
).scalars().all()
|
||||
included_by_user: dict[int, list[FrameCalendar]] = {}
|
||||
for fc in db.execute(select(FrameCalendar).where(FrameCalendar.frame_id == frame_id)).scalars().all():
|
||||
included_by_user.setdefault(fc.user_id, []).append(fc)
|
||||
|
||||
result = []
|
||||
for u in users:
|
||||
is_self = u.id == viewer_id
|
||||
if is_self:
|
||||
included = {fc.calendar_key: fc.included for fc in included_by_user.get(u.id, [])}
|
||||
calendars = [
|
||||
{**c, "included": included.get(c["key"], False)}
|
||||
for c in _user_available_calendars(u)
|
||||
]
|
||||
else:
|
||||
calendars = [
|
||||
{"key": fc.calendar_key, "label": fc.calendar_label, "included": True}
|
||||
for fc in included_by_user.get(u.id, []) if fc.included
|
||||
]
|
||||
result.append({
|
||||
"user_id": u.id, "display_name": u.display_name or u.username,
|
||||
"is_self": is_self, "calendars": calendars,
|
||||
})
|
||||
return result
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||
@@ -82,10 +113,11 @@ WEEK_START_LABELS = {0: "Monday", 1: "Tuesday", 2: "Wednesday", 3: "Thursday",
|
||||
|
||||
@router.get("/frames/{frame_id}/calendar", response_class=HTMLResponse)
|
||||
def frame_calendar_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
viewer = current_user(request, db)
|
||||
return _frame_page(
|
||||
request, db, frame_id, "frame_calendar.html", "calendar",
|
||||
calendar_views=CALENDAR_VIEW_LABELS,
|
||||
calendar_users=_calendar_users_for_frame(db, frame_id),
|
||||
calendar_users=_calendar_users_for_frame(db, frame_id, viewer.id if viewer else None),
|
||||
week_start_labels=WEEK_START_LABELS,
|
||||
)
|
||||
|
||||
|
||||
@@ -18,7 +18,7 @@ from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail
|
||||
from .. import caldav_client, mail
|
||||
from ..auth import (
|
||||
SESSION_COOKIE,
|
||||
SESSION_LIFETIME_S,
|
||||
@@ -30,6 +30,7 @@ from ..auth import (
|
||||
destroy_session,
|
||||
get_server_settings,
|
||||
hash_password,
|
||||
require_user_api,
|
||||
users_exist,
|
||||
verify_password,
|
||||
)
|
||||
@@ -424,6 +425,9 @@ def settings_submit(
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
calendar_ics_url: str = Form(""),
|
||||
calendar_caldav_url: str = Form(""),
|
||||
calendar_caldav_username: str = Form(""),
|
||||
calendar_caldav_password: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -452,6 +456,23 @@ def settings_submit(
|
||||
else:
|
||||
user.calendar_ics_url = stripped_ics
|
||||
|
||||
stripped_caldav_url = calendar_caldav_url.strip()
|
||||
if stripped_caldav_url and not valid_http_url(stripped_caldav_url):
|
||||
error = "CalDAV URL must be a plain http:// or https:// URL."
|
||||
else:
|
||||
if stripped_caldav_url != user.calendar_caldav_url:
|
||||
# Server (and likely account) changed -- last discovery no
|
||||
# longer describes what's actually there.
|
||||
user.calendar_caldav_calendars = None
|
||||
user.calendar_caldav_checked_at = 0.0
|
||||
user.calendar_caldav_url = stripped_caldav_url
|
||||
user.calendar_caldav_username = calendar_caldav_username.strip()
|
||||
# Blank password field = keep the existing one, same idiom as the
|
||||
# Immich API key -- a secret that round-trips through HTML is a
|
||||
# secret in every browser's autofill store.
|
||||
if calendar_caldav_password.strip():
|
||||
user.calendar_caldav_password = calendar_caldav_password.strip()
|
||||
|
||||
if new_password:
|
||||
if not user.password_hash or not verify_password(current_password, user.password_hash):
|
||||
error = "Current password is wrong -- password not changed."
|
||||
@@ -466,6 +487,28 @@ def settings_submit(
|
||||
)
|
||||
|
||||
|
||||
@router.post("/api/settings/caldav-discover")
|
||||
def api_caldav_discover(request: Request, db: Session = Depends(get_db)):
|
||||
"""Lists the calendars in the CalDAV account already saved on this
|
||||
user's Settings (not whatever's currently typed in the form but not
|
||||
yet saved -- same idiom as /api/frames/{id}/albums using the frame's
|
||||
already-saved Immich creds). Caches the result on the user row so
|
||||
every frame's Calendar tab can offer it without a live round-trip."""
|
||||
user = require_user_api(request, db)
|
||||
if not user.calendar_caldav_url or not user.calendar_caldav_username:
|
||||
raise HTTPException(400, "Save a CalDAV URL and username first")
|
||||
try:
|
||||
calendars = caldav_client.discover_calendars(
|
||||
user.calendar_caldav_url, user.calendar_caldav_username, user.calendar_caldav_password
|
||||
)
|
||||
except caldav_client.CalDavError as e:
|
||||
raise HTTPException(502, f"Could not discover calendars: {e}") from e
|
||||
user.calendar_caldav_calendars = calendars
|
||||
user.calendar_caldav_checked_at = time.time()
|
||||
db.commit()
|
||||
return calendars
|
||||
|
||||
|
||||
def _require_admin_page(request: Request, db: Session) -> User:
|
||||
user = current_user(request, db)
|
||||
if user is None or not user.is_admin:
|
||||
|
||||
@@ -24,19 +24,27 @@ document.getElementById('calendar-config-form').addEventListener('submit', async
|
||||
}
|
||||
});
|
||||
|
||||
// Each person's own opt-in -- auto-saves on toggle, not batched into the
|
||||
// form above, since it's the toggling user's own preference (see
|
||||
// api_frames.py's /calendar-included), not a frame-wide setting.
|
||||
document.querySelectorAll('.calendar-self-toggle').forEach((el) => {
|
||||
// Each calendar's own include/mute toggle -- auto-saves on change, not
|
||||
// batched into the form above, since it's a data-sharing choice (see
|
||||
// api_frames.py's /calendar-select), not a frame-wide setting. Works the
|
||||
// same element for your own calendars (full add/remove) and other
|
||||
// people's (mute only) -- the server enforces which direction is allowed
|
||||
// and this just reverts the checkbox with an error message if rejected.
|
||||
document.querySelectorAll('.calendar-toggle').forEach((el) => {
|
||||
el.addEventListener('change', async () => {
|
||||
try {
|
||||
const resp = await fetch(`${window.FRAME_API}/calendar-included`, {
|
||||
const resp = await fetch(`${window.FRAME_API}/calendar-select`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ included: el.checked }),
|
||||
body: JSON.stringify({
|
||||
user_id: Number(el.dataset.userId),
|
||||
calendar_key: el.dataset.key,
|
||||
calendar_label: el.dataset.label,
|
||||
included: el.checked,
|
||||
}),
|
||||
});
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
showStatus(true, el.checked ? 'Your calendar is included on this frame.' : 'Your calendar removed from this frame.');
|
||||
showStatus(true, el.checked ? 'Calendar included on this frame.' : 'Calendar removed from this frame.');
|
||||
} catch (e) {
|
||||
el.checked = !el.checked;
|
||||
showStatus(false, e.message);
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
// Settings page: "Discover calendars" against the CalDAV account already
|
||||
// saved on this form (same idiom as frame_photos.js's Load Albums using
|
||||
// the frame's already-saved Immich creds) -- so this only works after
|
||||
// the CalDAV URL/username/password have been saved once.
|
||||
|
||||
const discoverBtn = document.getElementById('caldav-discover');
|
||||
if (discoverBtn) {
|
||||
discoverBtn.addEventListener('click', async () => {
|
||||
const list = document.getElementById('caldav-calendar-list');
|
||||
discoverBtn.disabled = true;
|
||||
try {
|
||||
const resp = await fetch('/api/settings/caldav-discover', { method: 'POST' });
|
||||
if (!resp.ok) throw new Error(await apiError(resp));
|
||||
const calendars = await resp.json();
|
||||
list.innerHTML = '';
|
||||
if (calendars.length === 0) {
|
||||
list.innerHTML = '<li>No calendars found in this account.</li>';
|
||||
} else {
|
||||
for (const c of calendars) {
|
||||
const li = document.createElement('li');
|
||||
li.textContent = c.display_name;
|
||||
list.appendChild(li);
|
||||
}
|
||||
}
|
||||
showStatus(true, `Found ${calendars.length} calendar${calendars.length === 1 ? '' : 's'}.`);
|
||||
} catch (e) {
|
||||
showStatus(false, e.message);
|
||||
} finally {
|
||||
discoverBtn.disabled = false;
|
||||
}
|
||||
});
|
||||
}
|
||||
@@ -261,6 +261,11 @@ input:focus, select:focus {
|
||||
.checkbox-row input { width: auto; margin-top: 0; }
|
||||
.checkbox-row label { margin-top: 0; font-weight: normal; }
|
||||
|
||||
.calendar-user-list { list-style: none; margin: 12px 0 0; padding: 0; }
|
||||
.calendar-user-list > li { margin-top: 14px; }
|
||||
.calendar-user-list > li:first-child { margin-top: 0; }
|
||||
.calendar-user-name { margin: 0; font-size: 13px; font-weight: 600; color: var(--text); }
|
||||
|
||||
button {
|
||||
margin-top: 20px;
|
||||
padding: 10px 16px;
|
||||
@@ -540,6 +545,9 @@ code {
|
||||
.device-status-row { column-gap: 16px; }
|
||||
}
|
||||
|
||||
.mode-picker-row { display: flex; align-items: center; gap: 8px; margin-bottom: 18px; }
|
||||
.mode-picker-label { font-size: 13px; font-weight: 600; color: var(--text-muted); }
|
||||
|
||||
/* Mobile: sidebar off-canvas, hamburger in a slim top bar. */
|
||||
.mobile-bar { display: none; }
|
||||
.sidebar-backdrop { display: none; }
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<div class="mode-picker-row">
|
||||
<span class="mode-picker-label">Mode</span>
|
||||
{% include "_frame_mode_select.html" %}
|
||||
</div>
|
||||
@@ -73,6 +73,7 @@
|
||||
</div>
|
||||
</div>
|
||||
{% block device_status %}{% endblock %}
|
||||
{% block mode_picker %}{% endblock %}
|
||||
{% block tabs %}{% endblock %}
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Calendar{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
@@ -49,24 +49,29 @@
|
||||
</form>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 20px;">Included calendars</h2>
|
||||
<p class="sub">Each linked person decides whether their own calendar
|
||||
contributes to this frame -- being linked here doesn't include it
|
||||
automatically.</p>
|
||||
<p class="sub">Each linked person adds their own calendars (ICS
|
||||
subscription or CalDAV, set up in <a href="/settings">Settings</a>)
|
||||
-- being linked here doesn't include anything automatically.
|
||||
Anyone linked to this frame can mute a calendar they'd rather
|
||||
not see here, even one they don't own; only its owner can add
|
||||
it back.</p>
|
||||
<ul class="calendar-user-list">
|
||||
{% for u in calendar_users %}
|
||||
<li>
|
||||
{% if u.user_id == user.id %}
|
||||
{% if u.has_url %}
|
||||
<p class="calendar-user-name">{{ u.display_name }}{% if u.is_self %} (you){% endif %}</p>
|
||||
{% if u.calendars %}
|
||||
{% for c in u.calendars %}
|
||||
<label class="checkbox-row" style="margin-top: 6px;">
|
||||
<input type="checkbox" class="calendar-self-toggle" {% if u.included %}checked{% endif %}>
|
||||
{{ u.display_name }} (you)
|
||||
<input type="checkbox" class="calendar-toggle"
|
||||
data-user-id="{{ u.user_id }}" data-key="{{ c.key }}" data-label="{{ c.label }}"
|
||||
{% if c.included %}checked{% endif %}>
|
||||
{{ c.label }}
|
||||
</label>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 6px;">{{ u.display_name }} (you) -- no calendar set, add one in <a href="/settings">Settings</a>.</p>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
{% elif u.is_self %}
|
||||
<p class="sub" style="margin-top: 6px;">No calendars set up yet -- add an ICS link or CalDAV account in <a href="/settings">Settings</a>.</p>
|
||||
{% else %}
|
||||
<p class="sub" style="margin-top: 6px;">{{ u.display_name }}:
|
||||
{% if not u.has_url %}no calendar set{% elif u.included %}included{% else %}not included{% endif %}</p>
|
||||
<p class="sub" style="margin-top: 6px;">No calendars included.</p>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Configuration{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Photos{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
@@ -2,7 +2,7 @@
|
||||
|
||||
{% block title %}{{ frame.name or "Frame" }} · Stats{% endblock %}
|
||||
{% block page_title %}{% include "_frame_name_edit.html" %}{% endblock %}
|
||||
{% block head_actions %}{% include "_frame_mode_select.html" %}{% endblock %}
|
||||
{% block mode_picker %}{% include "_frame_mode_picker.html" %}{% endblock %}
|
||||
|
||||
{% block device_status %}{% include "_device_status_bar.html" %}{% endblock %}
|
||||
{% block tabs %}{% include "_frame_tabs.html" %}{% endblock %}
|
||||
|
||||
@@ -33,17 +33,43 @@
|
||||
plus <code>sharedLink.create</code> for the on-frame share QR.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Calendar</h2>
|
||||
<label>Calendar URL (iCal/CalDAV .ics feed)
|
||||
<label>Calendar URL (iCal .ics feed)
|
||||
<input type="text" name="calendar_ics_url" placeholder="https://calendar.example.com/you.ics"
|
||||
value="{{ user.calendar_ics_url }}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">Your personal calendar
|
||||
subscription link (no login needed -- e.g. Google Calendar's
|
||||
Settings → "Secret address in iCal format", or Apple/Outlook/
|
||||
Nextcloud's equivalent). Setting it here doesn't show it anywhere
|
||||
by itself -- include it on any frame you're linked to from that
|
||||
frame's Configuration → Calendar card, so a frame only shows
|
||||
calendars people have actually chosen to share with it.</p>
|
||||
<p class="sub" style="margin-top: 8px;">A single subscription link
|
||||
(no login needed) -- e.g. Google Calendar's Settings →
|
||||
"Secret address in iCal format", or Apple/Outlook's equivalent.</p>
|
||||
|
||||
<label style="margin-top: 20px;">CalDAV server URL
|
||||
<input type="text" name="calendar_caldav_url" placeholder="https://cloud.example.com/remote.php/dav/"
|
||||
value="{{ user.calendar_caldav_url }}">
|
||||
</label>
|
||||
<label>CalDAV username
|
||||
<input type="text" name="calendar_caldav_username" autocomplete="off"
|
||||
value="{{ user.calendar_caldav_username }}">
|
||||
</label>
|
||||
<label>CalDAV password
|
||||
<input type="password" name="calendar_caldav_password" autocomplete="off"
|
||||
placeholder="{% if user.calendar_caldav_password %}(unchanged -- enter a new password to replace){% else %}app password or account password{% endif %}">
|
||||
</label>
|
||||
<p class="sub" style="margin-top: 8px;">An account (Nextcloud,
|
||||
Fastmail, iCloud, ...) that can expose more than one calendar --
|
||||
e.g. Nextcloud's is usually
|
||||
<code>https://your-server/remote.php/dav/</code>. Save this
|
||||
section first, then use "Discover calendars" below to list what's
|
||||
in the account.</p>
|
||||
<button type="button" class="secondary" id="caldav-discover" style="margin-top: 12px;">Discover calendars</button>
|
||||
<ul class="sub" id="caldav-calendar-list" style="margin-top: 8px; padding-left: 18px;">
|
||||
{% if user.calendar_caldav_calendars %}
|
||||
{% for c in user.calendar_caldav_calendars %}<li>{{ c.display_name }}</li>{% endfor %}
|
||||
{% endif %}
|
||||
</ul>
|
||||
|
||||
<p class="sub" style="margin-top: 8px;">Neither of these shows up
|
||||
anywhere by itself -- add individual calendars to any frame
|
||||
you're linked to from that frame's Calendar tab, so a frame only
|
||||
shows calendars people have actually chosen to share with it.</p>
|
||||
|
||||
<h2 class="card-title" style="margin-top: 24px;">Change password</h2>
|
||||
<label>Current password
|
||||
@@ -58,4 +84,10 @@
|
||||
<button type="submit">Save</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<div id="result"></div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script src="/static/settings.js"></script>
|
||||
{% endblock %}
|
||||
|
||||
@@ -9,3 +9,4 @@ sqlalchemy==2.0.51
|
||||
qrcode==8.2
|
||||
icalendar==7.2.2
|
||||
recurring-ical-events==3.8.2
|
||||
caldav==3.2.1
|
||||
|
||||
Reference in New Issue
Block a user