8065e6effa
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.
251 lines
9.2 KiB
Python
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
|