fix watchlist remove (was undone by auto-populate); add watch toggle on ticker page
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:
@@ -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()
|
||||
|
||||
Reference in New Issue
Block a user