89 lines
3.6 KiB
Python
89 lines
3.6 KiB
Python
import logging
|
|
from repositories.watchlist_repository import watchlist_repository
|
|
from repositories.seat_repository import seat_repository
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
# Per-seat cap on watched trips, keyed by tenant tier — bounds the nightly
|
|
# cron's scrape volume predictably (seats x cap) instead of the old
|
|
# "re-scrape every competitor's homepage" approach. 1/seat at Analyse
|
|
# means a fully-seated ($349, 5 seats) tenant tops out at 5 real trip
|
|
# scrapes a night, same ceiling as scraping every competitor once, but
|
|
# now every alert is about a trip a PM actually chose to track instead of
|
|
# homepage noise. Discover/Intelligence values are placeholders — those
|
|
# tiers aren't built yet (CLAUDE.md §27) and will also get full catalogue
|
|
# auto-discovery on top, so the watchlist there is "pin your priorities,"
|
|
# not the only detection mechanism.
|
|
WATCHLIST_CAP_PER_SEAT = {
|
|
'analyse': 1,
|
|
'discover': 2,
|
|
'intelligence': 3,
|
|
'enterprise': 3,
|
|
}
|
|
|
|
|
|
def add_to_watchlist(tenant, seat, competitor_id, url, trip_name):
|
|
"""
|
|
Adds a specific competitor trip to a seat's personal watchlist,
|
|
enforcing WATCHLIST_CAP_PER_SEAT[tenant['tier']] against the seat's
|
|
current count first — same count-vs-cap shape as
|
|
licence_service.validate_email()'s seat check and api.py's
|
|
add_seat(). Never silently overwrites or evicts an existing item.
|
|
|
|
Data flow:
|
|
tenant + seat + competitor_id/url/trip_name →
|
|
watchlist_repository.count_for_seat(seat['id']) →
|
|
count >= cap for this tier → {'success': False, 'reason': ...} →
|
|
otherwise → watchlist_repository.create() →
|
|
{'success': True, 'item': created_record}
|
|
"""
|
|
cap = WATCHLIST_CAP_PER_SEAT.get(tenant.get('tier'), 1)
|
|
current_count = watchlist_repository.count_for_seat(seat['id'])
|
|
if current_count >= cap:
|
|
return {
|
|
'success': False,
|
|
'reason': f'Your watchlist is full ({current_count}/{cap}). Remove a trip to add another.',
|
|
}
|
|
|
|
item = watchlist_repository.create({
|
|
'tenant_id': tenant['id'],
|
|
'seat_id': seat['id'],
|
|
'competitor_id': competitor_id,
|
|
'url': url,
|
|
'trip_name': trip_name,
|
|
})
|
|
return {'success': True, 'item': item}
|
|
|
|
|
|
def remove_from_watchlist(item_id):
|
|
"""
|
|
Removes a watchlist item outright. Any active seat on the tenant may
|
|
remove any item — no per-seat ownership lock, matching this app's
|
|
existing no-fine-grained-permissions convention (e.g. CompetitorsPage
|
|
edits aren't restricted to whoever added a competitor either).
|
|
Returns True if a record was deleted, False if item_id was unknown.
|
|
"""
|
|
return watchlist_repository.delete(item_id)
|
|
|
|
|
|
def list_watchlist_for_tenant(tenant_id):
|
|
"""
|
|
Returns every watched item for a tenant (all seats'), each enriched
|
|
with the watching seat's email for display — a PM should be able to
|
|
see what teammates are tracking even though the cap itself is
|
|
per-seat. A seat that's since been removed (seat_repository.get_by_id
|
|
returns None) still shows the item with watched_by_email=None rather
|
|
than raising, so a removed teammate's picks don't break the whole list.
|
|
|
|
Data flow:
|
|
tenant_id → watchlist_repository.list_for_tenant() →
|
|
per item: seat_repository.get_by_id(item['seat_id']) →
|
|
item + watched_by_email merged → list returned
|
|
"""
|
|
items = watchlist_repository.list_for_tenant(tenant_id)
|
|
enriched = []
|
|
for item in items:
|
|
seat = seat_repository.get_by_id(item.get('seat_id'))
|
|
enriched.append({**item, 'watched_by_email': seat.get('email') if seat else None})
|
|
return enriched
|