Add calendar frame mode + server-side manage overlay (server)
Build and push server image / build-and-push (push) Successful in 42s
Build and push server image / build-and-push (push) Successful in 42s
calendar_feed.py/calendar_render.py: fetch/merge per-user ICS feeds, render agenda/week/month views. manage_overlay.py: composites the manage-button overlay server-side (QR, battery, location/date, share-QR, face labels), reused by every render mode. device.py/common.py wire both together: mode dispatch for /frame/image+advance+back, and the &manage=1 flag. Plus UI (frame_config.html Calendar card, settings calendar URL field) and the icalendar/recurring-ical-events deps.
This commit is contained in:
@@ -17,7 +17,6 @@ from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, File, Form, HTTPException, Request, UploadFile
|
||||
@@ -26,7 +25,7 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import gitea_releases, photo_queue, quiet_hours
|
||||
from .. import calendar_render, gitea_releases, photo_queue, quiet_hours
|
||||
from ..auth import require_frame_control, require_frame_view, require_user_api
|
||||
from ..db import frame_locked, get_db
|
||||
from ..image_pipeline import (
|
||||
@@ -37,15 +36,19 @@ from ..image_pipeline import (
|
||||
render_preview_png,
|
||||
)
|
||||
from ..firmware import firmware_path, parse_app_version
|
||||
from ..models import BatteryLog, Frame
|
||||
from ..models import BatteryLog, Frame, UserFrame
|
||||
from .common import (
|
||||
FRAME_MODES,
|
||||
OVERDUE_FACTOR,
|
||||
battery_estimate_s,
|
||||
calendar_sources_for_frame,
|
||||
fetch_source_and_faces,
|
||||
get_or_refresh_calendar_events,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
require_configured,
|
||||
valid_http_url,
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
@@ -60,17 +63,6 @@ MAX_QUEUE_TARGET_LEN = 5000
|
||||
ORIENTATIONS = ("landscape", "portrait", "landscape_flipped", "portrait_flipped")
|
||||
|
||||
|
||||
def _valid_repo_url(url: str) -> bool:
|
||||
"""The frame will periodically fetch from this URL on its own (see
|
||||
gitea_releases.py) and, with auto-update on, install whatever it
|
||||
finds -- unlike a one-off manual firmware upload, that's a standing
|
||||
trust relationship, so it's worth rejecting obviously-wrong input at
|
||||
save time rather than only failing later at fetch time. http(s) only
|
||||
-- no file://, no other schemes."""
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/albums")
|
||||
def api_albums(frame: Frame = Depends(require_frame_view)):
|
||||
url, key = immich_creds(frame)
|
||||
@@ -104,6 +96,9 @@ def api_config_save(
|
||||
color_boost: float | None = Form(None),
|
||||
contrast_boost: float | None = Form(None),
|
||||
dither_strength: float | None = Form(None),
|
||||
mode: str | None = Form(None),
|
||||
calendar_view: str | None = Form(None),
|
||||
calendar_photo_inlay: bool | None = Form(None),
|
||||
frame: Frame = Depends(require_frame_control),
|
||||
db: Session = Depends(get_db),
|
||||
):
|
||||
@@ -142,7 +137,7 @@ def api_config_save(
|
||||
cfg.timezone = timezone
|
||||
if firmware_update_repo_url is not None:
|
||||
stripped = firmware_update_repo_url.strip()
|
||||
if stripped and not _valid_repo_url(stripped):
|
||||
if stripped and not valid_http_url(stripped):
|
||||
raise HTTPException(400, "Firmware repo URL must be a plain http:// or https:// URL")
|
||||
cfg.firmware_update_repo_url = stripped
|
||||
if firmware_auto_update is not None:
|
||||
@@ -167,6 +162,18 @@ def api_config_save(
|
||||
cfg.contrast_boost = max(0.0, min(2.0, contrast_boost))
|
||||
if dither_strength is not None:
|
||||
cfg.dither_strength = max(0.0, min(1.0, dither_strength))
|
||||
if mode is not None:
|
||||
cfg.mode = mode if mode in FRAME_MODES else "photos"
|
||||
if calendar_view is not None:
|
||||
new_view = calendar_view if calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
if new_view != cfg.calendar_view:
|
||||
# A stale offset means something different in a different
|
||||
# view's units (days vs. weeks vs. months) -- same
|
||||
# reasoning as album_id's reset above.
|
||||
cfg.calendar_browse_offset = 0
|
||||
cfg.calendar_view = new_view
|
||||
if calendar_photo_inlay is not None:
|
||||
cfg.calendar_photo_inlay = calendar_photo_inlay
|
||||
cfg.stats_config_saves += 1
|
||||
return {"status": "saved"}
|
||||
|
||||
@@ -399,6 +406,81 @@ def api_preview_rendered(frame: Frame = Depends(require_frame_view), db: Session
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
class CalendarIncludedRequest(BaseModel):
|
||||
included: bool
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/calendar-included")
|
||||
def api_calendar_included(
|
||||
body: CalendarIncludedRequest,
|
||||
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."""
|
||||
user = require_user_api(request, db)
|
||||
row = db.get(UserFrame, (user.id, frame.id))
|
||||
if row is None:
|
||||
raise HTTPException(404, "Not linked to this frame")
|
||||
row.calendar_included = body.included
|
||||
# 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}
|
||||
|
||||
|
||||
def _calendar_photo_inlay(frame: Frame, db: Session):
|
||||
"""The agenda view's optional photo-inlay source image, or None if
|
||||
inlay is off, not agenda view, or the frame's photos-mode album isn't
|
||||
configured. Shared shape between the live render (routers/device.py's
|
||||
_render_calendar_mode) and this preview endpoint; small enough that
|
||||
duplicating rather than factoring out is fine, since the two call
|
||||
sites differ slightly in error handling."""
|
||||
if not (frame.calendar_view == "agenda" and frame.calendar_photo_inlay):
|
||||
return None
|
||||
url, key = immich_creds(frame)
|
||||
if not (url and key and frame.album_id):
|
||||
return None
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
if not asset_id:
|
||||
return None
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
return Image.open(io.BytesIO(client.download_asset_preview(asset_id)))
|
||||
except HTTPException:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/api/frames/{frame_id}/preview/calendar")
|
||||
def api_preview_calendar(frame: Frame = Depends(require_frame_view), db: Session = Depends(get_db)):
|
||||
"""The same merged, cached event set a live device render would use
|
||||
-- not a live preview of an unsaved calendar_view choice, same
|
||||
"reflects what's currently saved" convention as preview/rendered."""
|
||||
if not calendar_sources_for_frame(db, frame):
|
||||
raise HTTPException(400, "No calendars included on this frame yet")
|
||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||
photo_inlay = _calendar_photo_inlay(frame, db)
|
||||
view = frame.calendar_view if frame.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
png = calendar_render.render_calendar_preview_png(
|
||||
events, view=view, browse_offset=frame.calendar_browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone, photo_inlay=photo_inlay, fetch_summary=summary,
|
||||
)
|
||||
return Response(content=png, media_type="image/png")
|
||||
|
||||
|
||||
@router.post("/api/frames/{frame_id}/firmware")
|
||||
def api_firmware_upload(
|
||||
file: UploadFile = File(...),
|
||||
|
||||
@@ -5,6 +5,9 @@ from __future__ import annotations
|
||||
import io
|
||||
import logging
|
||||
import os
|
||||
import time
|
||||
from datetime import datetime, timedelta
|
||||
from urllib.parse import urlparse
|
||||
|
||||
import httpx
|
||||
from fastapi import HTTPException
|
||||
@@ -12,12 +15,16 @@ from PIL import Image
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
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 Frame
|
||||
from ..models import Frame, User, UserFrame
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
FRAME_MODES = ("photos", "calendar")
|
||||
|
||||
# Battery-history / estimate tuning (see /frame/battery and battery_estimate_s).
|
||||
BATTERY_HISTORY_MAX = 500 # ~20 days at hourly reports
|
||||
BATTERY_LOG_MAX = 20000 # ~2 years at hourly reports -- cap on the battery_log table per frame
|
||||
@@ -100,12 +107,12 @@ def fetch_source_and_faces(client: ImmichClient, frame: Frame, asset_id: str) ->
|
||||
return Image.open(io.BytesIO(jpeg_bytes)), faces
|
||||
|
||||
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str) -> bytes:
|
||||
def render_asset(client: ImmichClient, frame: Frame, asset_id: str, manage: dict | None = None) -> bytes:
|
||||
source, faces = fetch_source_and_faces(client, frame, asset_id)
|
||||
return render_frame(source, faces=faces, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, display_mode=frame.display_mode,
|
||||
color_boost=frame.color_boost, contrast_boost=frame.contrast_boost,
|
||||
dither_strength=frame.dither_strength)
|
||||
dither_strength=frame.dither_strength, manage=manage)
|
||||
|
||||
|
||||
def battery_estimate_s(frame: Frame) -> int | None:
|
||||
@@ -152,3 +159,182 @@ def shell_context(request, db: Session, user, active_frame: Frame | None = None,
|
||||
"active_frame": active_frame,
|
||||
"active_nav": active_nav,
|
||||
}
|
||||
|
||||
|
||||
def valid_http_url(url: str) -> bool:
|
||||
"""http(s)-only URL check -- generalized from what was api_frames.py's
|
||||
frame-specific _valid_repo_url, now shared by two call sites (the
|
||||
Gitea firmware repo URL, and a user's personal calendar ICS URL)."""
|
||||
parsed = urlparse(url)
|
||||
return parsed.scheme in ("http", "https") and bool(parsed.netloc)
|
||||
|
||||
|
||||
# --- Location/date-taken text for the manage overlay (see build_manage_content) ---
|
||||
|
||||
LOCATION_LINE_MAX_LEN = 14
|
||||
|
||||
US_STATE_ABBR = {
|
||||
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
||||
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
||||
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
||||
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
||||
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
|
||||
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
|
||||
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
|
||||
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
|
||||
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
|
||||
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
||||
"district of columbia": "DC",
|
||||
}
|
||||
|
||||
CA_PROVINCE_ABBR = {
|
||||
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
|
||||
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
|
||||
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
|
||||
"saskatchewan": "SK", "yukon": "YT",
|
||||
}
|
||||
|
||||
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
|
||||
CA_COUNTRY_NAMES = {"canada"}
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _format_location(exif: dict) -> tuple[str, str] | None:
|
||||
"""Returns (city_line, region_line), each independently truncated to
|
||||
fit its own corner-overlay line, or None if Immich hasn't geocoded
|
||||
this photo. region_line is the abbreviated state/province for US/CAN
|
||||
locations (e.g. "CA", "ON"), else the full country name."""
|
||||
city = exif.get("city")
|
||||
if not city:
|
||||
return None
|
||||
|
||||
state = exif.get("state")
|
||||
country = exif.get("country")
|
||||
country_key = (country or "").strip().lower()
|
||||
|
||||
if state and country_key in US_COUNTRY_NAMES:
|
||||
region = US_STATE_ABBR.get(state.strip().lower(), state)
|
||||
elif state and country_key in CA_COUNTRY_NAMES:
|
||||
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
|
||||
elif country:
|
||||
region = country
|
||||
elif state:
|
||||
region = state
|
||||
else:
|
||||
region = ""
|
||||
|
||||
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
|
||||
|
||||
|
||||
def _format_taken_at(exif: dict) -> str | None:
|
||||
raw = exif.get("dateTimeOriginal")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
def _manage_content_asset_id(frame: Frame) -> str | None:
|
||||
"""Whether frame.current_asset_id refers to a photo actually visible
|
||||
right now, for whichever mode is active -- always true in photos
|
||||
mode; only true in calendar mode when the agenda view's photo inlay
|
||||
is on (otherwise current_asset_id could be stale, left over from
|
||||
whenever photos mode last ran, and showing its location/date/share
|
||||
info on a manage overlay over a view with no visible photo at all
|
||||
would be actively misleading, not just unhelpful)."""
|
||||
relevant = frame.mode != "calendar" or (frame.calendar_view == "agenda" and frame.calendar_photo_inlay)
|
||||
return frame.current_asset_id if relevant and frame.current_asset_id else None
|
||||
|
||||
|
||||
def build_manage_content(db: Session, frame: Frame, request) -> dict:
|
||||
"""Gathers everything manage_overlay.compose() needs -- what used to
|
||||
be two separate device-facing endpoints (/frame/photo-info,
|
||||
/frame/face-labels, both removed -- see the module docstring in
|
||||
manage_overlay.py) are now just internal calls made here, once,
|
||||
server-side, since compositing itself also moved server-side.
|
||||
management_url and battery_percent always apply; location/date/
|
||||
share-URL/face-labels only when there's a real current photo (see
|
||||
_manage_content_asset_id) -- absent otherwise, which
|
||||
manage_overlay.compose() already treats as "skip that region",
|
||||
exactly the graceful-degradation behavior the old firmware-fetched
|
||||
version had."""
|
||||
base = str(request.base_url).rstrip("/")
|
||||
content: dict = {
|
||||
"management_url": f"{base}/m/{frame.manage_token}",
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
asset_id = _manage_content_asset_id(frame)
|
||||
if not asset_id:
|
||||
return content
|
||||
|
||||
client = immich_client_for(frame)
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch manage-overlay photo info for asset %s: %s", asset_id, e)
|
||||
return content
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
content["location_lines"] = _format_location(exif)
|
||||
content["taken_at"] = _format_taken_at(exif)
|
||||
content["share_url"] = f"{base}/frame/share/{asset_id}"
|
||||
|
||||
if any((face.get("person") or {}).get("name") for face in faces):
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
from ..face_labels import compute_face_labels
|
||||
|
||||
content["face_labels"] = compute_face_labels(preview_bytes, faces, frame.display_mode, frame.orientation)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
|
||||
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), ...]."""
|
||||
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]
|
||||
|
||||
|
||||
def get_or_refresh_calendar_events(db: Session, frame: Frame) -> tuple[list[dict], str]:
|
||||
"""Frame-level throttled merge-fetch (calendar_feed.CHECK_INTERVAL_S)
|
||||
-- same shape as the Gitea release-check throttle in api_frames.py's
|
||||
api_firmware_check. One shared cache for the whole merged result
|
||||
(every included user's events together), not per-user -- ICS feeds
|
||||
are small and this refetches at most every ~20 minutes regardless of
|
||||
how many are included, so per-user cache columns would add
|
||||
bookkeeping for a marginal benefit."""
|
||||
now = time.time()
|
||||
if frame.calendar_cached_events is not None and now - frame.calendar_checked_at < calendar_feed.CHECK_INTERVAL_S:
|
||||
return frame.calendar_cached_events, frame.calendar_fetch_summary
|
||||
|
||||
sources = calendar_sources_for_frame(db, frame)
|
||||
today = quiet_hours.local_date(frame)
|
||||
events, summary = calendar_feed.merge_events(
|
||||
sources,
|
||||
today - timedelta(days=calendar_feed.EXPAND_WINDOW_PAST_DAYS),
|
||||
today + timedelta(days=calendar_feed.EXPAND_WINDOW_FUTURE_DAYS),
|
||||
)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_cached_events = events
|
||||
locked.calendar_fetch_summary = summary
|
||||
locked.calendar_checked_at = now
|
||||
return events, summary
|
||||
|
||||
+144
-199
@@ -2,13 +2,18 @@
|
||||
into deployed firmware -- so multi-frame support changes only how the
|
||||
calling frame is resolved (see auth.require_device), never the paths or
|
||||
response key names the deployed flat parser depends on
|
||||
("refresh_interval_s", "firmware_version")."""
|
||||
("refresh_interval_s", "firmware_version").
|
||||
|
||||
manage=1 is the one addition: appended by firmware's manage button to
|
||||
whichever of these three GET/POST requests it was already about to make
|
||||
(see firmware/main/frame_client.c's fetch_and_display -- it no longer
|
||||
does its own overlay fetching/compositing, that's all server-side now,
|
||||
see manage_overlay.py and common.build_manage_content)."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import time
|
||||
from datetime import datetime
|
||||
|
||||
import httpx
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
@@ -17,10 +22,9 @@ from pydantic import BaseModel
|
||||
from sqlalchemy import delete, func, select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from .. import mail, photo_queue, quiet_hours
|
||||
from .. import calendar_render, mail, photo_queue, quiet_hours
|
||||
from ..auth import get_server_settings, require_device
|
||||
from ..db import frame_locked, get_db
|
||||
from ..face_labels import compute_face_labels
|
||||
from ..firmware import firmware_path
|
||||
from ..image_pipeline import render_placeholder
|
||||
from ..models import BatteryLog, Frame
|
||||
@@ -29,6 +33,8 @@ from .common import (
|
||||
BATTERY_LOG_MAX,
|
||||
RECHARGE_JUMP_PCT,
|
||||
RECHARGE_LOOKBACK,
|
||||
build_manage_content,
|
||||
get_or_refresh_calendar_events,
|
||||
immich_client_for,
|
||||
immich_creds,
|
||||
list_assets,
|
||||
@@ -41,7 +47,7 @@ logger = logging.getLogger(__name__)
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
def _setup_placeholder(frame: Frame, request: Request, manage: dict | None = None) -> bytes:
|
||||
"""What an unclaimed or not-yet-configured frame displays instead of a
|
||||
photo -- instructions with a QR, rendered at 200 so the device treats
|
||||
it as a perfectly normal image and never error-loops. The URLs are
|
||||
@@ -56,18 +62,21 @@ def _setup_placeholder(frame: Frame, request: Request) -> bytes:
|
||||
qr_url=claim_url,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
)
|
||||
if frame.owner_user_id is None:
|
||||
return render_placeholder(
|
||||
["Almost there!", f"Open {base} to finish setting up this frame."],
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
)
|
||||
return render_placeholder(
|
||||
["Almost there!", "Pick an album for this frame:", base],
|
||||
qr_url=base,
|
||||
orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb,
|
||||
manage=manage,
|
||||
)
|
||||
|
||||
|
||||
@@ -76,11 +85,12 @@ def _frame_configured(frame: Frame) -> bool:
|
||||
return bool(url and key and frame.album_id)
|
||||
|
||||
|
||||
# Renderer dispatch seam for future frame modes (calendar, canva, ...):
|
||||
# /frame/image looks up the frame's mode here. Only photos exists today.
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
# --- photos mode ---
|
||||
|
||||
def _render_photos_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
if not _frame_configured(frame):
|
||||
return _setup_placeholder(frame, request)
|
||||
return _setup_placeholder(frame, request, manage=manage)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
@@ -88,11 +98,108 @@ def _render_photos_mode(db: Session, frame: Frame, request: Request) -> bytes:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id)
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
def _advance_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.advance_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
def _back_photos_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.back_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return render_asset(client, frame, asset_id, manage=manage)
|
||||
|
||||
|
||||
# --- calendar mode ---
|
||||
|
||||
def _render_calendar_mode(db: Session, frame: Frame, request: Request, manage: dict | None,
|
||||
is_normal_wake: bool) -> bytes:
|
||||
from .common import calendar_sources_for_frame
|
||||
|
||||
if not calendar_sources_for_frame(db, frame):
|
||||
return render_placeholder(
|
||||
["This frame's calendar isn't set up yet",
|
||||
"Add a calendar in Settings, then include it on",
|
||||
"this frame's Configuration -> Calendar card."],
|
||||
orientation=frame.orientation, palette_rgb=frame.palette_rgb, manage=manage,
|
||||
)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
if is_normal_wake and locked.calendar_browse_offset != 0:
|
||||
locked.calendar_browse_offset = 0
|
||||
browse_offset = locked.calendar_browse_offset
|
||||
view = locked.calendar_view if locked.calendar_view in calendar_render.CALENDAR_VIEWS else "agenda"
|
||||
inlay_wanted = locked.calendar_photo_inlay and view == "agenda"
|
||||
|
||||
events, summary = get_or_refresh_calendar_events(db, frame)
|
||||
|
||||
photo_inlay = None
|
||||
if inlay_wanted and _frame_configured(frame):
|
||||
try:
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
if asset_id:
|
||||
jpeg_bytes = client.download_asset_preview(asset_id)
|
||||
import io
|
||||
|
||||
from PIL import Image
|
||||
|
||||
photo_inlay = Image.open(io.BytesIO(jpeg_bytes))
|
||||
except HTTPException:
|
||||
pass # inlay is nice-to-have -- an Immich hiccup shouldn't blank the whole agenda
|
||||
|
||||
return calendar_render.render_calendar(
|
||||
events, view=view, browse_offset=browse_offset, orientation=frame.orientation,
|
||||
palette_rgb=frame.palette_rgb, timezone=frame.timezone,
|
||||
photo_inlay=photo_inlay, fetch_summary=summary, manage=manage,
|
||||
)
|
||||
|
||||
|
||||
def _advance_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
"""NEXT in calendar mode: moves the displayed period forward one step
|
||||
(day for agenda, week for week view, month for month view) from
|
||||
wherever it currently is -- not from "today" -- so repeated presses
|
||||
walk further forward. See Frame.calendar_browse_offset."""
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_browse_offset += 1
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
def _back_calendar_mode(db: Session, frame: Frame, manage: dict | None) -> bytes:
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
locked.calendar_browse_offset -= 1
|
||||
return _render_calendar_mode(db, frame, request=None, manage=manage, is_normal_wake=False)
|
||||
|
||||
|
||||
RENDERERS = {
|
||||
"photos": _render_photos_mode,
|
||||
"calendar": _render_calendar_mode,
|
||||
}
|
||||
ADVANCE_RENDERERS = {
|
||||
"photos": _advance_photos_mode,
|
||||
"calendar": _advance_calendar_mode,
|
||||
}
|
||||
BACK_RENDERERS = {
|
||||
"photos": _back_photos_mode,
|
||||
"calendar": _back_calendar_mode,
|
||||
}
|
||||
|
||||
|
||||
@@ -131,6 +238,10 @@ def frame_config(request: Request, frame: Frame = Depends(require_device), db: S
|
||||
return response
|
||||
|
||||
|
||||
def _manage_flag(request: Request) -> bool:
|
||||
return request.query_params.get("manage") == "1"
|
||||
|
||||
|
||||
@router.get("/frame/image")
|
||||
def frame_image(
|
||||
request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)
|
||||
@@ -141,44 +252,36 @@ def frame_image(
|
||||
safe to call as often as the device wants, including after an
|
||||
unplanned reboot, without skipping ahead in the album. An unclaimed/
|
||||
unconfigured frame gets a rendered instruction placeholder (200, not
|
||||
an error) so a fresh device never error-loops."""
|
||||
an error) so a fresh device never error-loops.
|
||||
|
||||
?manage=1 (the manage button) composites the manage overlay onto
|
||||
whatever this would have returned anyway -- see build_manage_content.
|
||||
For calendar mode, this is also the "normal wake" that resets
|
||||
calendar_browse_offset back to 0 (see _render_calendar_mode)."""
|
||||
renderer = RENDERERS.get(frame.mode, _render_photos_mode)
|
||||
return Response(content=renderer(db, frame, request), media_type="application/octet-stream")
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
content = renderer(db, frame, request, manage, True)
|
||||
return Response(content=content, media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/advance")
|
||||
def frame_advance(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Forces an immediate advance to the next photo, ignoring
|
||||
refresh_interval_s, and resets the interval clock from now. Used by
|
||||
the device's next-photo button."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.advance_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
||||
def frame_advance(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Forces an immediate move forward -- the next photo in photos mode,
|
||||
or the next day/week/month in calendar mode -- ignoring
|
||||
refresh_interval_s. Used by the device's next-photo button."""
|
||||
handler = ADVANCE_RENDERERS.get(frame.mode, _advance_photos_mode)
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||
|
||||
|
||||
@router.post("/frame/back")
|
||||
def frame_back(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Returns to the previously-current photo (the mirror image of
|
||||
/frame/advance -- see photo_queue.back_forced()), and resets the
|
||||
interval clock from now. A no-op (still 200, current photo
|
||||
unchanged) if there's no history to go back to -- same "always
|
||||
returns something displayable" contract as /frame/advance, rather
|
||||
than erroring. Used by the device's back-photo button."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.back_forced(locked, assets)
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
return Response(content=render_asset(client, frame, asset_id), media_type="application/octet-stream")
|
||||
def frame_back(request: Request, frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""The mirror of /frame/advance -- back a photo in photos mode, back
|
||||
a period in calendar mode. A no-op (still 200, unchanged) if there's
|
||||
nothing to go back to. Used by the device's back-photo button."""
|
||||
handler = BACK_RENDERERS.get(frame.mode, _back_photos_mode)
|
||||
manage = build_manage_content(db, frame, request) if _manage_flag(request) else None
|
||||
return Response(content=handler(db, frame, manage), media_type="application/octet-stream")
|
||||
|
||||
|
||||
class BatteryReport(BaseModel):
|
||||
@@ -272,115 +375,6 @@ def frame_firmware(frame: Frame = Depends(require_device)):
|
||||
return FileResponse(path, media_type="application/octet-stream")
|
||||
|
||||
|
||||
LOCATION_LINE_MAX_LEN = 14
|
||||
|
||||
US_STATE_ABBR = {
|
||||
"alabama": "AL", "alaska": "AK", "arizona": "AZ", "arkansas": "AR", "california": "CA",
|
||||
"colorado": "CO", "connecticut": "CT", "delaware": "DE", "florida": "FL", "georgia": "GA",
|
||||
"hawaii": "HI", "idaho": "ID", "illinois": "IL", "indiana": "IN", "iowa": "IA",
|
||||
"kansas": "KS", "kentucky": "KY", "louisiana": "LA", "maine": "ME", "maryland": "MD",
|
||||
"massachusetts": "MA", "michigan": "MI", "minnesota": "MN", "mississippi": "MS", "missouri": "MO",
|
||||
"montana": "MT", "nebraska": "NE", "nevada": "NV", "new hampshire": "NH", "new jersey": "NJ",
|
||||
"new mexico": "NM", "new york": "NY", "north carolina": "NC", "north dakota": "ND", "ohio": "OH",
|
||||
"oklahoma": "OK", "oregon": "OR", "pennsylvania": "PA", "rhode island": "RI", "south carolina": "SC",
|
||||
"south dakota": "SD", "tennessee": "TN", "texas": "TX", "utah": "UT", "vermont": "VT",
|
||||
"virginia": "VA", "washington": "WA", "west virginia": "WV", "wisconsin": "WI", "wyoming": "WY",
|
||||
"district of columbia": "DC",
|
||||
}
|
||||
|
||||
CA_PROVINCE_ABBR = {
|
||||
"alberta": "AB", "british columbia": "BC", "manitoba": "MB", "new brunswick": "NB",
|
||||
"newfoundland and labrador": "NL", "northwest territories": "NT", "nova scotia": "NS",
|
||||
"nunavut": "NU", "ontario": "ON", "prince edward island": "PE", "quebec": "QC",
|
||||
"saskatchewan": "SK", "yukon": "YT",
|
||||
}
|
||||
|
||||
US_COUNTRY_NAMES = {"united states", "united states of america", "usa", "us"}
|
||||
CA_COUNTRY_NAMES = {"canada"}
|
||||
|
||||
|
||||
def _truncate(text: str, max_len: int) -> str:
|
||||
if len(text) <= max_len:
|
||||
return text
|
||||
return text[: max_len - 3] + "..."
|
||||
|
||||
|
||||
def _format_location(exif: dict) -> tuple[str, str] | None:
|
||||
"""Returns (city_line, region_line), each independently truncated to
|
||||
fit its own corner-overlay line, or None if Immich hasn't geocoded
|
||||
this photo. region_line is the abbreviated state/province for US/CAN
|
||||
locations (e.g. "CA", "ON"), else the full country name."""
|
||||
city = exif.get("city")
|
||||
if not city:
|
||||
return None
|
||||
|
||||
state = exif.get("state")
|
||||
country = exif.get("country")
|
||||
country_key = (country or "").strip().lower()
|
||||
|
||||
if state and country_key in US_COUNTRY_NAMES:
|
||||
region = US_STATE_ABBR.get(state.strip().lower(), state)
|
||||
elif state and country_key in CA_COUNTRY_NAMES:
|
||||
region = CA_PROVINCE_ABBR.get(state.strip().lower(), state)
|
||||
elif country:
|
||||
region = country
|
||||
elif state:
|
||||
region = state
|
||||
else:
|
||||
region = ""
|
||||
|
||||
return _truncate(city, LOCATION_LINE_MAX_LEN), _truncate(region, LOCATION_LINE_MAX_LEN)
|
||||
|
||||
|
||||
def _format_taken_at(exif: dict) -> str | None:
|
||||
raw = exif.get("dateTimeOriginal")
|
||||
if not raw:
|
||||
return None
|
||||
try:
|
||||
return datetime.fromisoformat(raw.replace("Z", "+00:00")).strftime("%m/%d/%y")
|
||||
except ValueError:
|
||||
return None
|
||||
|
||||
|
||||
@router.get("/frame/photo-info")
|
||||
def frame_photo_info(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Location/date-taken text for the manage-button overlay, plus the
|
||||
asset id used to build the share-QR's target URL. Read-only, same
|
||||
idempotent current-photo semantics as /frame/image -- doesn't advance
|
||||
anything."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
|
||||
if not asset_id:
|
||||
raise HTTPException(404, "No current photo")
|
||||
|
||||
try:
|
||||
asset = client.get_asset(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
raise HTTPException(502, f"Could not reach Immich: {e}") from e
|
||||
|
||||
exif = asset.get("exifInfo") or {}
|
||||
location = _format_location(exif)
|
||||
return {
|
||||
"asset_id": asset_id,
|
||||
"location_line1": location[0] if location else None,
|
||||
"location_line2": location[1] if location and location[1] else None,
|
||||
"taken_at": _format_taken_at(exif),
|
||||
# Last value this frame itself reported (see /frame/battery) --
|
||||
# not a fresh reading. Good enough for a glance on the manage
|
||||
# overlay, and lets the device skip a synchronous ADC read (which
|
||||
# would otherwise need to happen before the overlay is composited,
|
||||
# i.e. before the photo it's part of is even pushed to the panel)
|
||||
# just to render this.
|
||||
"battery_percent": frame.battery_percent,
|
||||
}
|
||||
|
||||
|
||||
@router.get("/frame/share/{asset_id}")
|
||||
def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||
"""Creates a 30-minute public Immich share link for asset_id and
|
||||
@@ -403,52 +397,3 @@ def frame_share(asset_id: str, frame: Frame = Depends(require_device)):
|
||||
raise HTTPException(502, f"Could not create share link: {e}") from e
|
||||
|
||||
return RedirectResponse(share_url)
|
||||
|
||||
|
||||
@router.get("/frame/face-labels")
|
||||
def frame_face_labels(frame: Frame = Depends(require_device), db: Session = Depends(get_db)):
|
||||
"""Named-face positions for the manage button's escalated "level 2"
|
||||
menu -- who's in the current photo, per Immich's own face
|
||||
recognition (no detection/recognition happens here, see
|
||||
app/face_labels.py). Response is a flattened, fixed-slot shape
|
||||
(name_0/x_0/y_0, ...) rather than a JSON array, so the device's
|
||||
hand-rolled parser can read it with the same flat-scalar helpers it
|
||||
already has. Empty (count: 0) if no faces are named, or if anything
|
||||
about fetching them fails -- this is a "nice to have" addition to
|
||||
the overlay, not worth failing the whole menu over."""
|
||||
require_configured(frame)
|
||||
client = immich_client_for(frame)
|
||||
assets = list_assets(client, frame)
|
||||
|
||||
with frame_locked(db, frame.id) as locked:
|
||||
photo_queue.get_current(locked, assets, in_quiet_hours=quiet_hours.in_quiet_hours(locked))
|
||||
asset_id = locked.current_asset_id
|
||||
display_mode = locked.display_mode
|
||||
orientation = locked.orientation
|
||||
|
||||
if not asset_id:
|
||||
return {"count": 0}
|
||||
|
||||
try:
|
||||
faces = client.get_asset_faces(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not fetch faces for asset %s: %s", asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
if not any((face.get("person") or {}).get("name") for face in faces):
|
||||
return {"count": 0} # skip the extra preview download in the common no-named-faces case
|
||||
|
||||
try:
|
||||
preview_bytes = client.download_asset_preview(asset_id)
|
||||
except httpx.HTTPError as e:
|
||||
logger.warning("Could not download asset %s for face-label mapping: %s", asset_id, e)
|
||||
return {"count": 0}
|
||||
|
||||
labels = compute_face_labels(preview_bytes, faces, display_mode, orientation)
|
||||
|
||||
result: dict[str, object] = {"count": len(labels)}
|
||||
for i, label in enumerate(labels):
|
||||
result[f"name_{i}"] = label["name"]
|
||||
result[f"x_{i}"] = label["x"]
|
||||
result[f"y_{i}"] = label["y"]
|
||||
return result
|
||||
|
||||
@@ -8,9 +8,11 @@ from __future__ import annotations
|
||||
from fastapi import APIRouter, Depends, HTTPException, Request
|
||||
from fastapi.responses import HTMLResponse, RedirectResponse
|
||||
from fastapi.templating import Jinja2Templates
|
||||
from sqlalchemy import select
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from ..auth import can_view_frame, current_user
|
||||
from ..calendar_render import CALENDAR_VIEW_LABELS
|
||||
from ..db import get_db
|
||||
from ..image_pipeline import (
|
||||
DEFAULT_PALETTE_RGB,
|
||||
@@ -18,7 +20,7 @@ from ..image_pipeline import (
|
||||
PALETTE_LABELS,
|
||||
palette_to_hex,
|
||||
)
|
||||
from ..models import Frame
|
||||
from ..models import Frame, User, UserFrame
|
||||
from ..quiet_hours import ALL_TIMEZONES
|
||||
from .common import shell_context
|
||||
|
||||
@@ -43,6 +45,25 @@ 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
|
||||
]
|
||||
|
||||
|
||||
@router.get("/frames/{frame_id}/config", response_class=HTMLResponse)
|
||||
def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get_db)):
|
||||
return _frame_page(
|
||||
@@ -52,6 +73,8 @@ def frame_config_page(frame_id: int, request: Request, db: Session = Depends(get
|
||||
default_palette_rgb=DEFAULT_PALETTE_RGB,
|
||||
palette_to_hex=palette_to_hex,
|
||||
display_mode_labels=DISPLAY_MODE_LABELS,
|
||||
calendar_views=CALENDAR_VIEW_LABELS,
|
||||
calendar_users=_calendar_users_for_frame(db, frame_id),
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"""HTML page routes: first-run setup, login/logout, user settings, and
|
||||
the admin panel. The frame pages themselves stay in main.py (Phase A's
|
||||
single-frame index) until the Phase D restructure.
|
||||
the admin panel. The per-frame pages (Photos/Configuration/Stats) live in
|
||||
routers/frame_pages.py.
|
||||
|
||||
All POSTs here are plain HTML forms, so CSRF rides a hidden form field
|
||||
(checked explicitly) rather than the X-CSRF-Token header the JSON API
|
||||
@@ -35,6 +35,7 @@ from ..auth import (
|
||||
)
|
||||
from ..db import get_db
|
||||
from ..models import Frame, PasswordResetToken, PendingClaim, User, UserFrame
|
||||
from .common import valid_http_url
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
@@ -422,6 +423,7 @@ def settings_submit(
|
||||
email: str = Form(""),
|
||||
immich_url: str = Form(""),
|
||||
immich_api_key: str = Form(""),
|
||||
calendar_ics_url: str = Form(""),
|
||||
current_password: str = Form(""),
|
||||
new_password: str = Form(""),
|
||||
db: Session = Depends(get_db),
|
||||
@@ -441,6 +443,15 @@ def settings_submit(
|
||||
if immich_api_key.strip():
|
||||
user.immich_api_key = immich_api_key.strip()
|
||||
|
||||
# Unlike the API key, this isn't a secret -- it round-trips visibly in
|
||||
# the form, so blank means an explicit clear (there needs to be some
|
||||
# way to actually remove a linked calendar), not "keep existing".
|
||||
stripped_ics = calendar_ics_url.strip()
|
||||
if stripped_ics and not valid_http_url(stripped_ics):
|
||||
error = "Calendar URL must be a plain http:// or https:// URL."
|
||||
else:
|
||||
user.calendar_ics_url = stripped_ics
|
||||
|
||||
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."
|
||||
|
||||
Reference in New Issue
Block a user