soulsync/core/profile_context.py
BoulderBadgeDad 6980253a96 Automations: run each as its OWNER profile in the background (part 1 of per-profile sync)
Background automations had no session, so get_current_profile_id() fell back to
admin (1) — wrong for a non-admin's scheduled job. Now the engine declares the
automation's owner around handler execution via a contextvar
(core/profile_context.py), and get_current_profile_id() consults it only when
there's NO web request. So:
- a real logged-in request always wins (foreground unchanged),
- admin + system automations are profile 1 → resolve to admin exactly as before
  (the 8 admin-owned auto-sync pipelines behave identically),
- only non-admin-owned automations gain their correct identity, deep through the
  whole call chain (incl. the per-profile client resolvers) — no threading
  profile_id through dozens of signatures.

Reset in a finally so a pooled thread can't leak the override to the next job.

Tests: contextvar set/reset/nested; get_current_profile_id honours the override
only outside a request (a real session still wins); and end-to-end — the engine
runs a non-admin automation as profile 4, an admin one as 1, an explicit trigger
profile overrides the owner, and the context resets even when the handler raises.
27 + 4 tests pass.

Part 2 (next): point the sync handlers' source-playlist READ at
get_spotify_client_for_profile so a non-admin's auto-sync pulls THEIR playlist.
2026-06-10 15:37:56 -07:00

45 lines
1.7 KiB
Python

"""Background profile context.
Work that runs OUTSIDE a web request — the automation engine, scheduled jobs —
has no Flask session, so ``get_current_profile_id()`` falls back to admin
(profile 1). That's wrong for an automation owned by a non-admin: their
playlist pull, their per-profile writes, should act as THEM.
This lets the engine declare "the work below is running for profile X" around a
unit of background work (set/reset in a try/finally). ``get_current_profile_id``
consults it only when there's no real request — so an actual logged-in session
always wins, and nothing changes for foreground/admin paths. Built on a
``ContextVar`` so the value is scoped to the running call and reset cleanly,
even on thread-pool reuse.
"""
from __future__ import annotations
import contextvars
_background_profile_id: "contextvars.ContextVar[int | None]" = contextvars.ContextVar(
"background_profile_id", default=None
)
def set_background_profile(profile_id):
"""Declare the profile for the current background unit of work. Returns a
token to pass to reset_background_profile (use in try/finally)."""
return _background_profile_id.set(profile_id)
def reset_background_profile(token) -> None:
"""Restore the previous background profile (clears the override)."""
try:
_background_profile_id.reset(token)
except Exception:
# Token from a different context — clear to the default rather than leak.
_background_profile_id.set(None)
def get_background_profile():
"""The background profile in effect, or None if none is set."""
return _background_profile_id.get()
__all__ = ["set_background_profile", "reset_background_profile", "get_background_profile"]