shopping lists
This commit is contained in:
48
README.md
48
README.md
@@ -6,12 +6,14 @@ A comprehensive web scraper for tracking product prices across multiple e-commer
|
||||
|
||||
- **Multi-site Price Tracking**: Monitor prices across JJ Food Service, A to Z Catering, and Amazon UK
|
||||
- **Beautiful Web UI**: Clean, responsive interface for managing products and viewing price history
|
||||
- **Daily Shopping Lists**: Automatically generate shopping lists with the best prices for each store
|
||||
- **Price Alerts**: Get notified when products reach your target price
|
||||
- **Historical Data**: View price trends with interactive charts
|
||||
- **Automated Scraping**: Schedule regular price checks
|
||||
- **Multiple Notifications**: Email and webhook notifications
|
||||
- **Multiple Notifications**: Email and webhook notifications for price alerts and shopping lists
|
||||
- **Robust Scraping**: Built-in retry logic, rotating user agents, and rate limiting
|
||||
- **Special Pricing Detection**: Automatically detects and prioritizes delivery prices and special offers
|
||||
- **Smart Shopping**: Compare prices across stores and get recommendations for the best deals
|
||||
|
||||
## Quick Start 🚀
|
||||
|
||||
@@ -37,6 +39,7 @@ A comprehensive web scraper for tracking product prices across multiple e-commer
|
||||
|
||||
The web interface provides:
|
||||
- **Dashboard**: Overview of all tracked products with current prices
|
||||
- **Shopping Lists**: Daily shopping lists showing the best deals for each store
|
||||
- **Add Products**: Easy form to add new products with URLs from multiple sites
|
||||
- **Product Details**: Detailed view with price history charts and statistics
|
||||
- **Settings**: Configuration management and system health checks
|
||||
@@ -256,3 +259,46 @@ This project is for educational purposes. Please review the terms of service of
|
||||
---
|
||||
|
||||
**Happy price tracking! 🛍️**
|
||||
|
||||
## Shopping Lists 🛍️
|
||||
|
||||
The price tracker automatically generates daily shopping lists showing the best deals for each store:
|
||||
|
||||
### Features
|
||||
- **Best Price Selection**: Automatically finds the lowest current price for each product
|
||||
- **Store-Specific Lists**: Separate shopping lists for each tracked store
|
||||
- **Savings Calculation**: Shows how much you save compared to target prices
|
||||
- **Customizable Preferences**: Set minimum savings thresholds, maximum items, and delivery schedules
|
||||
- **Multiple Formats**: View online, print, export to CSV, or email daily lists
|
||||
|
||||
### Daily Automation
|
||||
Set up automatic daily shopping lists:
|
||||
|
||||
```bash
|
||||
# Generate and send shopping lists manually
|
||||
python shopping_list_scheduler.py --force
|
||||
|
||||
# Test without sending (dry run)
|
||||
python shopping_list_scheduler.py --dry-run
|
||||
|
||||
# Set up daily cron job (9 AM daily)
|
||||
echo "0 9 * * * cd /path/to/price-tracker && python shopping_list_scheduler.py" | crontab -
|
||||
```
|
||||
|
||||
### Shopping List Preferences
|
||||
|
||||
For each store, you can configure:
|
||||
- **Enable/Disable**: Turn shopping lists on/off per store
|
||||
- **Minimum Savings**: Only include items with savings above threshold
|
||||
- **Maximum Items**: Limit list size to avoid overwhelming
|
||||
- **Out of Stock**: Include/exclude unavailable items
|
||||
- **Delivery Time**: Schedule when to send daily lists
|
||||
- **Notifications**: Email and/or webhook delivery
|
||||
|
||||
### Web Interface
|
||||
|
||||
Access shopping lists via the web interface:
|
||||
- **View All Lists**: See current shopping lists for all stores
|
||||
- **Store Details**: Detailed view with full product information
|
||||
- **Send Immediately**: Manually trigger email/webhook delivery
|
||||
- **Manage Preferences**: Configure settings per store
|
||||
|
||||
47
main.py
47
main.py
@@ -83,6 +83,47 @@ async def run_scraper():
|
||||
raise
|
||||
|
||||
|
||||
def run_shopping_lists():
|
||||
"""Generate and optionally send daily shopping lists."""
|
||||
from src.config import Config
|
||||
from src.database import DatabaseManager
|
||||
from src.notification import NotificationManager
|
||||
from src.shopping_list import AutoShoppingListGenerator
|
||||
|
||||
config = Config()
|
||||
db_manager = DatabaseManager(config.database_path)
|
||||
notification_manager = NotificationManager(config)
|
||||
shopping_generator = AutoShoppingListGenerator(db_manager, notification_manager)
|
||||
|
||||
print("🛒 Generating automated shopping lists...")
|
||||
|
||||
# Generate shopping lists
|
||||
shopping_lists = shopping_generator.generate_shopping_lists()
|
||||
summary = shopping_generator.get_summary_stats()
|
||||
|
||||
# Display results
|
||||
print(f"\n📊 Summary:")
|
||||
print(f" • {summary['total_products']} products tracked")
|
||||
print(f" • £{summary['total_cost']:.2f} total cost")
|
||||
print(f" • £{summary['total_savings']:.2f} total savings")
|
||||
print(f" • {summary['store_count']} stores involved")
|
||||
|
||||
for store_list in shopping_lists:
|
||||
print(f"\n🏪 {store_list.store_display_name}: {store_list.item_count} items (£{store_list.total_cost:.2f})")
|
||||
|
||||
# Send daily list if email is configured
|
||||
email_config = config.notification_config.get('email', {})
|
||||
if email_config.get('enabled') and email_config.get('recipient_email'):
|
||||
print(f"\n📧 Sending daily shopping list...")
|
||||
success = shopping_generator.send_daily_shopping_list()
|
||||
if success:
|
||||
print(f"✅ Shopping list sent successfully!")
|
||||
else:
|
||||
print(f"❌ Failed to send shopping list")
|
||||
|
||||
return shopping_lists
|
||||
|
||||
|
||||
def run_web_ui():
|
||||
"""Run the web UI for managing products and viewing price history."""
|
||||
import os
|
||||
@@ -99,14 +140,16 @@ def run_web_ui():
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='Price Tracker')
|
||||
parser.add_argument('--mode', choices=['scrape', 'web'], default='web',
|
||||
help='Run mode: scrape prices or start web UI')
|
||||
parser.add_argument('--mode', choices=['scrape', 'web', 'shopping'], default='web',
|
||||
help='Run mode: scrape prices, start web UI, or generate shopping lists')
|
||||
parser.add_argument('--config', help='Path to config file')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.mode == 'scrape':
|
||||
asyncio.run(run_scraper())
|
||||
elif args.mode == 'shopping':
|
||||
run_shopping_lists()
|
||||
else:
|
||||
run_web_ui()
|
||||
|
||||
|
||||
169
shopping_list_scheduler.py
Executable file
169
shopping_list_scheduler.py
Executable file
@@ -0,0 +1,169 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Daily shopping list scheduler for price tracker
|
||||
Run this script daily (e.g., via cron) to automatically generate and send shopping lists.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
import asyncio
|
||||
import logging
|
||||
from datetime import datetime, time
|
||||
|
||||
# Add the src directory to Python path
|
||||
sys.path.insert(0, os.path.join(os.path.dirname(__file__), 'src'))
|
||||
|
||||
from src.config import Config
|
||||
from src.database import DatabaseManager
|
||||
from src.notification import NotificationManager
|
||||
from src.shopping_list import ShoppingListManager
|
||||
|
||||
# Configure logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[
|
||||
logging.FileHandler('shopping_list_scheduler.log'),
|
||||
logging.StreamHandler()
|
||||
]
|
||||
)
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
async def main():
|
||||
"""Main scheduler function."""
|
||||
logger.info("Starting shopping list scheduler")
|
||||
|
||||
try:
|
||||
# Initialize components
|
||||
config = Config()
|
||||
|
||||
if config.has_config_error():
|
||||
logger.error(f"Configuration error: {config.get_config_error()}")
|
||||
return
|
||||
|
||||
db_manager = DatabaseManager(config.database_path)
|
||||
notification_manager = NotificationManager(config)
|
||||
shopping_list_manager = ShoppingListManager(db_manager, notification_manager)
|
||||
|
||||
# Generate shopping lists for all enabled stores
|
||||
logger.info("Generating shopping lists for all enabled stores")
|
||||
shopping_lists = shopping_list_manager.generate_all_shopping_lists()
|
||||
|
||||
if not shopping_lists:
|
||||
logger.warning("No shopping lists generated - no enabled stores or no products")
|
||||
return
|
||||
|
||||
logger.info(f"Generated {len(shopping_lists)} shopping lists")
|
||||
|
||||
# Check which stores should send at this time
|
||||
current_time = datetime.now().time()
|
||||
lists_to_send = {}
|
||||
|
||||
for store_name, shopping_list in shopping_lists.items():
|
||||
preferences = shopping_list_manager.get_store_preferences(store_name)
|
||||
|
||||
# Parse send time
|
||||
send_time_str = preferences.get('send_time', '09:00')
|
||||
try:
|
||||
send_time = time.fromisoformat(send_time_str)
|
||||
# Allow a 30-minute window around the scheduled time
|
||||
time_diff = abs((current_time.hour * 60 + current_time.minute) -
|
||||
(send_time.hour * 60 + send_time.minute))
|
||||
|
||||
if time_diff <= 30: # Within 30 minutes
|
||||
lists_to_send[store_name] = shopping_list
|
||||
logger.info(f"Scheduling {store_name} for sending (scheduled: {send_time_str}, current: {current_time.strftime('%H:%M')})")
|
||||
else:
|
||||
logger.info(f"Skipping {store_name} - not scheduled time (scheduled: {send_time_str}, current: {current_time.strftime('%H:%M')})")
|
||||
|
||||
except ValueError:
|
||||
logger.warning(f"Invalid send time format for {store_name}: {send_time_str}")
|
||||
# Default to sending if time format is invalid
|
||||
lists_to_send[store_name] = shopping_list
|
||||
|
||||
if not lists_to_send:
|
||||
logger.info("No shopping lists scheduled for this time")
|
||||
return
|
||||
|
||||
# Send the scheduled shopping lists
|
||||
logger.info(f"Sending {len(lists_to_send)} shopping lists")
|
||||
results = await shopping_list_manager.send_shopping_lists(lists_to_send)
|
||||
|
||||
# Log results
|
||||
successful = 0
|
||||
for store_name, success in results.items():
|
||||
if success:
|
||||
successful += 1
|
||||
logger.info(f"Successfully sent shopping list for {store_name}")
|
||||
else:
|
||||
logger.error(f"Failed to send shopping list for {store_name}")
|
||||
|
||||
logger.info(f"Shopping list scheduler completed: {successful}/{len(results)} sent successfully")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Shopping list scheduler failed: {str(e)}", exc_info=True)
|
||||
raise
|
||||
|
||||
|
||||
def force_send_all():
|
||||
"""Force send all shopping lists regardless of schedule."""
|
||||
logger.info("Force sending all shopping lists")
|
||||
|
||||
async def force_send():
|
||||
config = Config()
|
||||
|
||||
if config.has_config_error():
|
||||
logger.error(f"Configuration error: {config.get_config_error()}")
|
||||
return
|
||||
|
||||
db_manager = DatabaseManager(config.database_path)
|
||||
notification_manager = NotificationManager(config)
|
||||
shopping_list_manager = ShoppingListManager(db_manager, notification_manager)
|
||||
|
||||
shopping_lists = shopping_list_manager.generate_all_shopping_lists()
|
||||
|
||||
if shopping_lists:
|
||||
results = await shopping_list_manager.send_shopping_lists(shopping_lists)
|
||||
successful = sum(1 for success in results.values() if success)
|
||||
logger.info(f"Force send completed: {successful}/{len(results)} sent successfully")
|
||||
else:
|
||||
logger.warning("No shopping lists to send")
|
||||
|
||||
asyncio.run(force_send())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
import argparse
|
||||
|
||||
parser = argparse.ArgumentParser(description="Daily shopping list scheduler")
|
||||
parser.add_argument('--force', action='store_true',
|
||||
help='Force send all shopping lists regardless of schedule')
|
||||
parser.add_argument('--dry-run', action='store_true',
|
||||
help='Generate lists but do not send them')
|
||||
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.force:
|
||||
force_send_all()
|
||||
elif args.dry_run:
|
||||
logger.info("Dry run mode - generating lists without sending")
|
||||
|
||||
config = Config()
|
||||
if config.has_config_error():
|
||||
logger.error(f"Configuration error: {config.get_config_error()}")
|
||||
sys.exit(1)
|
||||
|
||||
db_manager = DatabaseManager(config.database_path)
|
||||
notification_manager = NotificationManager(config)
|
||||
shopping_list_manager = ShoppingListManager(db_manager, notification_manager)
|
||||
|
||||
shopping_lists = shopping_list_manager.generate_all_shopping_lists()
|
||||
|
||||
for store_name, shopping_list in shopping_lists.items():
|
||||
logger.info(f"{store_name}: {shopping_list.item_count} items, "
|
||||
f"£{shopping_list.total_cost:.2f} total, "
|
||||
f"£{shopping_list.total_savings:.2f} savings")
|
||||
else:
|
||||
asyncio.run(main())
|
||||
@@ -235,3 +235,7 @@ class DatabaseManager:
|
||||
}
|
||||
|
||||
return stats
|
||||
|
||||
def get_connection(self):
|
||||
"""Get a database connection."""
|
||||
return sqlite3.connect(self.db_path)
|
||||
|
||||
830
src/shopping_list.py
Normal file
830
src/shopping_list.py
Normal file
@@ -0,0 +1,830 @@
|
||||
"""
|
||||
Automated shopping list generator for price tracker
|
||||
Analyzes scraped prices to determine cheapest store for each product
|
||||
"""
|
||||
|
||||
import json
|
||||
import logging
|
||||
from datetime import datetime, timedelta
|
||||
from typing import Dict, List, Tuple, Optional, Any
|
||||
from dataclasses import dataclass
|
||||
|
||||
from .database import DatabaseManager
|
||||
from .notification import NotificationManager
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
@dataclass
|
||||
class ShoppingItem:
|
||||
"""Represents an item in a shopping list."""
|
||||
product_id: int
|
||||
product_name: str
|
||||
current_price: float
|
||||
store_name: str
|
||||
store_url: str
|
||||
last_updated: datetime
|
||||
savings_vs_most_expensive: float = 0.0
|
||||
|
||||
|
||||
@dataclass
|
||||
class StoreShoppingList:
|
||||
"""Represents a complete shopping list for one store."""
|
||||
store_name: str
|
||||
store_display_name: str
|
||||
base_url: str
|
||||
items: List[ShoppingItem]
|
||||
total_cost: float
|
||||
total_savings: float
|
||||
item_count: int
|
||||
|
||||
|
||||
class AutoShoppingListGenerator:
|
||||
"""Generates automated shopping lists based on current best prices."""
|
||||
|
||||
def __init__(self, db_manager: DatabaseManager, notification_manager: NotificationManager = None):
|
||||
self.db_manager = db_manager
|
||||
self.notification_manager = notification_manager
|
||||
|
||||
# Store display names and URLs
|
||||
self.store_info = {
|
||||
'jjfoodservice': {
|
||||
'display_name': 'JJ Food Service',
|
||||
'base_url': 'https://www.jjfoodservice.com'
|
||||
},
|
||||
'atoz_catering': {
|
||||
'display_name': 'A to Z Catering',
|
||||
'base_url': 'https://www.atoz-catering.co.uk'
|
||||
},
|
||||
'amazon_uk': {
|
||||
'display_name': 'Amazon UK',
|
||||
'base_url': 'https://www.amazon.co.uk'
|
||||
}
|
||||
}
|
||||
|
||||
def get_current_best_prices(self) -> Dict[int, Dict]:
|
||||
"""Get the current cheapest price for each product across all stores."""
|
||||
conn = self.db_manager.get_connection()
|
||||
cursor = conn.cursor()
|
||||
|
||||
# Get latest price for each product from each store
|
||||
query = """
|
||||
WITH latest_prices AS (
|
||||
SELECT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
ph.site_name,
|
||||
ph.price,
|
||||
ph.timestamp,
|
||||
p.urls,
|
||||
ROW_NUMBER() OVER (PARTITION BY p.id, ph.site_name ORDER BY ph.timestamp DESC) as rn
|
||||
FROM products p
|
||||
LEFT JOIN price_history ph ON p.id = ph.product_id
|
||||
WHERE ph.price IS NOT NULL AND ph.price > 0 AND p.active = 1
|
||||
),
|
||||
current_prices AS (
|
||||
SELECT * FROM latest_prices WHERE rn = 1
|
||||
),
|
||||
cheapest_per_product AS (
|
||||
SELECT
|
||||
product_id,
|
||||
product_name,
|
||||
MIN(price) as min_price,
|
||||
MAX(price) as max_price
|
||||
FROM current_prices
|
||||
GROUP BY product_id, product_name
|
||||
)
|
||||
SELECT
|
||||
cp.product_id,
|
||||
cp.product_name,
|
||||
cp.site_name,
|
||||
cp.price,
|
||||
cp.timestamp,
|
||||
cp.urls,
|
||||
cpp.min_price,
|
||||
cpp.max_price
|
||||
FROM current_prices cp
|
||||
JOIN cheapest_per_product cpp ON cp.product_id = cpp.product_id
|
||||
WHERE cp.price = cpp.min_price
|
||||
ORDER BY cp.product_name, cp.site_name
|
||||
"""
|
||||
|
||||
cursor.execute(query)
|
||||
results = cursor.fetchall()
|
||||
conn.close()
|
||||
|
||||
# Group by product (handle ties where multiple stores have same lowest price)
|
||||
best_prices = {}
|
||||
for row in results:
|
||||
product_id = row[0]
|
||||
if product_id not in best_prices:
|
||||
best_prices[product_id] = []
|
||||
|
||||
# Parse URLs from JSON
|
||||
urls = json.loads(row[5]) if row[5] else {}
|
||||
site_name = row[2]
|
||||
store_url = urls.get(site_name, "")
|
||||
|
||||
best_prices[product_id].append({
|
||||
'product_name': row[1],
|
||||
'store_name': site_name,
|
||||
'price': row[3],
|
||||
'scraped_at': datetime.fromisoformat(row[4]),
|
||||
'store_url': store_url,
|
||||
'min_price': row[6],
|
||||
'max_price': row[7]
|
||||
})
|
||||
|
||||
return best_prices
|
||||
|
||||
def generate_shopping_lists(self) -> List[StoreShoppingList]:
|
||||
"""Generate automated shopping lists for each store."""
|
||||
best_prices = self.get_current_best_prices()
|
||||
|
||||
# Group items by store
|
||||
store_lists = {}
|
||||
|
||||
for product_id, price_options in best_prices.items():
|
||||
# If multiple stores have the same lowest price, prefer in order: JJ Food Service, A to Z, Amazon
|
||||
store_priority = {'jjfoodservice': 1, 'atoz_catering': 2, 'amazon_uk': 3}
|
||||
best_option = min(price_options, key=lambda x: (x['price'], store_priority.get(x['store_name'], 999)))
|
||||
|
||||
store_name = best_option['store_name']
|
||||
if store_name not in store_lists:
|
||||
store_lists[store_name] = []
|
||||
|
||||
# Calculate savings vs most expensive option
|
||||
savings = best_option['max_price'] - best_option['price']
|
||||
|
||||
shopping_item = ShoppingItem(
|
||||
product_id=product_id,
|
||||
product_name=best_option['product_name'],
|
||||
current_price=best_option['price'],
|
||||
store_name=store_name,
|
||||
store_url=best_option['store_url'],
|
||||
last_updated=best_option['scraped_at'],
|
||||
savings_vs_most_expensive=savings
|
||||
)
|
||||
|
||||
store_lists[store_name].append(shopping_item)
|
||||
|
||||
# Convert to StoreShoppingList objects
|
||||
shopping_lists = []
|
||||
for store_name, items in store_lists.items():
|
||||
store_info = self.store_info.get(store_name, {
|
||||
'display_name': store_name.title(),
|
||||
'base_url': ''
|
||||
})
|
||||
|
||||
total_cost = sum(item.current_price for item in items)
|
||||
total_savings = sum(item.savings_vs_most_expensive for item in items)
|
||||
|
||||
shopping_list = StoreShoppingList(
|
||||
store_name=store_name,
|
||||
store_display_name=store_info['display_name'],
|
||||
base_url=store_info['base_url'],
|
||||
items=sorted(items, key=lambda x: x.product_name.lower()),
|
||||
total_cost=total_cost,
|
||||
total_savings=total_savings,
|
||||
item_count=len(items)
|
||||
)
|
||||
|
||||
shopping_lists.append(shopping_list)
|
||||
|
||||
# Sort by total cost (cheapest store first)
|
||||
return sorted(shopping_lists, key=lambda x: x.total_cost)
|
||||
|
||||
def get_summary_stats(self) -> Dict:
|
||||
"""Get summary statistics about the current shopping recommendations."""
|
||||
shopping_lists = self.generate_shopping_lists()
|
||||
|
||||
total_products = sum(sl.item_count for sl in shopping_lists)
|
||||
total_cost = sum(sl.total_cost for sl in shopping_lists)
|
||||
total_savings = sum(sl.total_savings for sl in shopping_lists)
|
||||
|
||||
# Find the most recommended store
|
||||
best_store = max(shopping_lists, key=lambda x: x.item_count) if shopping_lists else None
|
||||
|
||||
return {
|
||||
'total_products': total_products,
|
||||
'total_cost': total_cost,
|
||||
'total_savings': total_savings,
|
||||
'store_count': len(shopping_lists),
|
||||
'most_items_store': best_store.store_display_name if best_store else None,
|
||||
'most_items_count': best_store.item_count if best_store else 0,
|
||||
'generated_at': datetime.now()
|
||||
}
|
||||
|
||||
def send_daily_shopping_list(self) -> bool:
|
||||
"""Send daily shopping list via email/webhook."""
|
||||
if not self.notification_manager:
|
||||
return False
|
||||
|
||||
try:
|
||||
shopping_lists = self.generate_shopping_lists()
|
||||
summary = self.get_summary_stats()
|
||||
|
||||
if not shopping_lists:
|
||||
return False
|
||||
|
||||
# Generate email content
|
||||
subject = f"Daily Shopping List - £{summary['total_cost']:.2f} across {summary['store_count']} stores"
|
||||
|
||||
html_content = self._generate_email_html(shopping_lists, summary)
|
||||
text_content = self._generate_email_text(shopping_lists, summary)
|
||||
|
||||
# Send email notification
|
||||
success = self.notification_manager.send_email(
|
||||
subject=subject,
|
||||
message=text_content,
|
||||
html_message=html_content
|
||||
)
|
||||
|
||||
# Send webhook if configured
|
||||
if self.notification_manager.config.get('webhook', {}).get('enabled'):
|
||||
webhook_data = {
|
||||
'type': 'daily_shopping_list',
|
||||
'summary': summary,
|
||||
'store_lists': [
|
||||
{
|
||||
'store': sl.store_display_name,
|
||||
'items': len(sl.items),
|
||||
'total': sl.total_cost,
|
||||
'savings': sl.total_savings
|
||||
}
|
||||
for sl in shopping_lists
|
||||
]
|
||||
}
|
||||
self.notification_manager.send_webhook(webhook_data)
|
||||
|
||||
return success
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error sending daily shopping list: {str(e)}")
|
||||
return False
|
||||
|
||||
def _generate_email_html(self, shopping_lists: List[StoreShoppingList], summary: Dict) -> str:
|
||||
"""Generate HTML email content for shopping lists."""
|
||||
html = f"""
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; line-height: 1.6; color: #333; }}
|
||||
.container {{ max-width: 800px; margin: 0 auto; padding: 20px; }}
|
||||
.header {{ background: #007bff; color: white; padding: 20px; border-radius: 5px; text-align: center; }}
|
||||
.summary {{ background: #f8f9fa; padding: 15px; border-radius: 5px; margin: 20px 0; }}
|
||||
.store-section {{ margin: 20px 0; border: 1px solid #ddd; border-radius: 5px; }}
|
||||
.store-header {{ background: #28a745; color: white; padding: 15px; }}
|
||||
.item-list {{ padding: 0; margin: 0; list-style: none; }}
|
||||
.item {{ padding: 10px 15px; border-bottom: 1px solid #eee; display: flex; justify-content: space-between; }}
|
||||
.item:last-child {{ border-bottom: none; }}
|
||||
.price {{ font-weight: bold; color: #28a745; }}
|
||||
.savings {{ color: #dc3545; font-size: 0.9em; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="container">
|
||||
<div class="header">
|
||||
<h1>🛒 Daily Shopping List</h1>
|
||||
<p>Best prices found for {summary['generated_at'].strftime('%B %d, %Y')}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<h3>Summary</h3>
|
||||
<ul>
|
||||
<li><strong>{summary['total_products']}</strong> products tracked</li>
|
||||
<li><strong>£{summary['total_cost']:.2f}</strong> total cost at best prices</li>
|
||||
<li><strong>£{summary['total_savings']:.2f}</strong> total savings vs most expensive options</li>
|
||||
<li><strong>{summary['store_count']}</strong> stores recommended</li>
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
|
||||
for store_list in shopping_lists:
|
||||
html += f"""
|
||||
<div class="store-section">
|
||||
<div class="store-header">
|
||||
<h3>{store_list.store_display_name}</h3>
|
||||
<p>{store_list.item_count} items - £{store_list.total_cost:.2f} total (Save £{store_list.total_savings:.2f})</p>
|
||||
</div>
|
||||
<ul class="item-list">
|
||||
"""
|
||||
|
||||
for item in store_list.items:
|
||||
savings_text = f"(Save £{item.savings_vs_most_expensive:.2f})" if item.savings_vs_most_expensive > 0 else ""
|
||||
url_link = f'<a href="{item.store_url}" target="_blank">🔗</a>' if item.store_url else ""
|
||||
|
||||
html += f"""
|
||||
<li class="item">
|
||||
<span>{item.product_name} {url_link}</span>
|
||||
<span>
|
||||
<span class="price">£{item.current_price:.2f}</span>
|
||||
<span class="savings">{savings_text}</span>
|
||||
</span>
|
||||
</li>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</ul>
|
||||
</div>
|
||||
"""
|
||||
|
||||
html += """
|
||||
</div>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
return html
|
||||
|
||||
def _generate_email_text(self, shopping_lists: List[StoreShoppingList], summary: Dict) -> str:
|
||||
"""Generate plain text email content for shopping lists."""
|
||||
text = f"""
|
||||
DAILY SHOPPING LIST - {summary['generated_at'].strftime('%B %d, %Y')}
|
||||
{'=' * 50}
|
||||
|
||||
SUMMARY:
|
||||
- {summary['total_products']} products tracked
|
||||
- £{summary['total_cost']:.2f} total cost at best prices
|
||||
- £{summary['total_savings']:.2f} total savings vs most expensive options
|
||||
- {summary['store_count']} stores recommended
|
||||
|
||||
"""
|
||||
|
||||
for store_list in shopping_lists:
|
||||
text += f"""
|
||||
{store_list.store_display_name.upper()}
|
||||
{'-' * len(store_list.store_display_name)}
|
||||
{store_list.item_count} items - £{store_list.total_cost:.2f} total (Save £{store_list.total_savings:.2f})
|
||||
|
||||
"""
|
||||
|
||||
for item in store_list.items:
|
||||
savings_text = f" (Save £{item.savings_vs_most_expensive:.2f})" if item.savings_vs_most_expensive > 0 else ""
|
||||
text += f"• {item.product_name}: £{item.current_price:.2f}{savings_text}\n"
|
||||
|
||||
text += "\n"
|
||||
|
||||
return text
|
||||
|
||||
def _init_shopping_list_tables(self):
|
||||
"""Initialize shopping list related database tables."""
|
||||
with self.db.get_connection() as conn:
|
||||
# Table to store generated shopping lists
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shopping_lists (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
store_name TEXT NOT NULL,
|
||||
generated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
total_cost REAL NOT NULL,
|
||||
total_savings REAL DEFAULT 0,
|
||||
item_count INTEGER NOT NULL,
|
||||
list_data TEXT NOT NULL, -- JSON string of the full list
|
||||
sent_at TIMESTAMP,
|
||||
email_sent BOOLEAN DEFAULT 0,
|
||||
webhook_sent BOOLEAN DEFAULT 0
|
||||
)
|
||||
''')
|
||||
|
||||
# Table to track user preferences for shopping lists
|
||||
conn.execute('''
|
||||
CREATE TABLE IF NOT EXISTS shopping_list_preferences (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
store_name TEXT NOT NULL UNIQUE,
|
||||
enabled BOOLEAN DEFAULT 1,
|
||||
min_savings_threshold REAL DEFAULT 0,
|
||||
max_items INTEGER DEFAULT 50,
|
||||
include_out_of_stock BOOLEAN DEFAULT 0,
|
||||
auto_send_email BOOLEAN DEFAULT 1,
|
||||
auto_send_webhook BOOLEAN DEFAULT 0,
|
||||
send_time TEXT DEFAULT '09:00', -- HH:MM format
|
||||
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
|
||||
updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
''')
|
||||
|
||||
def get_latest_prices_by_store(self, days_back: int = 1) -> Dict[str, List[Dict[str, Any]]]:
|
||||
"""Get the latest prices for all products grouped by store."""
|
||||
with self.db.get_connection() as conn:
|
||||
query = '''
|
||||
SELECT
|
||||
p.id as product_id,
|
||||
p.name as product_name,
|
||||
p.description,
|
||||
p.target_price,
|
||||
p.urls,
|
||||
ph.site_name,
|
||||
ph.price,
|
||||
ph.availability,
|
||||
ph.timestamp
|
||||
FROM products p
|
||||
JOIN price_history ph ON p.id = ph.product_id
|
||||
WHERE p.active = 1
|
||||
AND ph.timestamp >= datetime('now', '-{} days')
|
||||
AND ph.id IN (
|
||||
SELECT MAX(id)
|
||||
FROM price_history ph2
|
||||
WHERE ph2.product_id = p.id
|
||||
AND ph2.site_name = ph.site_name
|
||||
GROUP BY ph2.product_id, ph2.site_name
|
||||
)
|
||||
ORDER BY ph.site_name, p.name
|
||||
'''.format(days_back)
|
||||
|
||||
cursor = conn.execute(query)
|
||||
results = cursor.fetchall()
|
||||
|
||||
# Group by store
|
||||
stores = {}
|
||||
for row in results:
|
||||
product_id, name, desc, target, urls_json, site, price, avail, timestamp = row
|
||||
|
||||
if site not in stores:
|
||||
stores[site] = []
|
||||
|
||||
# Parse URLs to get the specific URL for this site
|
||||
try:
|
||||
urls = json.loads(urls_json) if urls_json else {}
|
||||
site_url = urls.get(site, '')
|
||||
except:
|
||||
site_url = ''
|
||||
|
||||
stores[site].append({
|
||||
'product_id': product_id,
|
||||
'name': name,
|
||||
'description': desc or '',
|
||||
'target_price': target,
|
||||
'price': price,
|
||||
'availability': bool(avail),
|
||||
'timestamp': timestamp,
|
||||
'url': site_url
|
||||
})
|
||||
|
||||
return stores
|
||||
|
||||
def generate_shopping_list_for_store(self, store_name: str, preferences: Optional[Dict] = None) -> StoreShoppingList:
|
||||
"""Generate a shopping list for a specific store with the best prices."""
|
||||
if preferences is None:
|
||||
preferences = self.get_store_preferences(store_name)
|
||||
|
||||
# Get latest prices
|
||||
all_stores = self.get_latest_prices_by_store()
|
||||
store_items = all_stores.get(store_name, [])
|
||||
|
||||
shopping_items = []
|
||||
total_cost = 0.0
|
||||
total_savings = 0.0
|
||||
|
||||
for item in store_items:
|
||||
# Skip out of stock items if preference is set
|
||||
if not preferences.get('include_out_of_stock', False) and not item['availability']:
|
||||
continue
|
||||
|
||||
# Calculate savings if target price is set
|
||||
savings = 0.0
|
||||
if item['target_price'] and item['price'] < item['target_price']:
|
||||
savings = item['target_price'] - item['price']
|
||||
|
||||
# Skip items below savings threshold
|
||||
min_threshold = preferences.get('min_savings_threshold', 0)
|
||||
if min_threshold > 0 and savings < min_threshold:
|
||||
continue
|
||||
|
||||
shopping_item = ShoppingItem(
|
||||
product_id=item['product_id'],
|
||||
product_name=item['name'],
|
||||
current_price=item['price'],
|
||||
store_name=store_name,
|
||||
store_url=item['url'],
|
||||
last_updated=datetime.fromisoformat(item['timestamp'].replace('Z', '+00:00')) if isinstance(item['timestamp'], str) else datetime.now(),
|
||||
savings_vs_most_expensive=savings
|
||||
)
|
||||
|
||||
shopping_items.append(shopping_item)
|
||||
total_cost += item['price']
|
||||
total_savings += savings
|
||||
|
||||
# Limit items if max_items is set
|
||||
max_items = preferences.get('max_items', 50)
|
||||
if len(shopping_items) > max_items:
|
||||
# Sort by savings (highest first) and take top items
|
||||
shopping_items.sort(key=lambda x: x.savings or 0, reverse=True)
|
||||
shopping_items = shopping_items[:max_items]
|
||||
total_cost = sum(item.best_price for item in shopping_items)
|
||||
total_savings = sum(item.savings or 0 for item in shopping_items)
|
||||
|
||||
return StoreShoppingList(
|
||||
store_name=store_name,
|
||||
store_display_name=self.store_info.get(store_name, {}).get('display_name', store_name.title()),
|
||||
base_url=self.store_info.get(store_name, {}).get('base_url', ''),
|
||||
items=shopping_items,
|
||||
total_cost=total_cost,
|
||||
total_savings=total_savings,
|
||||
item_count=len(shopping_items)
|
||||
)
|
||||
|
||||
def generate_all_shopping_lists(self) -> Dict[str, StoreShoppingList]:
|
||||
"""Generate shopping lists for all enabled stores."""
|
||||
enabled_stores = self.get_enabled_stores()
|
||||
shopping_lists = {}
|
||||
|
||||
for store_name in enabled_stores:
|
||||
try:
|
||||
shopping_list = self.generate_shopping_list_for_store(store_name)
|
||||
if shopping_list.items: # Only include lists with items
|
||||
shopping_lists[store_name] = shopping_list
|
||||
logger.info(f"Generated shopping list for {store_name} with {len(shopping_list.items)} items")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to generate shopping list for {store_name}: {str(e)}")
|
||||
|
||||
return shopping_lists
|
||||
|
||||
def save_shopping_list(self, shopping_list: StoreShoppingList) -> int:
|
||||
"""Save a shopping list to the database."""
|
||||
import json
|
||||
|
||||
with self.db.get_connection() as conn:
|
||||
# Convert shopping list to JSON for storage
|
||||
list_data = {
|
||||
'store_name': shopping_list.store_name,
|
||||
'items': [
|
||||
{
|
||||
'product_id': item.product_id,
|
||||
'product_name': item.product_name,
|
||||
'description': item.description,
|
||||
'best_price': item.best_price,
|
||||
'site_name': item.site_name,
|
||||
'url': item.url,
|
||||
'availability': item.availability,
|
||||
'last_updated': item.last_updated.isoformat(),
|
||||
'target_price': item.target_price,
|
||||
'savings': item.savings
|
||||
}
|
||||
for item in shopping_list.items
|
||||
],
|
||||
'total_cost': shopping_list.total_cost,
|
||||
'total_savings': shopping_list.total_savings,
|
||||
'generated_at': shopping_list.generated_at.isoformat(),
|
||||
'item_count': shopping_list.item_count
|
||||
}
|
||||
|
||||
cursor = conn.execute('''
|
||||
INSERT INTO shopping_lists
|
||||
(store_name, total_cost, total_savings, item_count, list_data)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
''', (
|
||||
shopping_list.store_name,
|
||||
shopping_list.total_cost,
|
||||
shopping_list.total_savings,
|
||||
shopping_list.item_count,
|
||||
json.dumps(list_data)
|
||||
))
|
||||
|
||||
return cursor.lastrowid
|
||||
|
||||
def get_store_preferences(self, store_name: str) -> Dict[str, Any]:
|
||||
"""Get preferences for a specific store."""
|
||||
with self.db.get_connection() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT enabled, min_savings_threshold, max_items, include_out_of_stock,
|
||||
auto_send_email, auto_send_webhook, send_time
|
||||
FROM shopping_list_preferences
|
||||
WHERE store_name = ?
|
||||
''', (store_name,))
|
||||
|
||||
row = cursor.fetchone()
|
||||
if row:
|
||||
return {
|
||||
'enabled': bool(row[0]),
|
||||
'min_savings_threshold': row[1],
|
||||
'max_items': row[2],
|
||||
'include_out_of_stock': bool(row[3]),
|
||||
'auto_send_email': bool(row[4]),
|
||||
'auto_send_webhook': bool(row[5]),
|
||||
'send_time': row[6]
|
||||
}
|
||||
else:
|
||||
# Return default preferences
|
||||
return {
|
||||
'enabled': True,
|
||||
'min_savings_threshold': 0.0,
|
||||
'max_items': 50,
|
||||
'include_out_of_stock': False,
|
||||
'auto_send_email': True,
|
||||
'auto_send_webhook': False,
|
||||
'send_time': '09:00'
|
||||
}
|
||||
|
||||
def get_enabled_stores(self) -> List[str]:
|
||||
"""Get list of stores that have shopping lists enabled."""
|
||||
with self.db.get_connection() as conn:
|
||||
cursor = conn.execute('''
|
||||
SELECT DISTINCT store_name
|
||||
FROM shopping_list_preferences
|
||||
WHERE enabled = 1
|
||||
''')
|
||||
enabled_stores = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
# If no preferences set, return all stores that have recent price data
|
||||
if not enabled_stores:
|
||||
cursor = conn.execute('''
|
||||
SELECT DISTINCT site_name
|
||||
FROM price_history
|
||||
WHERE timestamp >= datetime('now', '-7 days')
|
||||
''')
|
||||
enabled_stores = [row[0] for row in cursor.fetchall()]
|
||||
|
||||
return enabled_stores
|
||||
|
||||
def update_store_preferences(self, store_name: str, preferences: Dict[str, Any]) -> bool:
|
||||
"""Update preferences for a specific store."""
|
||||
with self.db.get_connection() as conn:
|
||||
conn.execute('''
|
||||
INSERT OR REPLACE INTO shopping_list_preferences
|
||||
(store_name, enabled, min_savings_threshold, max_items, include_out_of_stock,
|
||||
auto_send_email, auto_send_webhook, send_time, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP)
|
||||
''', (
|
||||
store_name,
|
||||
preferences.get('enabled', True),
|
||||
preferences.get('min_savings_threshold', 0.0),
|
||||
preferences.get('max_items', 50),
|
||||
preferences.get('include_out_of_stock', False),
|
||||
preferences.get('auto_send_email', True),
|
||||
preferences.get('auto_send_webhook', False),
|
||||
preferences.get('send_time', '09:00')
|
||||
))
|
||||
|
||||
return True
|
||||
|
||||
async def send_shopping_lists(self, shopping_lists: Dict[str, StoreShoppingList]) -> Dict[str, bool]:
|
||||
"""Send shopping lists via email and/or webhook."""
|
||||
results = {}
|
||||
|
||||
for store_name, shopping_list in shopping_lists.items():
|
||||
preferences = self.get_store_preferences(store_name)
|
||||
sent = False
|
||||
|
||||
try:
|
||||
# Generate email content
|
||||
if preferences.get('auto_send_email', True):
|
||||
email_content = self._generate_email_content(shopping_list)
|
||||
email_sent = await self.notification_manager.send_email(
|
||||
subject=f"Daily Shopping List - {store_name}",
|
||||
body=email_content,
|
||||
html_body=self._generate_html_email_content(shopping_list)
|
||||
)
|
||||
sent = sent or email_sent
|
||||
|
||||
# Send webhook
|
||||
if preferences.get('auto_send_webhook', False):
|
||||
webhook_data = self._generate_webhook_data(shopping_list)
|
||||
webhook_sent = await self.notification_manager.send_webhook(webhook_data)
|
||||
sent = sent or webhook_sent
|
||||
|
||||
# Update database with send status
|
||||
if sent:
|
||||
list_id = self.save_shopping_list(shopping_list)
|
||||
with self.db.get_connection() as conn:
|
||||
conn.execute('''
|
||||
UPDATE shopping_lists
|
||||
SET sent_at = CURRENT_TIMESTAMP,
|
||||
email_sent = ?, webhook_sent = ?
|
||||
WHERE id = ?
|
||||
''', (
|
||||
preferences.get('auto_send_email', True),
|
||||
preferences.get('auto_send_webhook', False),
|
||||
list_id
|
||||
))
|
||||
|
||||
results[store_name] = sent
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to send shopping list for {store_name}: {str(e)}")
|
||||
results[store_name] = False
|
||||
|
||||
return results
|
||||
|
||||
def _generate_email_content(self, shopping_list: StoreShoppingList) -> str:
|
||||
"""Generate plain text email content for shopping list."""
|
||||
content = f"""
|
||||
Daily Shopping List - {shopping_list.store_name}
|
||||
Generated: {shopping_list.generated_at.strftime('%Y-%m-%d %H:%M')}
|
||||
|
||||
Summary:
|
||||
- Items: {shopping_list.item_count}
|
||||
- Total Cost: £{shopping_list.total_cost:.2f}
|
||||
- Total Savings: £{shopping_list.total_savings:.2f}
|
||||
|
||||
Items:
|
||||
"""
|
||||
|
||||
for item in shopping_list.items:
|
||||
content += f"\n• {item.product_name}"
|
||||
content += f"\n Price: £{item.best_price:.2f}"
|
||||
if item.target_price:
|
||||
content += f" (Target: £{item.target_price:.2f})"
|
||||
if item.savings:
|
||||
content += f" - Save £{item.savings:.2f}!"
|
||||
if not item.availability:
|
||||
content += " [OUT OF STOCK]"
|
||||
if item.url:
|
||||
content += f"\n Link: {item.url}"
|
||||
content += "\n"
|
||||
|
||||
content += f"\n\nHappy shopping!\n"
|
||||
return content
|
||||
|
||||
def _generate_html_email_content(self, shopping_list: StoreShoppingList) -> str:
|
||||
"""Generate HTML email content for shopping list."""
|
||||
html = f"""
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<style>
|
||||
body {{ font-family: Arial, sans-serif; margin: 20px; }}
|
||||
.header {{ background-color: #f8f9fa; padding: 20px; border-radius: 8px; margin-bottom: 20px; }}
|
||||
.summary {{ background-color: #e3f2fd; padding: 15px; border-radius: 6px; margin-bottom: 20px; }}
|
||||
.item {{ border: 1px solid #ddd; padding: 15px; margin-bottom: 10px; border-radius: 6px; }}
|
||||
.item.out-of-stock {{ background-color: #ffebee; }}
|
||||
.price {{ font-size: 18px; font-weight: bold; color: #2e7d32; }}
|
||||
.savings {{ color: #d32f2f; font-weight: bold; }}
|
||||
.total {{ font-size: 20px; font-weight: bold; margin-top: 20px; }}
|
||||
a {{ color: #1976d2; text-decoration: none; }}
|
||||
a:hover {{ text-decoration: underline; }}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<div class="header">
|
||||
<h1>Daily Shopping List - {shopping_list.store_name}</h1>
|
||||
<p>Generated: {shopping_list.generated_at.strftime('%Y-%m-%d %H:%M')}</p>
|
||||
</div>
|
||||
|
||||
<div class="summary">
|
||||
<h3>Summary</h3>
|
||||
<p><strong>Items:</strong> {shopping_list.item_count}</p>
|
||||
<p><strong>Total Cost:</strong> £{shopping_list.total_cost:.2f}</p>
|
||||
<p><strong>Total Savings:</strong> <span class="savings">£{shopping_list.total_savings:.2f}</span></p>
|
||||
</div>
|
||||
|
||||
<h3>Items</h3>
|
||||
"""
|
||||
|
||||
for item in shopping_list.items:
|
||||
availability_class = "" if item.availability else "out-of-stock"
|
||||
html += f'<div class="item {availability_class}">'
|
||||
html += f'<h4>{item.product_name}</h4>'
|
||||
if item.description:
|
||||
html += f'<p>{item.description}</p>'
|
||||
|
||||
html += f'<div class="price">£{item.best_price:.2f}</div>'
|
||||
|
||||
if item.target_price:
|
||||
html += f'<p>Target Price: £{item.target_price:.2f}</p>'
|
||||
|
||||
if item.savings:
|
||||
html += f'<p class="savings">Save £{item.savings:.2f}!</p>'
|
||||
|
||||
if not item.availability:
|
||||
html += '<p style="color: red;"><strong>OUT OF STOCK</strong></p>'
|
||||
|
||||
if item.url:
|
||||
html += f'<p><a href="{item.url}" target="_blank">View Product</a></p>'
|
||||
|
||||
html += '</div>'
|
||||
|
||||
html += f"""
|
||||
<div class="total">
|
||||
<p>Total Shopping Cost: £{shopping_list.total_cost:.2f}</p>
|
||||
<p>Total Savings: <span class="savings">£{shopping_list.total_savings:.2f}</span></p>
|
||||
</div>
|
||||
|
||||
<p>Happy shopping!</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
return html
|
||||
|
||||
def _generate_webhook_data(self, shopping_list: StoreShoppingList) -> Dict[str, Any]:
|
||||
"""Generate webhook data for shopping list."""
|
||||
return {
|
||||
"type": "daily_shopping_list",
|
||||
"store_name": shopping_list.store_name,
|
||||
"generated_at": shopping_list.generated_at.isoformat(),
|
||||
"summary": {
|
||||
"item_count": shopping_list.item_count,
|
||||
"total_cost": shopping_list.total_cost,
|
||||
"total_savings": shopping_list.total_savings
|
||||
},
|
||||
"items": [
|
||||
{
|
||||
"product_id": item.product_id,
|
||||
"name": item.product_name,
|
||||
"price": item.best_price,
|
||||
"savings": item.savings,
|
||||
"availability": item.availability,
|
||||
"url": item.url
|
||||
}
|
||||
for item in shopping_list.items
|
||||
]
|
||||
}
|
||||
107
src/web_ui.py
107
src/web_ui.py
@@ -18,6 +18,7 @@ from .database import DatabaseManager
|
||||
from .config import Config
|
||||
from .scraper_manager import ScraperManager
|
||||
from .notification import NotificationManager
|
||||
from .shopping_list import AutoShoppingListGenerator
|
||||
from .utils import format_price, group_results_by_status
|
||||
|
||||
|
||||
@@ -68,6 +69,7 @@ def create_app():
|
||||
db_manager = DatabaseManager(config.database_path)
|
||||
scraper_manager = ScraperManager(config)
|
||||
notification_manager = NotificationManager(config)
|
||||
shopping_list_generator = AutoShoppingListGenerator(db_manager, notification_manager)
|
||||
|
||||
class ProductForm(FlaskForm):
|
||||
name = StringField('Product Name', validators=[DataRequired()])
|
||||
@@ -366,5 +368,110 @@ def create_app():
|
||||
flash(f'Error deleting product: {str(e)}', 'error')
|
||||
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/shopping-lists')
|
||||
def shopping_lists():
|
||||
"""Display automated shopping lists based on best prices."""
|
||||
try:
|
||||
shopping_lists = shopping_list_generator.generate_shopping_lists()
|
||||
summary = shopping_list_generator.get_summary_stats()
|
||||
|
||||
return render_template('shopping_lists.html',
|
||||
shopping_lists=shopping_lists,
|
||||
summary=summary)
|
||||
except Exception as e:
|
||||
flash(f'Error generating shopping lists: {str(e)}', 'danger')
|
||||
return redirect(url_for('index'))
|
||||
|
||||
@app.route('/shopping-list/<store_name>')
|
||||
def shopping_list_detail(store_name):
|
||||
"""Display detailed shopping list for a specific store."""
|
||||
try:
|
||||
shopping_lists = shopping_list_generator.generate_shopping_lists()
|
||||
store_list = next((sl for sl in shopping_lists if sl.store_name == store_name), None)
|
||||
|
||||
if not store_list:
|
||||
flash(f'No items found for {store_name}', 'warning')
|
||||
return redirect(url_for('shopping_lists'))
|
||||
|
||||
return render_template('shopping_list_detail.html',
|
||||
shopping_list=store_list,
|
||||
store_name=store_name)
|
||||
except Exception as e:
|
||||
flash(f'Error loading shopping list: {str(e)}', 'danger')
|
||||
return redirect(url_for('shopping_lists'))
|
||||
|
||||
@app.route('/send-daily-shopping-list', methods=['POST'])
|
||||
def send_daily_shopping_list():
|
||||
"""Send daily shopping list via email/webhook."""
|
||||
try:
|
||||
success = shopping_list_generator.send_daily_shopping_list()
|
||||
|
||||
if success:
|
||||
return jsonify({
|
||||
'success': True,
|
||||
'message': 'Daily shopping list sent successfully'
|
||||
})
|
||||
else:
|
||||
return jsonify({
|
||||
'success': False,
|
||||
'message': 'Failed to send daily shopping list. Check notification settings.'
|
||||
})
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
@app.route('/api/shopping-lists')
|
||||
def api_shopping_lists():
|
||||
"""API endpoint for shopping lists data."""
|
||||
try:
|
||||
shopping_lists = shopping_list_generator.generate_shopping_lists()
|
||||
summary = shopping_list_generator.get_summary_stats()
|
||||
|
||||
# Convert to JSON-serializable format
|
||||
data = {
|
||||
'summary': {
|
||||
'total_products': summary['total_products'],
|
||||
'total_cost': summary['total_cost'],
|
||||
'total_savings': summary['total_savings'],
|
||||
'store_count': summary['store_count'],
|
||||
'most_items_store': summary['most_items_store'],
|
||||
'most_items_count': summary['most_items_count'],
|
||||
'generated_at': summary['generated_at'].isoformat()
|
||||
},
|
||||
'store_lists': []
|
||||
}
|
||||
|
||||
for store_list in shopping_lists:
|
||||
store_data = {
|
||||
'store_name': store_list.store_name,
|
||||
'store_display_name': store_list.store_display_name,
|
||||
'base_url': store_list.base_url,
|
||||
'total_cost': store_list.total_cost,
|
||||
'total_savings': store_list.total_savings,
|
||||
'item_count': store_list.item_count,
|
||||
'items': []
|
||||
}
|
||||
|
||||
for item in store_list.items:
|
||||
item_data = {
|
||||
'product_id': item.product_id,
|
||||
'product_name': item.product_name,
|
||||
'current_price': item.current_price,
|
||||
'store_url': item.store_url,
|
||||
'last_updated': item.last_updated.isoformat(),
|
||||
'savings_vs_most_expensive': item.savings_vs_most_expensive
|
||||
}
|
||||
store_data['items'].append(item_data)
|
||||
|
||||
data['store_lists'].append(store_data)
|
||||
|
||||
return jsonify(data)
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
except Exception as e:
|
||||
return jsonify({'error': str(e)}), 500
|
||||
|
||||
return app
|
||||
|
||||
@@ -96,6 +96,11 @@
|
||||
<i class="fas fa-home me-1"></i>Dashboard
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('shopping_lists') }}">
|
||||
<i class="fas fa-shopping-cart me-1"></i>Shopping Lists
|
||||
</a>
|
||||
</li>
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('add_product') }}">
|
||||
<i class="fas fa-plus me-1"></i>Add Product
|
||||
|
||||
214
templates/shopping_list_detail.html
Normal file
214
templates/shopping_list_detail.html
Normal file
@@ -0,0 +1,214 @@
|
||||
{% extends "layout.html" %}
|
||||
|
||||
{% block title %}{{ shopping_list.store_display_name }} Shopping List{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<!-- Header -->
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h1>
|
||||
<i class="fas fa-store"></i> {{ shopping_list.store_display_name }}
|
||||
<small class="text-muted">Shopping List</small>
|
||||
</h1>
|
||||
<div class="btn-group" role="group">
|
||||
<a href="{{ url_for('shopping_lists') }}" class="btn btn-secondary">
|
||||
<i class="fas fa-arrow-left"></i> Back to All Lists
|
||||
</a>
|
||||
{% if shopping_list.base_url %}
|
||||
<a href="{{ shopping_list.base_url }}" target="_blank" class="btn btn-success">
|
||||
<i class="fas fa-external-link-alt"></i> Visit Store
|
||||
</a>
|
||||
{% endif %}
|
||||
<button class="btn btn-info" onclick="window.print()">
|
||||
<i class="fas fa-print"></i> Print List
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card border-primary">
|
||||
<div class="card-body">
|
||||
<div class="row text-center">
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-primary">{{ shopping_list.item_count }}</h3>
|
||||
<p class="mb-0">Items to Buy</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-success">£{{ "%.2f"|format(shopping_list.total_cost) }}</h3>
|
||||
<p class="mb-0">Total Cost</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-info">£{{ "%.2f"|format(shopping_list.total_savings) }}</h3>
|
||||
<p class="mb-0">Total Savings</p>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<h3 class="text-warning">
|
||||
{{ "%.1f"|format((shopping_list.total_savings / (shopping_list.total_cost + shopping_list.total_savings) * 100) if (shopping_list.total_cost + shopping_list.total_savings) > 0 else 0) }}%
|
||||
</h3>
|
||||
<p class="mb-0">Savings Rate</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shopping Items -->
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-shopping-cart"></i>
|
||||
Items to Buy ({{ shopping_list.item_count }})
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body p-0">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover mb-0">
|
||||
<thead class="table-light">
|
||||
<tr>
|
||||
<th width="5%">#</th>
|
||||
<th width="45%">Product</th>
|
||||
<th width="15%" class="text-center">Price</th>
|
||||
<th width="15%" class="text-center">Savings</th>
|
||||
<th width="15%" class="text-center">Last Updated</th>
|
||||
<th width="5%" class="text-center">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for item in shopping_list.items %}
|
||||
<tr class="shopping-item">
|
||||
<td class="align-middle">
|
||||
<span class="badge badge-secondary">{{ loop.index }}</span>
|
||||
</td>
|
||||
<td class="align-middle">
|
||||
<strong>{{ item.product_name }}</strong>
|
||||
{% if item.savings_vs_most_expensive > 0 %}
|
||||
<br><small class="text-success">
|
||||
<i class="fas fa-arrow-down"></i> Best price!
|
||||
</small>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<span class="h5 text-success mb-0">
|
||||
£{{ "%.2f"|format(item.current_price) }}
|
||||
</span>
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
{% if item.savings_vs_most_expensive > 0 %}
|
||||
<span class="text-info font-weight-bold">
|
||||
£{{ "%.2f"|format(item.savings_vs_most_expensive) }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
<small class="text-muted">
|
||||
{{ item.last_updated.strftime('%Y-%m-%d %H:%M') if item.last_updated else 'Unknown' }}
|
||||
</small>
|
||||
</td>
|
||||
<td class="align-middle text-center">
|
||||
{% if item.store_url %}
|
||||
<a href="{{ item.store_url }}" target="_blank"
|
||||
class="btn btn-sm btn-outline-primary"
|
||||
title="View product on {{ shopping_list.store_display_name }}">
|
||||
<i class="fas fa-external-link-alt"></i>
|
||||
</a>
|
||||
{% else %}
|
||||
<span class="text-muted">-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
<tfoot class="table-light">
|
||||
<tr>
|
||||
<th colspan="2" class="text-right">Totals:</th>
|
||||
<th class="text-center">
|
||||
<span class="h5 text-success">£{{ "%.2f"|format(shopping_list.total_cost) }}</span>
|
||||
</th>
|
||||
<th class="text-center">
|
||||
<span class="h5 text-info">£{{ "%.2f"|format(shopping_list.total_savings) }}</span>
|
||||
</th>
|
||||
<th colspan="2"></th>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shopping Tips -->
|
||||
<div class="row mt-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-lightbulb"></i> Shopping Tips
|
||||
</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fas fa-check text-success"></i> This list shows items where <strong>{{ shopping_list.store_display_name }}</strong> has the best price</li>
|
||||
<li><i class="fas fa-check text-success"></i> You're saving <strong>£{{ "%.2f"|format(shopping_list.total_savings) }}</strong> compared to other stores</li>
|
||||
<li><i class="fas fa-check text-success"></i> Prices were last updated from live store data</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<ul class="list-unstyled">
|
||||
<li><i class="fas fa-info-circle text-info"></i> Click product links to go directly to store pages</li>
|
||||
<li><i class="fas fa-info-circle text-info"></i> Print this list for easy shopping reference</li>
|
||||
<li><i class="fas fa-info-circle text-info"></i> Check other stores for remaining items on your list</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.shopping-item {
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.shopping-item:hover {
|
||||
background-color: #f8f9fa;
|
||||
}
|
||||
|
||||
@media print {
|
||||
.btn-group, .card:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
|
||||
.table {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.card-body {
|
||||
padding: 0.5rem !important;
|
||||
}
|
||||
|
||||
.shopping-item:hover {
|
||||
background-color: transparent !important;
|
||||
}
|
||||
|
||||
.page-break {
|
||||
page-break-after: always;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
220
templates/shopping_lists.html
Normal file
220
templates/shopping_lists.html
Normal file
@@ -0,0 +1,220 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}Smart Shopping Lists{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container-fluid">
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<h1 class="mb-4">
|
||||
<i class="fas fa-shopping-cart"></i> Smart Shopping Lists
|
||||
<small class="text-muted">- Automated best price recommendations</small>
|
||||
</h1>
|
||||
|
||||
<!-- Summary Card -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="card border-primary">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<h5 class="mb-0"><i class="fas fa-chart-line"></i> Shopping Summary</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h3 class="text-primary">{{ summary.total_products }}</h3>
|
||||
<p class="mb-0">Total Products</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h3 class="text-success">£{{ "%.2f"|format(summary.total_cost) }}</h3>
|
||||
<p class="mb-0">Total Cost</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h3 class="text-info">£{{ "%.2f"|format(summary.total_savings) }}</h3>
|
||||
<p class="mb-0">Total Savings</p>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<div class="text-center">
|
||||
<h3 class="text-warning">{{ summary.store_count }}</h3>
|
||||
<p class="mb-0">Stores to Visit</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% if summary.most_items_store %}
|
||||
<div class="row mt-3">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-info mb-0">
|
||||
<i class="fas fa-star"></i>
|
||||
<strong>{{ summary.most_items_store }}</strong> has the most items ({{ summary.most_items_count }}) - consider starting there!
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Actions -->
|
||||
<div class="row mb-4">
|
||||
<div class="col-md-12">
|
||||
<div class="btn-group" role="group">
|
||||
<form style="display: inline;" method="POST" action="{{ url_for('send_daily_shopping_list') }}">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-envelope"></i> Send Daily Email
|
||||
</button>
|
||||
</form>
|
||||
<a href="{{ url_for('api_shopping_lists') }}" class="btn btn-info" target="_blank">
|
||||
<i class="fas fa-code"></i> API Data
|
||||
</a>
|
||||
<button class="btn btn-secondary" onclick="window.print()">
|
||||
<i class="fas fa-print"></i> Print Lists
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Shopping Lists by Store -->
|
||||
{% if shopping_lists %}
|
||||
<div class="row">
|
||||
{% for store_list in shopping_lists %}
|
||||
<div class="col-md-6 col-lg-4 mb-4">
|
||||
<div class="card h-100">
|
||||
<div class="card-header d-flex justify-content-between align-items-center">
|
||||
<h5 class="mb-0">
|
||||
<i class="fas fa-store"></i> {{ store_list.store_display_name }}
|
||||
</h5>
|
||||
<span class="badge badge-primary">{{ store_list.item_count }} items</span>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row mb-3">
|
||||
<div class="col-6">
|
||||
<strong>Total Cost:</strong><br>
|
||||
<span class="h5 text-success">£{{ "%.2f"|format(store_list.total_cost) }}</span>
|
||||
</div>
|
||||
<div class="col-6">
|
||||
<strong>Savings:</strong><br>
|
||||
<span class="h5 text-info">£{{ "%.2f"|format(store_list.total_savings) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top Items Preview -->
|
||||
<div class="mb-3">
|
||||
<h6>Items to Buy:</h6>
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for item in store_list.items[:3] %}
|
||||
<li class="list-group-item p-2 border-0">
|
||||
<div class="d-flex justify-content-between">
|
||||
<span class="text-truncate" style="max-width: 200px;" title="{{ item.product_name }}">
|
||||
{{ item.product_name }}
|
||||
</span>
|
||||
<span class="text-success font-weight-bold">
|
||||
£{{ "%.2f"|format(item.current_price) }}
|
||||
</span>
|
||||
</div>
|
||||
{% if item.savings_vs_most_expensive > 0 %}
|
||||
<small class="text-muted">
|
||||
Saves £{{ "%.2f"|format(item.savings_vs_most_expensive) }}
|
||||
</small>
|
||||
{% endif %}
|
||||
</li>
|
||||
{% endfor %}
|
||||
{% if store_list.items|length > 3 %}
|
||||
<li class="list-group-item p-2 border-0 text-muted">
|
||||
... and {{ store_list.items|length - 3 }} more items
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-footer">
|
||||
<div class="btn-group w-100" role="group">
|
||||
<a href="{{ url_for('shopping_list_detail', store_name=store_list.store_name) }}"
|
||||
class="btn btn-primary btn-sm">
|
||||
<i class="fas fa-list"></i> Full List
|
||||
</a>
|
||||
{% if store_list.base_url %}
|
||||
<a href="{{ store_list.base_url }}" target="_blank"
|
||||
class="btn btn-outline-secondary btn-sm">
|
||||
<i class="fas fa-external-link-alt"></i> Visit Store
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="row">
|
||||
<div class="col-md-12">
|
||||
<div class="alert alert-warning">
|
||||
<h4><i class="fas fa-exclamation-triangle"></i> No Shopping Data Available</h4>
|
||||
<p>No price data found to generate shopping lists. Make sure you have:</p>
|
||||
<ul>
|
||||
<li>Added products to track</li>
|
||||
<li>Run the scraper to collect price data</li>
|
||||
<li>Products have valid prices from at least one store</li>
|
||||
</ul>
|
||||
<a href="{{ url_for('index') }}" class="btn btn-primary">
|
||||
<i class="fas fa-plus"></i> Add Products
|
||||
</a>
|
||||
<form style="display: inline;" method="POST" action="{{ url_for('scrape_all_products') }}">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="fas fa-search"></i> Run Scraper
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Help Section -->
|
||||
<div class="row mt-5">
|
||||
<div class="col-md-12">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h5><i class="fas fa-question-circle"></i> How Smart Shopping Lists Work</h5>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-brain"></i> Automatic Analysis</h6>
|
||||
<p>The system automatically analyzes all your tracked products and finds the store with the lowest current price for each item.</p>
|
||||
|
||||
<h6><i class="fas fa-map-marked-alt"></i> Store Grouping</h6>
|
||||
<p>Products are grouped by store, so you know exactly what to buy from each location for maximum savings.</p>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<h6><i class="fas fa-calculator"></i> Savings Calculation</h6>
|
||||
<p>See how much you save compared to buying each item at the most expensive store.</p>
|
||||
|
||||
<h6><i class="fas fa-envelope"></i> Daily Notifications</h6>
|
||||
<p>Set up daily email notifications to get your optimized shopping list delivered automatically.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@media print {
|
||||
.btn-group, .card-footer, .alert-warning, .card:last-child {
|
||||
display: none !important;
|
||||
}
|
||||
.card {
|
||||
break-inside: avoid;
|
||||
margin-bottom: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
{% endblock %}
|
||||
84
test_shopping_lists.py
Executable file
84
test_shopping_lists.py
Executable file
@@ -0,0 +1,84 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Test script for automated shopping list generation
|
||||
"""
|
||||
|
||||
import os
|
||||
import sys
|
||||
sys.path.append(os.path.dirname(__file__))
|
||||
|
||||
from src.config import Config
|
||||
from src.database import DatabaseManager
|
||||
from src.notification import NotificationManager
|
||||
from src.shopping_list import AutoShoppingListGenerator
|
||||
|
||||
def test_shopping_lists():
|
||||
"""Test the automated shopping list generation."""
|
||||
print("🛒 Testing Automated Shopping List Generation")
|
||||
print("=" * 50)
|
||||
|
||||
# Initialize components
|
||||
config = Config()
|
||||
db_manager = DatabaseManager(config.database_path)
|
||||
notification_manager = NotificationManager(config)
|
||||
shopping_generator = AutoShoppingListGenerator(db_manager, notification_manager)
|
||||
|
||||
# Generate shopping lists
|
||||
print("\n📊 Generating shopping lists...")
|
||||
shopping_lists = shopping_generator.generate_shopping_lists()
|
||||
summary = shopping_generator.get_summary_stats()
|
||||
|
||||
# Display summary
|
||||
print(f"\n📈 Summary:")
|
||||
print(f" • Total products: {summary['total_products']}")
|
||||
print(f" • Total cost: £{summary['total_cost']:.2f}")
|
||||
print(f" • Total savings: £{summary['total_savings']:.2f}")
|
||||
print(f" • Stores involved: {summary['store_count']}")
|
||||
|
||||
if summary['most_items_store']:
|
||||
print(f" • Best store: {summary['most_items_store']} ({summary['most_items_count']} items)")
|
||||
|
||||
# Display shopping lists
|
||||
print(f"\n🛍️ Shopping Lists by Store:")
|
||||
print("-" * 30)
|
||||
|
||||
for store_list in shopping_lists:
|
||||
print(f"\n🏪 {store_list.store_display_name}")
|
||||
print(f" Items: {store_list.item_count}")
|
||||
print(f" Total: £{store_list.total_cost:.2f}")
|
||||
if store_list.total_savings > 0:
|
||||
print(f" Savings: £{store_list.total_savings:.2f}")
|
||||
|
||||
print(f" Products:")
|
||||
for i, item in enumerate(store_list.items[:5], 1):
|
||||
savings_text = f" (save £{item.savings_vs_most_expensive:.2f})" if item.savings_vs_most_expensive > 0 else ""
|
||||
print(f" {i}. {item.product_name}: £{item.current_price:.2f}{savings_text}")
|
||||
|
||||
if len(store_list.items) > 5:
|
||||
print(f" ... and {len(store_list.items) - 5} more items")
|
||||
|
||||
if not shopping_lists:
|
||||
print(" No shopping lists available.")
|
||||
print(" Add some products and run a price scrape first.")
|
||||
|
||||
print(f"\n✅ Shopping list generation complete!")
|
||||
|
||||
# Test email sending (if configured)
|
||||
email_config = config.notification_config.get('email', {})
|
||||
if email_config.get('enabled') and email_config.get('recipient_email'):
|
||||
print(f"\n📧 Testing email notification...")
|
||||
try:
|
||||
success = shopping_generator.send_daily_shopping_list()
|
||||
if success:
|
||||
print(f" ✅ Email sent successfully!")
|
||||
else:
|
||||
print(f" ❌ Failed to send email (check configuration)")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error sending email: {str(e)}")
|
||||
else:
|
||||
print(f"\n📧 Email notifications not configured")
|
||||
|
||||
return shopping_lists
|
||||
|
||||
if __name__ == "__main__":
|
||||
test_shopping_lists()
|
||||
Reference in New Issue
Block a user