Files
pokemon-stock-checker/dashboard/api.py
T
2026-04-10 18:30:04 -04:00

1294 lines
41 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"""
REST API endpoints for Pokemon Stock Monitor dashboard.
"""
import sys
import re
from pathlib import Path
# Add parent directory to path for imports
sys.path.insert(0, str(Path(__file__).parent.parent))
from flask import Blueprint, jsonify, request
from src.database import get_database
from src.favorites import get_favorites_manager
from src.scraper_state import scraper_state
from src.discord_notifier import send_error_notification
api_bp = Blueprint('api', __name__)
def clean_product_name(name: str) -> str:
"""Fix common encoding issues and garbage in product names"""
if not name:
return name
# Remove SVG/CSS class garbage (pikachu_svg__st1{fill:#7c888f}.review-full-...)
name = re.sub(r'[\w-]*_svg__[\w\s{}:#.;-]+', '', name, flags=re.IGNORECASE)
name = re.sub(r'\.review-full-[\w\s{}:#.;-]+', '', name, flags=re.IGNORECASE)
name = re.sub(r'\{[^}]*\}', '', name) # Remove any remaining {css} blocks
name = re.sub(r'\d+\s*Reviews?$', '', name, flags=re.IGNORECASE) # Remove trailing "X Reviews"
# Fix Pokémon encoding issues
name = re.sub(r'Pok[éÃ\u00c3\u00a9]+mon', 'Pokémon', name, flags=re.IGNORECASE)
name = name.replace('Pokémon', 'Pokémon')
name = name.replace('Pokテゥmon', 'Pokémon')
# Fix dashes
name = name.replace('â€"', '')
name = name.replace('â€"', '-')
# Fix quotes and apostrophes
name = name.replace('’', "'")
name = name.replace('‘', "'")
name = name.replace('“', '"')
name = name.replace(''', "'")
name = name.replace(''', "'")
# Fix accented characters
name = name.replace('é', 'é')
name = name.replace('è', 'è')
name = name.replace('Ã ', 'à')
# Fix trademark
name = name.replace('â„¢', '')
name = name.replace('®', '®')
name = name.replace('Â', '')
# Clean up multiple spaces
name = re.sub(r'\s+', ' ', name)
return name.strip()
def clean_product_dict(product: dict) -> dict:
"""Clean encoding in a product dictionary"""
if product and 'name' in product:
product['name'] = clean_product_name(product['name'])
return product
# ==================== Stats Endpoints ====================
@api_bp.route('/stats')
def get_stats():
"""Get dashboard stats summary"""
db = get_database()
stats = db.get_dashboard_stats()
# If no check in database, fall back to scraper state's last_check
if not stats.get('last_check'):
state = scraper_state.get_state()
if state.get('last_check'):
stats['last_check'] = {'checked_at': state['last_check']}
return jsonify(stats)
@api_bp.route('/health')
def get_health():
"""Get monitor health status"""
db = get_database()
last_check = db.get_last_check()
return jsonify({
'status': 'ok',
'last_check': last_check
})
# ==================== Products Endpoints ====================
@api_bp.route('/products')
def get_products():
"""Get products with optional filters"""
db = get_database()
# Parse query params
site = request.args.get('site')
category = request.args.get('category')
in_stock = request.args.get('in_stock')
favorites_only = request.args.get('favorites_only', 'false').lower() == 'true'
event_type = request.args.get('event_type') # 'new_drop' or 'restock'
period = request.args.get('period') # 'today' or 'week'
limit = int(request.args.get('limit', 100))
offset = int(request.args.get('offset', 0))
# Convert in_stock to bool if provided
if in_stock is not None:
in_stock = in_stock.lower() == 'true'
products = db.get_products(
site=site,
category=category,
in_stock=in_stock,
favorites_only=favorites_only,
event_type=event_type,
period=period,
limit=limit,
offset=offset
)
# Clean names and add favorite status to each product
favorites = get_favorites_manager()
for product in products:
clean_product_dict(product)
fav = favorites.check_product_priority(
url=product['url'],
name=product['name'],
category=product.get('category')
)
product['is_favorite'] = fav is not None
product['favorite_id'] = fav['id'] if fav else None
return jsonify({
'products': products,
'total': db.get_product_count(site=site, in_stock=in_stock)
})
@api_bp.route('/products/<int:product_id>')
def get_product(product_id):
"""Get single product details"""
db = get_database()
product = db.get_product(product_id)
if not product:
return jsonify({'error': 'Product not found'}), 404
clean_product_dict(product)
return jsonify(product)
@api_bp.route('/products/<int:product_id>/history')
def get_product_history(product_id):
"""Get price and stock history for a product"""
db = get_database()
days = int(request.args.get('days', 30))
price_history = db.get_price_history(product_id, days=days)
# Get stock events for this product
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("""
SELECT event_type, recorded_at FROM stock_events
WHERE product_id = ? AND recorded_at >= datetime('now', ?)
ORDER BY recorded_at ASC
""", (product_id, f"-{days} days"))
stock_events = [dict(row) for row in cursor.fetchall()]
return jsonify({
'price_history': price_history,
'stock_events': stock_events
})
@api_bp.route('/products/<int:product_id>/timeline')
def get_product_timeline(product_id):
"""Get complete timeline of all events for a product"""
db = get_database()
product = db.get_product(product_id)
if not product:
return jsonify({'error': 'Product not found'}), 404
with db.get_connection() as conn:
cursor = conn.cursor()
# Get all stock events
cursor.execute("""
SELECT 'stock' as source, event_type as type, recorded_at as timestamp
FROM stock_events WHERE product_id = ?
UNION ALL
SELECT 'price' as source, 'price_change' as type, recorded_at as timestamp
FROM price_history WHERE product_id = ?
ORDER BY timestamp DESC
""", (product_id, product_id))
events = [dict(row) for row in cursor.fetchall()]
# Get price at each point
cursor.execute("""
SELECT price, recorded_at FROM price_history
WHERE product_id = ? ORDER BY recorded_at ASC
""", (product_id,))
prices = [dict(row) for row in cursor.fetchall()]
# Calculate stats
stats = {
'total_restocks': sum(1 for e in events if e['type'] == 'restock'),
'total_sellouts': sum(1 for e in events if e['type'] == 'out_of_stock'),
'price_changes': sum(1 for e in events if e['type'] == 'price_change'),
'first_seen': product.get('first_seen'),
'last_seen': product.get('last_seen')
}
return jsonify({
'product': product,
'timeline': events,
'price_history': prices,
'stats': stats
})
# ==================== Events Endpoints ====================
@api_bp.route('/events')
def get_events():
"""Get recent events feed"""
db = get_database()
limit = int(request.args.get('limit', 20))
event_types = request.args.getlist('type')
events = db.get_recent_events(
limit=limit,
event_types=event_types if event_types else None
)
# Clean product names in events
for event in events:
clean_product_dict(event)
return jsonify({'events': events})
# ==================== Analytics Endpoints ====================
@api_bp.route('/analytics/drops')
def get_drop_timing():
"""Get drop timing analytics"""
db = get_database()
days = int(request.args.get('days', 30))
stats = db.get_drop_timing_stats(days=days)
return jsonify({'drop_timing': stats})
@api_bp.route('/analytics/stock')
def get_stock_duration():
"""Get stock duration analytics"""
db = get_database()
stats = db.get_stock_duration_stats()
return jsonify({'stock_duration': stats})
@api_bp.route('/analytics/sites')
def get_site_stats():
"""Get per-site statistics"""
db = get_database()
stats = db.get_site_stats()
return jsonify({'sites': stats})
@api_bp.route('/analytics/checks')
def get_check_history():
"""Get check history"""
db = get_database()
site = request.args.get('site')
limit = int(request.args.get('limit', 100))
history = db.get_check_history(site=site, limit=limit)
return jsonify({'checks': history})
@api_bp.route('/analytics/selling-rates')
def get_selling_rates():
"""Get detailed selling rate analytics per product"""
db = get_database()
limit = int(request.args.get('limit', 50))
category = request.args.get('category')
with db.get_connection() as conn:
cursor = conn.cursor()
# Get products with their sell-through times
query = """
SELECT
p.id, p.name, p.url, p.site, p.category, p.image_url,
COUNT(CASE WHEN e.event_type = 'restock' THEN 1 END) as restock_count,
COUNT(CASE WHEN e.event_type = 'out_of_stock' THEN 1 END) as sellout_count,
COUNT(CASE WHEN e.event_type = 'new_drop' THEN 1 END) as drop_count
FROM products p
LEFT JOIN stock_events e ON p.id = e.product_id
WHERE 1=1
"""
params = []
if category:
query += " AND p.category = ?"
params.append(category)
query += """
GROUP BY p.id
HAVING sellout_count > 0 OR restock_count > 0
ORDER BY (restock_count + sellout_count) DESC
LIMIT ?
"""
params.append(limit)
cursor.execute(query, params)
products = [dict(row) for row in cursor.fetchall()]
# For each product, calculate average time to sell out
for product in products:
cursor.execute("""
SELECT
in_event.recorded_at as in_stock_time,
out_event.recorded_at as out_stock_time,
CAST((julianday(out_event.recorded_at) - julianday(in_event.recorded_at)) * 24 * 60 AS INTEGER) as minutes_in_stock
FROM stock_events in_event
JOIN stock_events out_event ON in_event.product_id = out_event.product_id
AND out_event.event_type = 'out_of_stock'
AND out_event.recorded_at > in_event.recorded_at
AND NOT EXISTS (
SELECT 1 FROM stock_events middle
WHERE middle.product_id = in_event.product_id
AND middle.event_type IN ('restock', 'new_drop', 'out_of_stock')
AND middle.recorded_at > in_event.recorded_at
AND middle.recorded_at < out_event.recorded_at
)
WHERE in_event.product_id = ?
AND in_event.event_type IN ('restock', 'new_drop')
ORDER BY in_event.recorded_at DESC
LIMIT 10
""", (product['id'],))
sellthrough_times = [dict(row) for row in cursor.fetchall()]
product['sellthrough_history'] = sellthrough_times
if sellthrough_times:
avg_minutes = sum(s['minutes_in_stock'] for s in sellthrough_times) / len(sellthrough_times)
product['avg_minutes_to_sellout'] = round(avg_minutes, 1)
else:
product['avg_minutes_to_sellout'] = None
return jsonify({'products': products})
# ==================== Favorites Endpoints ====================
@api_bp.route('/favorites', methods=['GET'])
def get_favorites():
"""Get all favorites"""
favorites = get_favorites_manager()
fav_type = request.args.get('type')
if fav_type:
favs = favorites.db.get_favorites(fav_type=fav_type)
else:
favs = favorites.get_all_favorites()
return jsonify({'favorites': favs})
@api_bp.route('/favorites', methods=['POST'])
def add_favorite():
"""Add a new favorite"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
fav_type = data.get('type')
value = data.get('value')
if not fav_type or not value:
return jsonify({'error': 'type and value are required'}), 400
favorites = get_favorites_manager()
if fav_type == 'product':
fav_id = favorites.add_product_favorite(
url=value,
display_name=data.get('display_name'),
priority=data.get('priority', 'high'),
notify_discord=data.get('notify_discord', True),
notify_sound=data.get('notify_sound', False),
custom_webhook=data.get('custom_webhook')
)
elif fav_type == 'category':
fav_id = favorites.add_category_favorite(
category=value,
display_name=data.get('display_name'),
priority=data.get('priority', 'high'),
notify_discord=data.get('notify_discord', True),
notify_sound=data.get('notify_sound', False),
custom_webhook=data.get('custom_webhook')
)
else:
return jsonify({'error': 'Invalid type. Must be "product" or "category"'}), 400
return jsonify({'id': fav_id, 'success': True}), 201
@api_bp.route('/favorites/<int:favorite_id>', methods=['PUT'])
def update_favorite(favorite_id):
"""Update a favorite"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
favorites = get_favorites_manager()
favorites.update_favorite(favorite_id, **data)
return jsonify({'success': True})
@api_bp.route('/favorites/<int:favorite_id>', methods=['DELETE'])
def delete_favorite(favorite_id):
"""Delete a favorite"""
favorites = get_favorites_manager()
favorites.remove_favorite(favorite_id)
return jsonify({'success': True})
@api_bp.route('/favorites/suggestions')
def get_favorite_suggestions():
"""Get suggested categories for favorites"""
favorites = get_favorites_manager()
suggestions = favorites.get_suggested_categories()
return jsonify({'suggestions': suggestions})
# ==================== Scraper Control Endpoints ====================
@api_bp.route('/scrapers/state')
def get_scraper_state():
"""Get current scraper state"""
state = scraper_state.get_state()
return jsonify(state)
@api_bp.route('/scrapers/<scraper>/toggle', methods=['POST'])
def toggle_scraper(scraper):
"""Enable or disable a scraper"""
data = request.get_json() or {}
enabled = data.get('enabled')
if enabled is None:
# Toggle current state
current = scraper_state.get_state()
if scraper not in current['scrapers']:
return jsonify({'error': 'Unknown scraper'}), 404
enabled = not current['scrapers'][scraper]['enabled']
success = scraper_state.set_scraper_enabled(scraper, enabled)
if success:
return jsonify({'success': True, 'enabled': enabled})
return jsonify({'error': 'Failed to update scraper state'}), 400
@api_bp.route('/scrapers/interval', methods=['POST'])
def set_check_interval():
"""Set the check interval"""
data = request.get_json() or {}
interval = data.get('interval')
if not interval or not isinstance(interval, int):
return jsonify({'error': 'interval (int) required'}), 400
success = scraper_state.set_check_interval(interval)
if success:
return jsonify({'success': True, 'interval': interval})
return jsonify({'error': 'Interval must be at least 10 seconds'}), 400
@api_bp.route('/scrapers/start', methods=['POST'])
def start_monitor():
"""Start the stock monitor"""
result = scraper_state.start_monitor()
if result['success']:
return jsonify(result)
return jsonify(result), 400
@api_bp.route('/scrapers/stop', methods=['POST'])
def stop_monitor():
"""Stop the stock monitor"""
result = scraper_state.stop_monitor()
if result['success']:
return jsonify(result)
return jsonify(result), 400
# ==================== News Endpoints ====================
@api_bp.route('/news')
def get_news():
"""Get news articles with optional filters"""
db = get_database()
# Parse query params
source = request.args.get('source')
sentiment = request.args.get('sentiment')
drop_related = request.args.get('drop_related')
limit = int(request.args.get('limit', 50))
offset = int(request.args.get('offset', 0))
# Convert drop_related to bool if provided
if drop_related is not None:
drop_related = drop_related.lower() == 'true'
articles = db.get_news_articles(
source=source,
sentiment=sentiment,
drop_related=drop_related,
limit=limit,
offset=offset
)
return jsonify({
'articles': articles,
'total': len(articles) # For now, just return count of fetched
})
@api_bp.route('/news/stats')
def get_news_stats():
"""Get news statistics"""
db = get_database()
stats = db.get_news_stats()
return jsonify(stats)
@api_bp.route('/news/correlation')
def get_news_correlation():
"""Get news articles correlated with recent drops"""
db = get_database()
hours = int(request.args.get('hours', 24))
articles = db.get_correlated_news(hours=hours)
return jsonify({'articles': articles})
@api_bp.route('/news/refresh', methods=['POST'])
def refresh_news():
"""Manually trigger news fetch from all sources"""
from src.news.pokemon_fetcher import fetch_pokemon_news
from src.news.sentiment import analyze_article
db = get_database()
fetched = 0
errors = []
# Fetch Pokemon.com news
try:
articles = fetch_pokemon_news(limit=20)
for article in articles:
db.add_news_article(
source=article.source,
source_account=article.source_account,
external_id=article.external_id,
title=article.title,
content=article.content,
url=article.url,
author=article.author,
image_url=article.image_url,
published_at=article.published_at.isoformat() if article.published_at else None,
sentiment_score=article.sentiment_score,
sentiment_label=article.sentiment_label,
keywords=article.keywords,
is_drop_related=article.is_drop_related,
is_restock_related=article.is_restock_related
)
fetched += 1
except Exception as e:
errors.append(f"Pokemon.com: {str(e)}")
return jsonify({
'success': True,
'fetched': fetched,
'errors': errors
})
@api_bp.route('/news/manual', methods=['POST'])
def add_manual_news():
"""Add manually pasted news (Discord messages, etc.)"""
from src.news.sentiment import analyze_article
from datetime import datetime
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
content = data.get('content')
if not content:
return jsonify({'error': 'content is required'}), 400
source_account = data.get('source_account', 'Manual Entry')
url = data.get('url')
title = data.get('title')
# Analyze sentiment
analysis = analyze_article(content, title)
db = get_database()
article_id = db.add_news_article(
source='discord_manual',
source_account=source_account,
external_id=f"manual_{datetime.now().timestamp()}",
title=title,
content=content,
url=url,
author=source_account,
published_at=datetime.now().isoformat(),
sentiment_score=analysis['sentiment_score'],
sentiment_label=analysis['sentiment_label'],
keywords=analysis['keywords'],
is_drop_related=analysis['is_drop_related'],
is_restock_related=analysis['is_restock_related']
)
return jsonify({'id': article_id, 'success': True, 'analysis': analysis}), 201
@api_bp.route('/news/<int:article_id>', methods=['DELETE'])
def delete_news_article(article_id):
"""Delete a news article"""
db = get_database()
# Check if article exists
article = db.get_news_article(article_id)
if not article:
return jsonify({'error': 'Article not found'}), 404
# Delete from database
with db.get_connection() as conn:
cursor = conn.cursor()
cursor.execute("DELETE FROM news_articles WHERE id = ?", (article_id,))
return jsonify({'success': True})
# ==================== Chrome Extension Integration ====================
# In-memory storage for extension data (persisted to file)
import json
from datetime import datetime
from pathlib import Path
EXTENSION_DATA_FILE = Path(__file__).parent.parent / "data" / "extension_data.json"
def load_extension_data():
"""Load extension data from file"""
if EXTENSION_DATA_FILE.exists():
try:
with open(EXTENSION_DATA_FILE, 'r') as f:
return json.load(f)
except:
pass
return {
'skus': [],
'products': [],
'api_stats': {},
'last_sync': None
}
def save_extension_data(data):
"""Save extension data to file"""
EXTENSION_DATA_FILE.parent.mkdir(parents=True, exist_ok=True)
with open(EXTENSION_DATA_FILE, 'w') as f:
json.dump(data, f, indent=2)
extension_data = load_extension_data()
@api_bp.route('/extension/sync', methods=['POST'])
def sync_extension_data():
"""
Receive data from Chrome extension.
The extension should POST its known SKUs, products, events, and stats.
"""
global extension_data
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
db = get_database()
new_skus = []
events_processed = 0
# Merge SKUs (add new ones)
# NOTE: We only store SKUs for reference - we don't create products from bare SKUs
# because Pokemon Center URLs require a product slug that we don't have.
# Products are only created when we receive full product data with proper URLs.
if 'skus' in data:
existing_skus = set(extension_data.get('skus', []))
new_skus = [s for s in data['skus'] if s not in existing_skus]
extension_data['skus'] = list(existing_skus | set(data['skus']))
# Merge products - update existing or add new
if 'products' in data:
existing_urls = {p['url']: p for p in extension_data.get('products', [])}
for product in data['products']:
url = product.get('url')
if not url:
continue
# Update extension data store
if url in existing_urls:
# Update existing product data
existing_urls[url].update(product)
else:
extension_data['products'].append(product)
existing_urls[url] = product
# Sync to main database
product_id = db.get_or_create_product(
url=url,
name=product.get('name', 'Unknown'),
site=product.get('site', 'pokemoncenter'),
product_id=product.get('sku') or product.get('productId'),
image_url=product.get('imageUrl'),
price=product.get('price'),
in_stock=product.get('inStock', True)
)
# Update price if changed
if product.get('price') and product_id:
db.update_product_price(product_id, product['price'])
extension_data['products'] = list(existing_urls.values())
# Process events (restocks, new drops, price changes)
if 'events' in data:
for event in data['events']:
event_type = event.get('type')
url = event.get('url')
if not event_type or not url:
continue
product = db.get_product_by_url(url)
if not product:
# Create product if it doesn't exist
product_id = db.get_or_create_product(
url=url,
name=event.get('name', 'Unknown'),
site='pokemoncenter',
in_stock=event.get('inStock', True),
price=event.get('price')
)
else:
product_id = product['id']
if product_id:
if event_type == 'restock':
db.record_stock_event(product_id, 'restock')
# Update stock status
db.update_product_stock(product_id, True)
events_processed += 1
elif event_type == 'new_drop':
db.record_stock_event(product_id, 'new_drop')
events_processed += 1
elif event_type == 'out_of_stock':
db.record_stock_event(product_id, 'out_of_stock')
# Update stock status
db.update_product_stock(product_id, False)
events_processed += 1
elif event_type == 'price_change':
new_price = event.get('newPrice')
if new_price:
db.update_product_price(product_id, new_price)
events_processed += 1
# Check for bot protection - extension explicitly reports when a check returned 0 products
if data.get('bot_protection_detected'):
send_error_notification(
"Pokemon Center extension detected possible bot protection — 0 products found on last check. "
"Check the extension tab manually and verify you can browse PokemonCenter.com.",
site="pokemoncenter"
)
# Update API stats
if 'apiStats' in data:
extension_data['api_stats'] = data['apiStats']
extension_data['last_sync'] = datetime.now().isoformat()
save_extension_data(extension_data)
return jsonify({
'success': True,
'total_skus': len(extension_data['skus']),
'total_products': len(extension_data['products']),
'new_skus_added': len(new_skus),
'events_processed': events_processed
})
@api_bp.route('/extension/skus', methods=['GET'])
def get_extension_skus():
"""Get all SKUs tracked by the extension"""
return jsonify({
'skus': extension_data.get('skus', []),
'total': len(extension_data.get('skus', []))
})
@api_bp.route('/extension/skus', methods=['POST'])
def add_extension_skus():
"""Add new SKUs from extension"""
global extension_data
data = request.get_json()
if not data or 'skus' not in data:
return jsonify({'error': 'skus array required'}), 400
existing = set(extension_data.get('skus', []))
new_skus = [s for s in data['skus'] if s not in existing]
extension_data['skus'] = list(existing | set(data['skus']))
extension_data['last_sync'] = datetime.now().isoformat()
save_extension_data(extension_data)
return jsonify({
'success': True,
'added': len(new_skus),
'total': len(extension_data['skus'])
})
@api_bp.route('/extension/products', methods=['GET'])
def get_extension_products():
"""Get all products tracked by the extension"""
return jsonify({
'products': extension_data.get('products', []),
'total': len(extension_data.get('products', []))
})
@api_bp.route('/extension/stats')
def get_extension_stats():
"""Get extension statistics for dashboard"""
return jsonify({
'total_skus': len(extension_data.get('skus', [])),
'total_products': len(extension_data.get('products', [])),
'api_stats': extension_data.get('api_stats', {}),
'last_sync': extension_data.get('last_sync'),
'source': 'chrome_extension'
})
@api_bp.route('/extension/broken-urls', methods=['GET'])
def get_broken_pokemoncenter_urls():
"""
Find Pokemon Center products with broken URLs.
Broken URLs are those without a product slug (just /product/{sku}).
"""
db = get_database()
with db.get_connection() as conn:
cursor = conn.cursor()
# Find pokemoncenter products where URL ends with just the SKU (no slug)
# Valid URLs look like: /product/10-10022-102/product-name-here
# Invalid URLs look like: /product/10-10050 (no trailing slug)
cursor.execute("""
SELECT id, url, name, product_id, first_seen
FROM products
WHERE site = 'pokemoncenter'
AND url LIKE '%/product/%'
AND url NOT LIKE '%/product/%/%'
""")
broken = [dict(row) for row in cursor.fetchall()]
return jsonify({
'broken_urls': broken,
'count': len(broken)
})
@api_bp.route('/extension/broken-urls/cleanup', methods=['POST'])
def cleanup_broken_pokemoncenter_urls():
"""
Delete Pokemon Center products with broken URLs.
"""
db = get_database()
with db.get_connection() as conn:
cursor = conn.cursor()
# Delete products with broken URLs
cursor.execute("""
DELETE FROM products
WHERE site = 'pokemoncenter'
AND url LIKE '%/product/%'
AND url NOT LIKE '%/product/%/%'
""")
deleted = cursor.rowcount
return jsonify({
'success': True,
'deleted': deleted
})
@api_bp.route('/products/deduplicate', methods=['POST'])
def deduplicate_products():
"""
Remove duplicate products from the database.
Duplicates are identified by:
- Same product_id and site
- Same name (case-insensitive) and site
"""
db = get_database()
stats = db.deduplicate_products()
return jsonify({
'success': True,
'duplicates_found': stats['duplicates_found'],
'products_removed': stats['products_removed'],
'by_site': stats['by_site']
})
@api_bp.route('/events/deduplicate', methods=['POST'])
def deduplicate_events():
"""
Remove duplicate events from the database.
Duplicates are events for the same product/type within the same hour.
"""
db = get_database()
stats = db.deduplicate_events()
return jsonify({
'success': True,
'events_removed': stats['events_removed']
})
@api_bp.route('/extension/clear', methods=['POST'])
def clear_extension_data():
"""Clear all extension data"""
global extension_data
extension_data = {
'skus': [],
'products': [],
'api_stats': {},
'last_sync': None
}
save_extension_data(extension_data)
return jsonify({'success': True})
# ==================== Check Trigger ====================
@api_bp.route('/check', methods=['POST'])
def trigger_check():
"""Trigger a manual stock check (placeholder - needs integration)"""
# This would need to communicate with the main monitor process
# For now, just return a message
return jsonify({
'message': 'Manual check triggered. Check monitor logs for results.',
'success': True
})
# ==================== User Management Endpoints ====================
@api_bp.route('/users', methods=['GET'])
def get_users():
"""Get all users"""
db = get_database()
users = db.get_all_users()
return jsonify({'users': users})
@api_bp.route('/users', methods=['POST'])
def create_user():
"""Create a new user"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
name = data.get('name')
if not name:
return jsonify({'error': 'name is required'}), 400
db = get_database()
# Check if user already exists
existing = db.get_user_by_name(name)
if existing:
return jsonify({'error': 'User with this name already exists'}), 400
user_id = db.create_user(
name=name,
zip_code=data.get('zip_code'),
radius_miles=data.get('radius_miles', 25),
discord_webhook=data.get('discord_webhook'),
notify_enabled=data.get('notify_enabled', True)
)
return jsonify({'id': user_id, 'success': True}), 201
@api_bp.route('/users/<int:user_id>', methods=['GET'])
def get_user(user_id):
"""Get a specific user"""
db = get_database()
user = db.get_user(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
return jsonify(user)
@api_bp.route('/users/<int:user_id>', methods=['PUT'])
def update_user(user_id):
"""Update a user"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
db = get_database()
# Check user exists
user = db.get_user(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
# If changing name, check for conflicts
if 'name' in data and data['name'] != user['name']:
existing = db.get_user_by_name(data['name'])
if existing:
return jsonify({'error': 'User with this name already exists'}), 400
success = db.update_user(user_id, **data)
return jsonify({'success': success})
@api_bp.route('/users/<int:user_id>', methods=['DELETE'])
def delete_user(user_id):
"""Delete a user"""
db = get_database()
user = db.get_user(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
success = db.delete_user(user_id)
return jsonify({'success': success})
@api_bp.route('/users/<int:user_id>/location', methods=['PUT'])
def update_user_location(user_id):
"""Update user's location settings"""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
zip_code = data.get('zip_code')
if not zip_code:
return jsonify({'error': 'zip_code is required'}), 400
db = get_database()
user = db.get_user(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
radius_miles = data.get('radius_miles', 25)
success = db.update_user_location(user_id, zip_code, radius_miles)
return jsonify({'success': success})
@api_bp.route('/users/<int:user_id>/stores', methods=['GET'])
def get_user_stores(user_id):
"""Get nearby stores for a user based on their location"""
from src.store_locator import find_all_nearby_stores
db = get_database()
user = db.get_user(user_id)
if not user:
return jsonify({'error': 'User not found'}), 404
if not user.get('zip_code'):
return jsonify({'error': 'User has no location set'}), 400
stores = find_all_nearby_stores(user['zip_code'], user.get('radius_miles', 25))
# Convert Store objects to dictionaries
result = {}
for retailer, store_list in stores.items():
result[retailer] = [
{
'name': s.name,
'address': s.address,
'city': s.city,
'state': s.state,
'zip_code': s.zip_code,
'distance_miles': s.distance_miles,
'phone': s.phone,
'store_id': s.store_id
}
for s in store_list
]
return jsonify({
'user': user['name'],
'location': {'zip_code': user['zip_code'], 'radius_miles': user.get('radius_miles', 25)},
'stores': result
})
# =============================================================================
# Checkout Profile Routes
# =============================================================================
@api_bp.route('/users/<int:user_id>/profile/data', methods=['GET'])
def get_profile_data(user_id):
"""
Return non-sensitive profile fields for pre-filling the edit form.
Only works if the profile is already unlocked (in memory).
Card number is returned masked. Passwords are never returned.
"""
from src.profile_store import get_unlocked_profile, masked_card
db = get_database()
if not db.get_user(user_id):
return jsonify({'error': 'User not found'}), 404
profile = get_unlocked_profile(user_id)
if not profile:
return jsonify({'error': 'Profile not unlocked'}), 403
shipping = profile.get('shipping', {})
creds = profile.get('site_credentials', {})
card = masked_card(user_id)
return jsonify({
'shipping': shipping,
'masked_card': card,
'site_credentials': {
'target_email': creds.get('target_email', ''),
'bestbuy_email': creds.get('bestbuy_email', ''),
'gamestop_email': creds.get('gamestop_email', ''),
# passwords intentionally omitted
}
})
@api_bp.route('/users/<int:user_id>/profile', methods=['GET'])
def get_profile_status(user_id):
"""
Return profile metadata — never returns card data or decrypted fields.
Frontend uses this to show lock/unlock state and masked card info.
"""
from src.profile_store import is_unlocked, masked_card
db = get_database()
if not db.get_user(user_id):
return jsonify({'error': 'User not found'}), 404
has_profile = db.user_has_profile(user_id)
unlocked = is_unlocked(user_id)
return jsonify({
'has_profile': has_profile,
'is_unlocked': unlocked,
'masked_card': masked_card(user_id) if unlocked else None,
})
@api_bp.route('/users/<int:user_id>/profile', methods=['POST'])
def save_profile(user_id):
"""
Save (or replace) an encrypted checkout profile.
Expects JSON: { password, shipping: {...}, payment: {...} }
Card data is encrypted immediately — never stored in plaintext.
"""
from src.profile_store import save_profile as _save
db = get_database()
if not db.get_user(user_id):
return jsonify({'error': 'User not found'}), 404
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
password = data.get('password', '').strip()
if len(password) < 8:
return jsonify({'error': 'Password must be at least 8 characters'}), 400
shipping = data.get('shipping', {})
payment = data.get('payment', {})
site_credentials = data.get('site_credentials', {})
required_shipping = ['first_name', 'last_name', 'address1', 'city', 'state', 'zip', 'email']
missing = [f for f in required_shipping if not shipping.get(f)]
if missing:
return jsonify({'error': f'Missing shipping fields: {", ".join(missing)}'}), 400
# If card fields are blank and a profile already exists, keep the existing card data
if not payment.get('card_number') and db.user_has_profile(user_id):
from src.profile_store import get_unlocked_profile
existing = get_unlocked_profile(user_id)
if existing:
for k in ['card_number', 'expiry_month', 'expiry_year', 'cvv', 'name_on_card']:
if not payment.get(k):
payment[k] = existing.get('payment', {}).get(k, '')
required_payment = ['card_number', 'expiry_month', 'expiry_year', 'cvv']
missing = [f for f in required_payment if not payment.get(f)]
if missing:
return jsonify({'error': f'Missing payment fields: {", ".join(missing)}'}), 400
try:
ok = _save(user_id, password, shipping, payment, db, site_credentials=site_credentials)
except Exception as e:
return jsonify({'error': str(e)}), 500
if not ok:
return jsonify({'error': 'Encryption failed — is the cryptography package installed? Run: pip install cryptography'}), 500
return jsonify({'success': True, 'is_unlocked': True})
@api_bp.route('/users/<int:user_id>/profile/unlock', methods=['POST'])
def unlock_profile(user_id):
"""Decrypt profile into memory. Expects JSON: { password }"""
from src.profile_store import unlock_profile as _unlock
db = get_database()
if not db.get_user(user_id):
return jsonify({'error': 'User not found'}), 404
if not db.user_has_profile(user_id):
return jsonify({'error': 'No profile saved for this user'}), 404
data = request.get_json()
password = (data or {}).get('password', '')
if not _unlock(user_id, password, db):
return jsonify({'error': 'Incorrect password'}), 401
from src.profile_store import masked_card
return jsonify({'success': True, 'masked_card': masked_card(user_id)})
@api_bp.route('/users/<int:user_id>/profile/lock', methods=['POST'])
def lock_profile(user_id):
"""Clear profile from memory."""
from src.profile_store import lock_profile as _lock
_lock(user_id)
return jsonify({'success': True})
@api_bp.route('/users/<int:user_id>/profile', methods=['DELETE'])
def delete_profile(user_id):
"""Permanently delete a user's saved profile."""
from src.profile_store import lock_profile as _lock
db = get_database()
if not db.get_user(user_id):
return jsonify({'error': 'User not found'}), 404
_lock(user_id)
db.delete_user_profile(user_id)
return jsonify({'success': True})