Files
notify-bridge/packages/server/src/notify_bridge_server/api/tracking_configs.py
T
alexei.dolgolyov 8065e6effa feat(immich): multi-time-point scheduling for scheduled/periodic/memory
Replace the single comma-separated text box with an add/remove list of
native HH:MM pickers for the Immich scheduled-assets, periodic-summary,
and memory slots. The backend already stored comma-separated *_times and
scheduled one cron job per time; this makes entering several times per
day discoverable and hardens the read/write path.

Backend:
- services/time_list.py: normalize_time_list (validate / dedup / sort /
  cap at 24, raising TimeListError) + lenient parse_hhmm_list; the
  scheduler now uses the shared parser (drops its private copy).
- tracking_configs API normalizes *_times on every write (422 on bad
  input) and rejects enabling a slot whose times list is empty.
- scheduler warns when an enabled slot has zero or dropped fire times,
  restoring the observability lost with the old per-call warning.

Frontend:
- TimeListEditor.svelte: add/remove native time rows, dedupe + sort on
  emit, per-day cap, collapses on-screen duplicates, aria-labelled rows;
  syncs from the value prop only on external changes (untrack guard) so
  keyboard entry isn't clobbered mid-edit.
- Descriptor-driven save guard: an enabled feature section must have at
  least one time.
- i18n (en/ru) keys; refreshed help text; removed dead invalidTimeList.

Tests: time_list normalization/parsing (incl. non-ASCII/odd shapes) and
the enabled-implies-times validation.
2026-05-29 14:57:41 +03:00

251 lines
9.2 KiB
Python

