feat: add Scheduler provider + multi-provider UX fixes

Scheduler provider:
- Virtual provider (no external service) that emits SCHEDULED_MESSAGE
  events on user-defined intervals or cron expressions
- Custom variables stored in tracker filters, flattened into template context
- fire_count persists across triggers via tracker state
- APScheduler CronTrigger support for cron-mode schedules
- Default templates (EN+RU), seeded on startup

Multi-provider UX fixes:
- Tracking config hides Immich-specific sections (periodic, scheduled,
  memory, asset display) for non-Immich providers
- Command config driven by provider capabilities — hides commands/settings
  for providers without bot commands
- Template config hides empty "Scheduled Messages" group
- Test menu on tracker targets is provider-aware (Immich shows all 4 test
  types, others show only basic)
- Removed redundant Test button from tracker card
- System-owned tracking configs (user_id=0) seeded for Gitea + Scheduler
- Fixed ownership checks to allow system configs in tracker-target links
- Capabilities cache shared across template-configs and command-configs
- Command tracker bot selector uses EntitySelect instead of raw select
- Sample context includes Gitea + Scheduler variables for template preview
This commit is contained in:
2026-03-22 15:50:51 +03:00
parent 6d28cfb8d8
commit 0562f78b35
30 changed files with 688 additions and 56 deletions
@@ -94,7 +94,7 @@ async def create_notification_tracker_target(
# Validate config ownership + provider type match
if body.tracking_config_id:
tc = await session.get(TrackingConfig, body.tracking_config_id)
if not tc or tc.user_id != user.id:
if not tc or (tc.user_id != user.id and tc.user_id != 0):
raise HTTPException(status_code=404, detail="Tracking config not found")
if tc.provider_type != provider.type:
raise HTTPException(
@@ -139,7 +139,7 @@ async def update_notification_tracker_target(
# Validate config ownership + provider type match if being changed
if "tracking_config_id" in updates and updates["tracking_config_id"]:
tc = await session.get(TrackingConfig, updates["tracking_config_id"])
if not tc or tc.user_id != user.id:
if not tc or (tc.user_id != user.id and tc.user_id != 0):
raise HTTPException(status_code=404, detail="Tracking config not found")
if tc.provider_type != provider.type:
raise HTTPException(
@@ -97,6 +97,8 @@ async def create_provider(
detail=test_result.get("message", "Cannot connect to Gitea"),
)
# Scheduler: no validation needed (virtual provider)
provider = ServiceProvider(
user_id=user.id,
type=body.type,
@@ -253,6 +255,9 @@ async def test_provider(
gitea = make_gitea_provider(http_session, provider)
return await gitea.test_connection()
if provider.type == "scheduler":
return {"ok": True, "message": "Virtual provider — always available"}
return {"ok": False, "message": f"Unknown provider type: {provider.type}"}
@@ -101,7 +101,10 @@ async def list_configs(
user: User = Depends(get_current_user),
session: AsyncSession = Depends(get_session),
):
query = select(TrackingConfig).where(TrackingConfig.user_id == user.id)
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)
@@ -167,6 +170,6 @@ def _response(c: TrackingConfig) -> dict:
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:
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
@@ -277,6 +277,8 @@ def _event_allowed_by_tracking_config(event: ServiceEvent, tc: TrackingConfig) -
"pr_merged": tc.track_pr_merged,
"pr_commented": tc.track_pr_commented,
"release_published": tc.track_release_published,
# Scheduler events
"scheduled_message": tc.track_scheduled_message,
# Immich events
"assets_added": tc.track_assets_added,
"assets_removed": tc.track_assets_removed,