fix watchlist remove (was undone by auto-populate); add watch toggle on ticker page
Deploy / lint (push) Successful in 6s
Deploy / test (push) Successful in 34s
Deploy / deploy (push) Successful in 23s

Removing a ticker did nothing because get_watchlist re-runs auto_populate on
every read, instantly re-adding any top-ranked ticker the user had just removed.
Removals are now tombstoned as a "dismissed" entry_type: auto-population skips
them, the list hides them, and a later manual add revives the row. Also exposes
an Add/Remove-watchlist toggle in the ticker detail header.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
2026-06-14 14:17:27 +02:00
parent d892c46fbb
commit 0e9f1846f6
3 changed files with 174 additions and 19 deletions
+39 -13
View File
@@ -26,6 +26,11 @@ logger = logging.getLogger(__name__)
DEFAULT_AUTO_SIZE = 10
MAX_MANUAL = 10
# entry_type values. "dismissed" is a tombstone: the user removed the ticker, so
# auto-population must not silently re-add it on the next read. Dismissed rows are
# hidden from the watchlist and revived (→ manual) if the user adds them again.
DISMISSED = "dismissed"
async def _get_ticker(db: AsyncSession, symbol: str) -> Ticker:
normalised = symbol.strip().upper()
@@ -64,19 +69,20 @@ async def auto_populate(
)
)
# Get manual ticker_ids so we don't duplicate
manual_result = await db.execute(
# Get manual + dismissed ticker_ids so we don't duplicate or resurrect a
# ticker the user has explicitly removed.
kept_result = await db.execute(
select(WatchlistEntry.ticker_id).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type == "manual",
WatchlistEntry.entry_type.in_(["manual", DISMISSED]),
)
)
manual_ticker_ids = {row[0] for row in manual_result.all()}
excluded_ticker_ids = {row[0] for row in kept_result.all()}
now = datetime.now(timezone.utc)
for ticker_id in top_ticker_ids:
if ticker_id in manual_ticker_ids:
continue # Already on watchlist as manual
if ticker_id in excluded_ticker_ids:
continue # Already manual, or dismissed by the user
entry = WatchlistEntry(
user_id=user_id,
ticker_id=ticker_id,
@@ -100,17 +106,19 @@ async def add_manual_entry(
"""
ticker = await _get_ticker(db, symbol)
# Check if already on watchlist
# Check if already on watchlist. A dismissed row is revived as manual rather
# than rejected; an active (auto/manual) row is a genuine duplicate.
existing = await db.execute(
select(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.ticker_id == ticker.id,
)
)
if existing.scalar_one_or_none() is not None:
existing_entry = existing.scalar_one_or_none()
if existing_entry is not None and existing_entry.entry_type != DISMISSED:
raise DuplicateError(f"Ticker already on watchlist: {ticker.symbol}")
# Count current manual entries
# Count current manual entries (dismissed tombstones don't count)
count_result = await db.execute(
select(func.count()).select_from(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
@@ -125,10 +133,11 @@ async def add_manual_entry(
"Remove an entry before adding a new one."
)
# Check total cap
# Check total cap (exclude dismissed tombstones)
total_result = await db.execute(
select(func.count()).select_from(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type != DISMISSED,
)
)
total_count = total_result.scalar() or 0
@@ -140,6 +149,14 @@ async def add_manual_entry(
"Remove an entry before adding a new one."
)
if existing_entry is not None:
# Revive a dismissed entry as manual.
existing_entry.entry_type = "manual"
existing_entry.added_at = datetime.now(timezone.utc)
await db.commit()
await db.refresh(existing_entry)
return existing_entry
entry = WatchlistEntry(
user_id=user_id,
ticker_id=ticker.id,
@@ -157,20 +174,26 @@ async def remove_entry(
user_id: int,
symbol: str,
) -> None:
"""Remove a watchlist entry (manual or auto)."""
"""Remove a watchlist entry (manual or auto).
Marks the entry as dismissed (a tombstone) rather than deleting it, so the
next auto-population pass won't immediately re-add a top-ranked ticker the
user just removed. A subsequent manual add revives the row.
"""
ticker = await _get_ticker(db, symbol)
result = await db.execute(
select(WatchlistEntry).where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.ticker_id == ticker.id,
WatchlistEntry.entry_type != DISMISSED,
)
)
entry = result.scalar_one_or_none()
if entry is None:
raise NotFoundError(f"Ticker not on watchlist: {ticker.symbol}")
await db.delete(entry)
entry.entry_type = DISMISSED
await db.commit()
@@ -254,7 +277,10 @@ async def get_watchlist(
stmt = (
select(WatchlistEntry, Ticker.symbol)
.join(Ticker, WatchlistEntry.ticker_id == Ticker.id)
.where(WatchlistEntry.user_id == user_id)
.where(
WatchlistEntry.user_id == user_id,
WatchlistEntry.entry_type != DISMISSED,
)
)
result = await db.execute(stmt)
rows = result.all()