"""Tracking configuration CRUD API routes."""
import logging
from fastapi import APIRouter, Depends, HTTPException, status
from pydantic import BaseModel
from sqlmodel import select
from sqlmodel.ext.asyncio.session import AsyncSession
from ..auth.dependencies import get_current_user
from ..database.engine import get_session
from ..database.models import TrackingConfig, User
from ..services.scheduler import reschedule_immich_dispatch_jobs
from ..services.time_list import TimeListError, normalize_time_list
_LOGGER = logging.getLogger(__name__)
router = APIRouter(prefix="/api/tracking-configs", tags=["tracking-configs"])
# Immich dispatch slots that fire on a wall-clock schedule. Each has a
# ``{kind}_enabled`` flag and a ``{kind}_times`` comma-separated HH:MM list.
_DISPATCH_KINDS = ("periodic", "scheduled", "memory")
# TrackingConfig fields holding comma-separated ``HH:MM`` dispatch schedules.
# Normalized (validated, de-duplicated, sorted, capped) on every write so the
# scheduler only ever reads clean values and cron jobs stay deterministic.
_TIME_LIST_FIELDS = tuple(f"{k}_times" for k in _DISPATCH_KINDS)
def _normalize_time_fields(values: dict) -> None:
"""Canonicalize any ``*_times`` keys present in ``values`` (in place).
Raises HTTP 422 with a field-scoped message when an entry is malformed or
the list exceeds the per-day cap, so the client surfaces exactly which slot
was rejected instead of the input being silently dropped at schedule time.
"""
for field in _TIME_LIST_FIELDS:
if values.get(field) is None:
continue
try:
values[field] = normalize_time_list(values[field])
except TimeListError as err:
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{field}: {err}",
) from err
def _validate_enabled_have_times(values: dict, existing: TrackingConfig | None) -> None:
"""Reject enabling a dispatch slot with no fire times.
An enabled slot whose ``{kind}_times`` normalizes to empty would save fine
but the scheduler creates zero cron jobs for it — a silently dead slot that
shows as "enabled" in the UI yet never fires. We fail the write with a 422
instead. Only kinds the request actually touches (enabled flag or times) are
checked, so unrelated edits to a pre-existing config aren't blocked; the
effective state is the request value merged over ``existing``.
"""
for kind in _DISPATCH_KINDS:
enabled_key, times_key = f"{kind}_enabled", f"{kind}_times"
if enabled_key not in values and times_key not in values:
continue
enabled = values.get(
enabled_key, getattr(existing, enabled_key, False) if existing else False
)
times = values.get(
times_key, getattr(existing, times_key, "") if existing else ""
)
if enabled and not (times or "").strip():
raise HTTPException(
status_code=status.HTTP_422_UNPROCESSABLE_ENTITY,
detail=f"{times_key}: add at least one time when {kind} is enabled",
)
class TrackingConfigCreate(BaseModel):
provider_type: str
name: str
icon: str = ""
track_assets_added: bool = True
track_assets_removed: bool = False
track_collection_renamed: bool = True
track_collection_deleted: bool = True
track_sharing_changed: bool = False
track_images: bool = True
track_videos: bool = True
notify_favorites_only: bool = False
include_tags: bool = True
include_asset_details: bool = False
max_assets_to_show: int = 10
assets_order_by: str = "none"
assets_order: str = "descending"
periodic_enabled: bool = False
periodic_interval_days: int = 1
periodic_start_date: str = "2025-01-01"
periodic_times: str = "12:00"
scheduled_enabled: bool = False
scheduled_times: str = "09:00"
scheduled_collection_mode: str = "per_collection"
scheduled_limit: int = 10
scheduled_favorite_only: bool = False
scheduled_asset_type: str = "all"
scheduled_min_rating: int = 0
scheduled_order_by: str = "random"
scheduled_order: str = "descending"
memory_enabled: bool = False
memory_source: str = "albums"
memory_times: str = "09:00"
memory_collection_mode: str = "combined"
memory_limit: int = 10
memory_favorite_only: bool = False
memory_asset_type: str = "all"
memory_min_rating: int = 0
quiet_hours_enabled: bool = False
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
class TrackingConfigUpdate(BaseModel):
name: str | None = None
icon: str | None = None
track_assets_added: bool | None = None
track_assets_removed: bool | None = None
track_collection_renamed: bool | None = None
track_collection_deleted: bool | None = None
track_sharing_changed: bool | None = None
track_images: bool | None = None
track_videos: bool | None = None
notify_favorites_only: bool | None = None
include_tags: bool | None = None
include_asset_details: bool | None = None
max_assets_to_show: int | None = None
assets_order_by: str | None = None
assets_order: str | None = None
periodic_enabled: bool | None = None
periodic_interval_days: int | None = None
periodic_start_date: str | None = None
periodic_times: str | None = None
scheduled_enabled: bool | None = None
scheduled_times: str | None = None
scheduled_collection_mode: str | None = None
scheduled_limit: int | None = None
scheduled_favorite_only: bool | None = None
scheduled_asset_type: str | None = None
scheduled_min_rating: int | None = None
scheduled_order_by: str | None = None
scheduled_order: str | None = None
memory_enabled: bool | None = None
memory_source: str | None = None
memory_times: str | None = None
memory_collection_mode: str | None = None
memory_limit: int | None = None
memory_favorite_only: bool | None = None
memory_asset_type: str | None = None
memory_min_rating: int | None = None
quiet_hours_enabled: bool | None = None
quiet_hours_start: str | None = None
quiet_hours_end: str | None = None
@router.get("")
async def list_configs(
provider_type: str | None = None,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from sqlmodel import or_
query = select(TrackingConfig).where(
or_(TrackingConfig.user_id == user.id, TrackingConfig.user_id == 0)
)
if provider_type:
query = query.where(TrackingConfig.provider_type == provider_type)
result = await session.exec(query)
return [_response(c) for c in result.all()]
@router.post("", status_code=status.HTTP_201_CREATED)
async def create_config(
body: TrackingConfigCreate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
data = body.model_dump()
_normalize_time_fields(data)
_validate_enabled_have_times(data, None)
config = TrackingConfig(user_id=user.id, **data)
session.add(config)
await session.commit()
await session.refresh(config)
if config.provider_type == "immich":
await reschedule_immich_dispatch_jobs()
return _response(config)
@router.get("/{config_id}")
async def get_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
return _response(await _get(session, config_id, user.id))
@router.put("/{config_id}")
async def update_config(
config_id: int,
body: TrackingConfigUpdate,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
config = await _get(session, config_id, user.id)
updates = body.model_dump(exclude_unset=True)
_normalize_time_fields(updates)
_validate_enabled_have_times(updates, config)
for field, value in updates.items():
setattr(config, field, value)
session.add(config)
await session.commit()
await session.refresh(config)
if config.provider_type == "immich":
await reschedule_immich_dispatch_jobs()
return _response(config)
@router.delete("/{config_id}", status_code=status.HTTP_204_NO_CONTENT)
async def delete_config(
config_id: int,
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
from .delete_protection import check_tracking_config, raise_if_used
config = await _get(session, config_id, user.id)
raise_if_used(await check_tracking_config(session, config.id), config.name)
provider_type = config.provider_type
await session.delete(config)
await session.commit()
if provider_type == "immich":
await reschedule_immich_dispatch_jobs()
def _response(c: TrackingConfig) -> dict:
return {k: getattr(c, k) for k in TrackingConfig.model_fields if k not in ("user_id", "created_at")} | {
"created_at": c.created_at.isoformat(),
}
async def _get(session: AsyncSession, config_id: int, user_id: int) -> TrackingConfig:
config = await session.get(TrackingConfig, config_id)
if not config or (config.user_id != user_id and config.user_id != 0):
raise HTTPException(status_code=404, detail="Tracking config not found")
return config