feat: Actions system — scheduled mutations on external services

Full-stack implementation of provider-scoped Actions with extensible
executor architecture. First action type: Immich auto_organize (sort
assets into albums by person, CLIP search, date range, favorites).

Core:
- ActionTypeDefinition registry + ActionExecutor ABC with execute/validate/dry-run
- ImmichActionExecutor with multi-album support and client-side filtering
- ImmichClient write methods: add/remove assets, create album, paginated search

Server:
- Action, ActionRule, ActionExecution DB models
- Full CRUD API + manual execute + dry-run + execution history endpoints
- APScheduler integration (interval + cron) for automated execution
- Action type discovery API + provider people endpoint

Frontend:
- Actions page with CRUD, execute/dry-run buttons, inline rule editor
- RuleEditor: person/album MultiEntitySelect pickers, criteria config
- ExecutionHistory: expandable per-rule result details
- MultiEntitySelect reusable component (searchable multi-pick palette)
- Notification tracker album picker migrated to MultiEntitySelect
- Fixed MdiIcon race condition (icons missing after cache-clearing reload)
This commit is contained in:
2026-03-23 16:59:20 +03:00
parent 0fde3c6b3d
commit 6a559bfcd2
26 changed files with 2888 additions and 25 deletions
@@ -470,6 +470,60 @@ class EventLog(SQLModel, table=True):
created_at: datetime = Field(default_factory=_utcnow)
class Action(SQLModel, table=True):
"""A scheduled action that mutates an external service."""
__tablename__ = "action"
id: int | None = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="user.id")
provider_id: int = Field(foreign_key="service_provider.id")
name: str
icon: str = Field(default="")
action_type: str # e.g. "auto_organize"
config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
schedule_type: str = Field(default="interval") # "interval" or "cron"
schedule_interval: int = Field(default=3600) # seconds
schedule_cron: str = Field(default="")
enabled: bool = Field(default=False) # default disabled for safety
last_run_at: datetime | None = Field(default=None)
last_run_status: str = Field(default="") # "success", "partial", "failed", ""
created_at: datetime = Field(default_factory=_utcnow)
class ActionRule(SQLModel, table=True):
"""One rule within an Action. Executed in order."""
__tablename__ = "action_rule"
id: int | None = Field(default=None, primary_key=True)
action_id: int = Field(foreign_key="action.id", index=True)
name: str = Field(default="")
rule_config: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
enabled: bool = Field(default=True)
order: int = Field(default=0)
created_at: datetime = Field(default_factory=_utcnow)
class ActionExecution(SQLModel, table=True):
"""Log of an action execution (scheduled, manual, or dry-run)."""
__tablename__ = "action_execution"
id: int | None = Field(default=None, primary_key=True)
action_id: int = Field(foreign_key="action.id", index=True)
started_at: datetime = Field(default_factory=_utcnow)
finished_at: datetime | None = Field(default=None)
status: str = Field(default="running") # "running", "success", "partial", "failed"
rules_processed: int = Field(default=0)
rules_succeeded: int = Field(default=0)
rules_failed: int = Field(default=0)
total_items_affected: int = Field(default=0)
summary: dict[str, Any] = Field(default_factory=dict, sa_column=Column(JSON))
error: str = Field(default="")
trigger: str = Field(default="scheduled") # "scheduled", "manual", "dry_run"
class AppSetting(SQLModel, table=True):
"""Key-value app-level settings (admin-configurable)."""