rename grocery to product
This commit is contained in:
parent
1b984d18d9
commit
d27871160e
@ -1,6 +1,6 @@
|
||||
# 🚀 Quick Start Guide
|
||||
|
||||
Get your Grocery Tracker up and running in minutes!
|
||||
Get your Product Tracker up and running in minutes!
|
||||
|
||||
## Prerequisites
|
||||
|
||||
@ -62,8 +62,8 @@ This will:
|
||||
|
||||
## First Steps
|
||||
|
||||
1. **Add a Shop**: Go to "Shops" and add your first grocery store
|
||||
2. **Add Groceries**: Go to "Groceries" and add some items
|
||||
1. **Add a Shop**: Go to "Shops" and add your first store
|
||||
2. **Add Products**: Go to "Products" and add some items
|
||||
3. **Record a Purchase**: Use "Add Purchase" to record your shopping
|
||||
|
||||
## Troubleshooting
|
||||
@ -85,8 +85,8 @@ This will:
|
||||
|
||||
✅ **Complete Backend**: FastAPI with SQLAlchemy and SQLite
|
||||
✅ **Modern Frontend**: React with TypeScript and Tailwind CSS
|
||||
✅ **Database Models**: Groceries, Shops, Shopping Events
|
||||
✅ **Database Models**: Products, Shops, Shopping Events
|
||||
✅ **API Documentation**: Automatic Swagger docs
|
||||
✅ **Beautiful UI**: Responsive design with modern components
|
||||
|
||||
Happy grocery tracking! 🛒
|
||||
Happy product tracking! 🛒
|
||||
48
README.md
48
README.md
@ -1,12 +1,12 @@
|
||||
# Grocery Tracker
|
||||
# Product Tracker
|
||||
|
||||
A web application for tracking grocery prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend.
|
||||
A web application for tracking product prices and shopping events. Built with FastAPI (Python) backend and React (TypeScript) frontend.
|
||||
|
||||
## Features
|
||||
|
||||
- **Grocery Management**: Add, edit, and track grocery items with prices, categories, and organic status
|
||||
- **Product Management**: Add, edit, and track product items with prices, categories, and organic status
|
||||
- **Shop Management**: Manage different shops with locations
|
||||
- **Shopping Events**: Record purchases with multiple groceries and amounts
|
||||
- **Shopping Events**: Record purchases with multiple products and amounts
|
||||
- **Price Tracking**: Monitor price changes over time
|
||||
- **Modern UI**: Clean, responsive interface built with React and Tailwind CSS
|
||||
|
||||
@ -42,9 +42,9 @@ A web application for tracking grocery prices and shopping events. Built with Fa
|
||||
|
||||
### Core Entities
|
||||
|
||||
#### Groceries (`groceries` table)
|
||||
#### Products (`products` table)
|
||||
- `id`: Integer, Primary key, Auto-increment
|
||||
- `name`: String, Grocery name (indexed, required)
|
||||
- `name`: String, Product name (indexed, required)
|
||||
- `category`: String, Food category (required)
|
||||
- `organic`: Boolean, Organic flag (default: false)
|
||||
- `weight`: Float, Weight/volume (optional)
|
||||
@ -72,11 +72,11 @@ A web application for tracking grocery prices and shopping events. Built with Fa
|
||||
|
||||
### Association Table
|
||||
|
||||
#### Shopping Event Groceries (`shopping_event_groceries` table)
|
||||
Many-to-many relationship between shopping events and groceries with additional data:
|
||||
#### Shopping Event Products (`shopping_event_products` table)
|
||||
Many-to-many relationship between shopping events and products with additional data:
|
||||
- `id`: Integer, Primary key, Auto-increment
|
||||
- `shopping_event_id`: Integer, Foreign key to shopping_events (required)
|
||||
- `grocery_id`: Integer, Foreign key to groceries (required)
|
||||
- `product_id`: Integer, Foreign key to products (required)
|
||||
- `amount`: Float, Quantity purchased in this event (required, > 0)
|
||||
- `price`: Float, Price at time of purchase (required, ≥ 0)
|
||||
|
||||
@ -84,10 +84,10 @@ Many-to-many relationship between shopping events and groceries with additional
|
||||
|
||||
```
|
||||
┌─────────────────┐ ┌─────────────────────────────┐ ┌─────────────────┐
|
||||
│ Shops │ │ Shopping Event Groceries │ │ Groceries │
|
||||
│ Shops │ │ Shopping Event Products │ │ Products │
|
||||
│ │ │ (Association Table) │ │ │
|
||||
│ • id │ ←──────→│ • shopping_event_id │ ←──────→│ • id │
|
||||
│ • name │ 1:N │ • grocery_id │ N:M │ • name │
|
||||
│ • name │ 1:N │ • product_id │ N:M │ • name │
|
||||
│ • city │ │ • amount │ │ • category │
|
||||
│ • address │ │ • price │ │ • organic │
|
||||
│ • created_at │ │ │ │ • weight │
|
||||
@ -110,7 +110,7 @@ Many-to-many relationship between shopping events and groceries with additional
|
||||
|
||||
### Key Features
|
||||
|
||||
- **Price History**: Each grocery purchase stores the price at that time, enabling price tracking
|
||||
- **Price History**: Each product purchase stores the price at that time, enabling price tracking
|
||||
- **Flexible Quantities**: Support for decimal amounts (e.g., 1.5 kg of apples)
|
||||
- **Auto-calculation**: Total amount can be automatically calculated from individual items
|
||||
- **Free Items**: Supports items with price 0 (samples, promotions, etc.)
|
||||
@ -146,7 +146,7 @@ Many-to-many relationship between shopping events and groceries with additional
|
||||
4. **Setup database:**
|
||||
```bash
|
||||
# Create PostgreSQL database
|
||||
createdb grocery_tracker
|
||||
createdb product_tracker
|
||||
|
||||
# Copy environment variables
|
||||
cp env.example .env
|
||||
@ -189,12 +189,12 @@ Many-to-many relationship between shopping events and groceries with additional
|
||||
|
||||
## API Endpoints
|
||||
|
||||
### Groceries
|
||||
- `GET /groceries/` - List all groceries
|
||||
- `POST /groceries/` - Create new grocery
|
||||
- `GET /groceries/{id}` - Get specific grocery
|
||||
- `PUT /groceries/{id}` - Update grocery
|
||||
- `DELETE /groceries/{id}` - Delete grocery
|
||||
### Products
|
||||
- `GET /products/` - List all products
|
||||
- `POST /products/` - Create new product
|
||||
- `GET /products/{id}` - Get specific product
|
||||
- `PUT /products/{id}` - Update product
|
||||
- `DELETE /products/{id}` - Delete product
|
||||
|
||||
### Shops
|
||||
- `GET /shops/` - List all shops
|
||||
@ -212,8 +212,8 @@ Many-to-many relationship between shopping events and groceries with additional
|
||||
|
||||
## Usage
|
||||
|
||||
1. **Add Shops**: Start by adding shops where you buy groceries
|
||||
2. **Add Groceries**: Create grocery items with prices and categories
|
||||
1. **Add Shops**: Start by adding shops where you buy products
|
||||
2. **Add Products**: Create product items with prices and categories
|
||||
3. **Record Purchases**: Use the "Add Purchase" form to record shopping events
|
||||
4. **Track Prices**: Monitor how prices change over time
|
||||
5. **View Statistics**: Analyze spending patterns by category and shop
|
||||
@ -252,8 +252,8 @@ services:
|
||||
db:
|
||||
image: postgres:15
|
||||
environment:
|
||||
POSTGRES_DB: grocery_tracker
|
||||
POSTGRES_USER: grocery_user
|
||||
POSTGRES_DB: product_tracker
|
||||
POSTGRES_USER: product_user
|
||||
POSTGRES_PASSWORD: your_password
|
||||
volumes:
|
||||
- postgres_data:/var/lib/postgresql/data
|
||||
@ -267,7 +267,7 @@ services:
|
||||
depends_on:
|
||||
- db
|
||||
environment:
|
||||
DATABASE_URL: postgresql://grocery_user:your_password@db:5432/grocery_tracker
|
||||
DATABASE_URL: postgresql://product_user:your_password@db:5432/product_tracker
|
||||
|
||||
frontend:
|
||||
build: ./frontend
|
||||
|
||||
@ -1,12 +1,12 @@
|
||||
# Test Data Scripts Documentation
|
||||
|
||||
This directory contains scripts for creating and managing test data for the Grocery Tracker application.
|
||||
This directory contains scripts for creating and managing test data for the Product Tracker application.
|
||||
|
||||
## Scripts Overview
|
||||
|
||||
### 1. `create_test_data.py` - Comprehensive Test Data Generator
|
||||
|
||||
Creates realistic test data including shops, groceries, and shopping events.
|
||||
Creates realistic test data including shops, products, and shopping events.
|
||||
|
||||
#### Basic Usage
|
||||
|
||||
@ -32,7 +32,7 @@ python create_test_data.py --dry-run
|
||||
| `--days N` | Number of days back to generate events | 90 |
|
||||
| `--url URL` | API base URL | http://localhost:8000 |
|
||||
| `--shops-only` | Create only shops | False |
|
||||
| `--groceries-only` | Create only groceries | False |
|
||||
| `--products-only` | Create only products | False |
|
||||
| `--events-only` | Create only shopping events (requires existing data) | False |
|
||||
| `--verbose`, `-v` | Verbose output with detailed progress | False |
|
||||
| `--dry-run` | Show what would be created without creating it | False |
|
||||
@ -43,10 +43,10 @@ python create_test_data.py --dry-run
|
||||
# Create only shops
|
||||
python create_test_data.py --shops-only
|
||||
|
||||
# Create only groceries
|
||||
python create_test_data.py --groceries-only
|
||||
# Create only products
|
||||
python create_test_data.py --products-only
|
||||
|
||||
# Create 100 shopping events using existing shops and groceries
|
||||
# Create 100 shopping events using existing shops and products
|
||||
python create_test_data.py --events-only --events 100
|
||||
|
||||
# Create test data for the past 6 months with verbose output
|
||||
@ -74,14 +74,14 @@ python cleanup_test_data.py
|
||||
- **Safeway** (San Francisco)
|
||||
- **Trader Joe's** (Berkeley)
|
||||
- **Berkeley Bowl** (Berkeley)
|
||||
- **Rainbow Grocery** (San Francisco)
|
||||
- **Rainbow Product** (San Francisco)
|
||||
- **Mollie Stone's Market** (Palo Alto)
|
||||
- **Costco Wholesale** (San Mateo)
|
||||
- **Target** (Mountain View)
|
||||
- **Sprouts Farmers Market** (Sunnyvale)
|
||||
- **Lucky Supermarket** (San Jose)
|
||||
|
||||
### Groceries (50+ items across 8 categories)
|
||||
### Products (50+ items across 8 categories)
|
||||
|
||||
| Category | Items | Organic Options |
|
||||
|----------|-------|-----------------|
|
||||
@ -190,7 +190,7 @@ After running the test data scripts:
|
||||
|
||||
The test data is designed to showcase all application features:
|
||||
- Multiple shops and locations
|
||||
- Diverse grocery categories
|
||||
- Diverse product categories
|
||||
- Realistic shopping patterns
|
||||
- Price variations and organic options
|
||||
- Historical data for analytics
|
||||
@ -202,7 +202,7 @@ The test data is designed to showcase all application features:
|
||||
You can modify the data arrays in `create_test_data.py` to create custom test scenarios:
|
||||
|
||||
- Add more shops for specific regions
|
||||
- Include specialty grocery categories
|
||||
- Include specialty product categories
|
||||
- Adjust price ranges for different markets
|
||||
- Create seasonal shopping patterns
|
||||
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to clean up all test data from the Grocery Tracker application.
|
||||
This will delete all shopping events, groceries, and shops.
|
||||
Script to clean up all test data from the Product Tracker application.
|
||||
This will delete all shopping events, products, and shops.
|
||||
"""
|
||||
|
||||
import requests
|
||||
@ -42,37 +42,37 @@ def delete_all_shopping_events() -> int:
|
||||
print(f" ❌ Error fetching shopping events: {e}")
|
||||
return 0
|
||||
|
||||
def delete_all_groceries() -> int:
|
||||
"""Delete all groceries and return the count of deleted groceries."""
|
||||
print("🥬 Deleting all groceries...")
|
||||
def delete_all_products() -> int:
|
||||
"""Delete all products and return the count of deleted products."""
|
||||
print("🥬 Deleting all products...")
|
||||
|
||||
try:
|
||||
# Get all groceries
|
||||
response = requests.get(f"{BASE_URL}/groceries/")
|
||||
# Get all products
|
||||
response = requests.get(f"{BASE_URL}/products/")
|
||||
if response.status_code != 200:
|
||||
print(f" ❌ Failed to fetch groceries: {response.status_code}")
|
||||
print(f" ❌ Failed to fetch products: {response.status_code}")
|
||||
return 0
|
||||
|
||||
groceries = response.json()
|
||||
products = response.json()
|
||||
deleted_count = 0
|
||||
|
||||
for grocery in groceries:
|
||||
for product in products:
|
||||
try:
|
||||
delete_response = requests.delete(f"{BASE_URL}/groceries/{grocery['id']}")
|
||||
delete_response = requests.delete(f"{BASE_URL}/products/{product['id']}")
|
||||
if delete_response.status_code == 200:
|
||||
deleted_count += 1
|
||||
organic_label = "🌱" if grocery['organic'] else "🌾"
|
||||
print(f" ✅ Deleted grocery: {organic_label} {grocery['name']}")
|
||||
organic_label = "🌱" if product['organic'] else "🌾"
|
||||
print(f" ✅ Deleted product: {organic_label} {product['name']}")
|
||||
else:
|
||||
print(f" ❌ Failed to delete grocery {grocery['name']}: {delete_response.status_code}")
|
||||
print(f" ❌ Failed to delete product {product['name']}: {delete_response.status_code}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error deleting grocery {grocery['name']}: {e}")
|
||||
print(f" ❌ Error deleting product {product['name']}: {e}")
|
||||
|
||||
print(f" 📊 Deleted {deleted_count} groceries total\n")
|
||||
print(f" 📊 Deleted {deleted_count} products total\n")
|
||||
return deleted_count
|
||||
|
||||
except Exception as e:
|
||||
print(f" ❌ Error fetching groceries: {e}")
|
||||
print(f" ❌ Error fetching products: {e}")
|
||||
return 0
|
||||
|
||||
def delete_all_shops() -> int:
|
||||
@ -115,15 +115,15 @@ def get_current_data_summary():
|
||||
try:
|
||||
# Get counts
|
||||
shops_response = requests.get(f"{BASE_URL}/shops/")
|
||||
groceries_response = requests.get(f"{BASE_URL}/groceries/")
|
||||
products_response = requests.get(f"{BASE_URL}/products/")
|
||||
events_response = requests.get(f"{BASE_URL}/shopping-events/")
|
||||
|
||||
shops_count = len(shops_response.json()) if shops_response.status_code == 200 else 0
|
||||
groceries_count = len(groceries_response.json()) if groceries_response.status_code == 200 else 0
|
||||
products_count = len(products_response.json()) if products_response.status_code == 200 else 0
|
||||
events_count = len(events_response.json()) if events_response.status_code == 200 else 0
|
||||
|
||||
print(f"🏪 Shops: {shops_count}")
|
||||
print(f"🥬 Groceries: {groceries_count}")
|
||||
print(f"🥬 Products: {products_count}")
|
||||
print(f"🛒 Shopping Events: {events_count}")
|
||||
|
||||
if events_count > 0 and events_response.status_code == 200:
|
||||
@ -132,7 +132,7 @@ def get_current_data_summary():
|
||||
print(f"💰 Total spent: ${total_spent:.2f}")
|
||||
|
||||
print()
|
||||
return shops_count, groceries_count, events_count
|
||||
return shops_count, products_count, events_count
|
||||
|
||||
except Exception as e:
|
||||
print(f"❌ Error getting data summary: {e}")
|
||||
@ -140,9 +140,9 @@ def get_current_data_summary():
|
||||
|
||||
def main():
|
||||
"""Main function to clean up all test data."""
|
||||
print("🧹 GROCERY TRACKER DATA CLEANUP")
|
||||
print("🧹 PRODUCT TRACKER DATA CLEANUP")
|
||||
print("=" * 40)
|
||||
print("This script will delete ALL data from the Grocery Tracker app.")
|
||||
print("This script will delete ALL data from the Product Tracker app.")
|
||||
print("Make sure the backend server is running on http://localhost:8000\n")
|
||||
|
||||
try:
|
||||
@ -155,9 +155,9 @@ def main():
|
||||
print("✅ Connected to API server\n")
|
||||
|
||||
# Show current data
|
||||
shops_count, groceries_count, events_count = get_current_data_summary()
|
||||
shops_count, products_count, events_count = get_current_data_summary()
|
||||
|
||||
if shops_count == 0 and groceries_count == 0 and events_count == 0:
|
||||
if shops_count == 0 and products_count == 0 and events_count == 0:
|
||||
print("✅ Database is already empty. Nothing to clean up!")
|
||||
return
|
||||
|
||||
@ -171,19 +171,19 @@ def main():
|
||||
|
||||
print("\n🧹 Starting cleanup process...\n")
|
||||
|
||||
# Delete in order: events -> groceries -> shops
|
||||
# Delete in order: events -> products -> shops
|
||||
# (due to foreign key constraints)
|
||||
deleted_events = delete_all_shopping_events()
|
||||
deleted_groceries = delete_all_groceries()
|
||||
deleted_products = delete_all_products()
|
||||
deleted_shops = delete_all_shops()
|
||||
|
||||
# Final summary
|
||||
print("📋 CLEANUP SUMMARY")
|
||||
print("=" * 30)
|
||||
print(f"🛒 Shopping Events deleted: {deleted_events}")
|
||||
print(f"🥬 Groceries deleted: {deleted_groceries}")
|
||||
print(f"🥬 Products deleted: {deleted_products}")
|
||||
print(f"🏪 Shops deleted: {deleted_shops}")
|
||||
print(f"📊 Total items deleted: {deleted_events + deleted_groceries + deleted_shops}")
|
||||
print(f"📊 Total items deleted: {deleted_events + deleted_products + deleted_shops}")
|
||||
|
||||
print("\n🎉 Cleanup completed successfully!")
|
||||
print("The database is now empty and ready for fresh data.")
|
||||
|
||||
@ -1,7 +1,7 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Script to create comprehensive test data for the Grocery Tracker application.
|
||||
This includes shops, groceries, and shopping events with realistic data.
|
||||
Script to create comprehensive test data for the Product Tracker application.
|
||||
This includes shops, products, and shopping events with realistic data.
|
||||
"""
|
||||
|
||||
import requests
|
||||
@ -20,7 +20,7 @@ SHOPS_DATA = [
|
||||
{"name": "Safeway", "city": "San Francisco", "address": "2020 Market St"},
|
||||
{"name": "Trader Joe's", "city": "Berkeley", "address": "1885 University Ave"},
|
||||
{"name": "Berkeley Bowl", "city": "Berkeley", "address": "2020 Oregon St"},
|
||||
{"name": "Rainbow Grocery", "city": "San Francisco", "address": "1745 Folsom St"},
|
||||
{"name": "Rainbow Product", "city": "San Francisco", "address": "1745 Folsom St"},
|
||||
{"name": "Mollie Stone's Market", "city": "Palo Alto", "address": "164 S California Ave"},
|
||||
{"name": "Costco Wholesale", "city": "San Mateo", "address": "2300 S Norfolk St"},
|
||||
{"name": "Target", "city": "Mountain View", "address": "1200 El Camino Real"},
|
||||
@ -119,13 +119,13 @@ PRICE_RANGES = {
|
||||
|
||||
def parse_arguments():
|
||||
"""Parse command line arguments."""
|
||||
parser = argparse.ArgumentParser(description='Create test data for Grocery Tracker')
|
||||
parser = argparse.ArgumentParser(description='Create test data for Product Tracker')
|
||||
parser.add_argument('--events', type=int, default=30, help='Number of shopping events to create (default: 30)')
|
||||
parser.add_argument('--days', type=int, default=90, help='Number of days back to generate events (default: 90)')
|
||||
parser.add_argument('--url', type=str, default=BASE_URL, help='API base URL (default: http://localhost:8000)')
|
||||
parser.add_argument('--shops-only', action='store_true', help='Create only shops')
|
||||
parser.add_argument('--groceries-only', action='store_true', help='Create only groceries')
|
||||
parser.add_argument('--events-only', action='store_true', help='Create only shopping events (requires existing shops and groceries)')
|
||||
parser.add_argument('--products-only', action='store_true', help='Create only products')
|
||||
parser.add_argument('--events-only', action='store_true', help='Create only shopping events (requires existing shops and products)')
|
||||
parser.add_argument('--verbose', '-v', action='store_true', help='Verbose output')
|
||||
parser.add_argument('--dry-run', action='store_true', help='Show what would be created without actually creating it')
|
||||
return parser.parse_args()
|
||||
@ -171,43 +171,43 @@ def create_shops(base_url: str, verbose: bool = False, dry_run: bool = False) ->
|
||||
print(f" 📊 Created {len(created_shops)} shops total\n")
|
||||
return created_shops
|
||||
|
||||
def create_groceries(base_url: str, verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Create groceries and return the created grocery objects."""
|
||||
print("🥬 Creating groceries...")
|
||||
created_groceries = []
|
||||
def create_products(base_url: str, verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Create products and return the created product objects."""
|
||||
print("🥬 Creating products...")
|
||||
created_products = []
|
||||
|
||||
if dry_run:
|
||||
print(" [DRY RUN] Would create the following groceries:")
|
||||
for grocery_data in GROCERIES_DATA:
|
||||
organic_label = "🌱" if grocery_data['organic'] else "🌾"
|
||||
print(f" 📋 {organic_label} {grocery_data['name']} ({grocery_data['category']})")
|
||||
print(" [DRY RUN] Would create the following products:")
|
||||
for product_data in GROCERIES_DATA:
|
||||
organic_label = "🌱" if product_data['organic'] else "🌾"
|
||||
print(f" 📋 {organic_label} {product_data['name']} ({product_data['category']})")
|
||||
return []
|
||||
|
||||
for grocery_data in GROCERIES_DATA:
|
||||
for product_data in GROCERIES_DATA:
|
||||
try:
|
||||
if verbose:
|
||||
print(f" 🔄 Creating grocery: {grocery_data['name']}...")
|
||||
print(f" 🔄 Creating product: {product_data['name']}...")
|
||||
|
||||
response = requests.post(f"{base_url}/groceries/", json=grocery_data, timeout=10)
|
||||
response = requests.post(f"{base_url}/products/", json=product_data, timeout=10)
|
||||
if response.status_code == 200:
|
||||
grocery = response.json()
|
||||
created_groceries.append(grocery)
|
||||
organic_label = "🌱" if grocery['organic'] else "🌾"
|
||||
print(f" ✅ Created grocery: {organic_label} {grocery['name']} ({grocery['category']})")
|
||||
product = response.json()
|
||||
created_products.append(product)
|
||||
organic_label = "🌱" if product['organic'] else "🌾"
|
||||
print(f" ✅ Created product: {organic_label} {product['name']} ({product['category']})")
|
||||
else:
|
||||
print(f" ❌ Failed to create grocery {grocery_data['name']}: {response.status_code}")
|
||||
print(f" ❌ Failed to create product {product_data['name']}: {response.status_code}")
|
||||
if verbose:
|
||||
print(f" Response: {response.text}")
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f" ❌ Network error creating grocery {grocery_data['name']}: {e}")
|
||||
print(f" ❌ Network error creating product {product_data['name']}: {e}")
|
||||
except Exception as e:
|
||||
print(f" ❌ Error creating grocery {grocery_data['name']}: {e}")
|
||||
print(f" ❌ Error creating product {product_data['name']}: {e}")
|
||||
|
||||
print(f" 📊 Created {len(created_groceries)} groceries total\n")
|
||||
return created_groceries
|
||||
print(f" 📊 Created {len(created_products)} products total\n")
|
||||
return created_products
|
||||
|
||||
def generate_random_price(category: str, organic: bool = False) -> float:
|
||||
"""Generate a random price for a grocery item based on category and organic status."""
|
||||
"""Generate a random price for a product item based on category and organic status."""
|
||||
min_price, max_price = PRICE_RANGES.get(category, (1.99, 9.99))
|
||||
|
||||
# Organic items are typically 20-50% more expensive
|
||||
@ -220,23 +220,23 @@ def generate_random_price(category: str, organic: bool = False) -> float:
|
||||
return round(price, 2)
|
||||
|
||||
def get_existing_data(base_url: str) -> tuple[List[Dict], List[Dict]]:
|
||||
"""Get existing shops and groceries from the API."""
|
||||
"""Get existing shops and products from the API."""
|
||||
try:
|
||||
shops_response = requests.get(f"{base_url}/shops/", timeout=10)
|
||||
groceries_response = requests.get(f"{base_url}/groceries/", timeout=10)
|
||||
products_response = requests.get(f"{base_url}/products/", timeout=10)
|
||||
|
||||
shops = shops_response.json() if shops_response.status_code == 200 else []
|
||||
groceries = groceries_response.json() if groceries_response.status_code == 200 else []
|
||||
products = products_response.json() if products_response.status_code == 200 else []
|
||||
|
||||
return shops, groceries
|
||||
return shops, products
|
||||
except requests.exceptions.RequestException as e:
|
||||
print(f" ❌ Error fetching existing data: {e}")
|
||||
return [], []
|
||||
|
||||
def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: str,
|
||||
def create_shopping_events(shops: List[Dict], products: List[Dict], base_url: str,
|
||||
num_events: int = 25, days_back: int = 90,
|
||||
verbose: bool = False, dry_run: bool = False) -> List[Dict[str, Any]]:
|
||||
"""Create shopping events with random groceries and realistic data."""
|
||||
"""Create shopping events with random products and realistic data."""
|
||||
print(f"🛒 Creating {num_events} shopping events...")
|
||||
created_events = []
|
||||
|
||||
@ -244,8 +244,8 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
|
||||
print(" ❌ No shops available. Cannot create shopping events.")
|
||||
return []
|
||||
|
||||
if not groceries:
|
||||
print(" ❌ No groceries available. Cannot create shopping events.")
|
||||
if not products:
|
||||
print(" ❌ No products available. Cannot create shopping events.")
|
||||
return []
|
||||
|
||||
# Generate events over the specified time period
|
||||
@ -256,7 +256,7 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
|
||||
print(f" [DRY RUN] Would create {num_events} shopping events over {days_back} days")
|
||||
print(f" 📋 Date range: {start_date.strftime('%Y-%m-%d')} to {end_date.strftime('%Y-%m-%d')}")
|
||||
print(f" 📋 Available shops: {len(shops)}")
|
||||
print(f" 📋 Available groceries: {len(groceries)}")
|
||||
print(f" 📋 Available products: {len(products)}")
|
||||
return []
|
||||
|
||||
for i in range(num_events):
|
||||
@ -272,32 +272,32 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
|
||||
minutes=random.randint(0, 59)
|
||||
)
|
||||
|
||||
# Random number of groceries (2-8 items per shopping trip)
|
||||
num_groceries = random.randint(2, 8)
|
||||
selected_groceries = random.sample(groceries, min(num_groceries, len(groceries)))
|
||||
# Random number of products (2-8 items per shopping trip)
|
||||
num_products = random.randint(2, 8)
|
||||
selected_products = random.sample(products, min(num_products, len(products)))
|
||||
|
||||
# Create grocery items for this event
|
||||
event_groceries = []
|
||||
# Create product items for this event
|
||||
event_products = []
|
||||
total_amount = 0.0
|
||||
|
||||
for grocery in selected_groceries:
|
||||
for product in selected_products:
|
||||
# Random amount based on item type
|
||||
if grocery['weight_unit'] == 'piece':
|
||||
if product['weight_unit'] == 'piece':
|
||||
amount = random.randint(1, 4)
|
||||
elif grocery['weight_unit'] == 'dozen':
|
||||
elif product['weight_unit'] == 'dozen':
|
||||
amount = 1
|
||||
elif grocery['weight_unit'] in ['box', 'head', 'bunch']:
|
||||
elif product['weight_unit'] in ['box', 'head', 'bunch']:
|
||||
amount = random.randint(1, 2)
|
||||
elif grocery['weight_unit'] in ['gallon', 'l']:
|
||||
elif product['weight_unit'] in ['gallon', 'l']:
|
||||
amount = 1
|
||||
else:
|
||||
amount = round(random.uniform(0.5, 3.0), 2)
|
||||
|
||||
# Generate price based on category and organic status
|
||||
price = generate_random_price(grocery['category'], grocery['organic'])
|
||||
price = generate_random_price(product['category'], product['organic'])
|
||||
|
||||
event_groceries.append({
|
||||
"grocery_id": grocery['id'],
|
||||
event_products.append({
|
||||
"product_id": product['id'],
|
||||
"amount": amount,
|
||||
"price": price
|
||||
})
|
||||
@ -311,11 +311,11 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
|
||||
notes = None
|
||||
if random.random() < 0.3:
|
||||
note_options = [
|
||||
"Weekly grocery shopping",
|
||||
"Weekly product shopping",
|
||||
"Quick lunch ingredients",
|
||||
"Dinner party prep",
|
||||
"Meal prep for the week",
|
||||
"Emergency grocery run",
|
||||
"Emergency product run",
|
||||
"Organic produce haul",
|
||||
"Bulk shopping trip",
|
||||
"Special occasion shopping",
|
||||
@ -332,14 +332,14 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
|
||||
"date": event_date.isoformat(),
|
||||
"total_amount": total_amount,
|
||||
"notes": notes,
|
||||
"groceries": event_groceries
|
||||
"products": event_products
|
||||
}
|
||||
|
||||
response = requests.post(f"{base_url}/shopping-events/", json=event_data, timeout=15)
|
||||
if response.status_code == 200:
|
||||
event = response.json()
|
||||
created_events.append(event)
|
||||
print(f" ✅ Created event #{event['id']}: {shop['name']} - ${total_amount:.2f} ({len(event_groceries)} items)")
|
||||
print(f" ✅ Created event #{event['id']}: {shop['name']} - ${total_amount:.2f} ({len(event_products)} items)")
|
||||
else:
|
||||
print(f" ❌ Failed to create shopping event: {response.status_code}")
|
||||
if verbose:
|
||||
@ -352,7 +352,7 @@ def create_shopping_events(shops: List[Dict], groceries: List[Dict], base_url: s
|
||||
print(f" 📊 Created {len(created_events)} shopping events total\n")
|
||||
return created_events
|
||||
|
||||
def print_summary(shops: List[Dict], groceries: List[Dict], events: List[Dict]):
|
||||
def print_summary(shops: List[Dict], products: List[Dict], events: List[Dict]):
|
||||
"""Print a summary of the created test data."""
|
||||
print("📋 TEST DATA SUMMARY")
|
||||
print("=" * 50)
|
||||
@ -361,13 +361,13 @@ def print_summary(shops: List[Dict], groceries: List[Dict], events: List[Dict]):
|
||||
for shop in shops:
|
||||
print(f" • {shop['name']} ({shop['city']})")
|
||||
|
||||
print(f"\n🥬 Groceries: {len(groceries)}")
|
||||
print(f"\n🥬 Products: {len(products)}")
|
||||
categories = {}
|
||||
for grocery in groceries:
|
||||
category = grocery['category']
|
||||
for product in products:
|
||||
category = product['category']
|
||||
if category not in categories:
|
||||
categories[category] = []
|
||||
categories[category].append(grocery)
|
||||
categories[category].append(product)
|
||||
|
||||
for category, items in categories.items():
|
||||
organic_count = sum(1 for item in items if item['organic'])
|
||||
@ -394,7 +394,7 @@ def main():
|
||||
"""Main function to create all test data."""
|
||||
args = parse_arguments()
|
||||
|
||||
print("🚀 GROCERY TRACKER TEST DATA GENERATOR")
|
||||
print("🚀 PRODUCT TRACKER TEST DATA GENERATOR")
|
||||
print("=" * 50)
|
||||
|
||||
if args.dry_run:
|
||||
@ -415,28 +415,28 @@ def main():
|
||||
print("✅ Connected to API server\n")
|
||||
|
||||
shops = []
|
||||
groceries = []
|
||||
products = []
|
||||
events = []
|
||||
|
||||
# Create data based on arguments
|
||||
if args.shops_only:
|
||||
shops = create_shops(args.url, args.verbose, args.dry_run)
|
||||
elif args.groceries_only:
|
||||
groceries = create_groceries(args.url, args.verbose, args.dry_run)
|
||||
elif args.products_only:
|
||||
products = create_products(args.url, args.verbose, args.dry_run)
|
||||
elif args.events_only:
|
||||
# Get existing data for events
|
||||
shops, groceries = get_existing_data(args.url)
|
||||
events = create_shopping_events(shops, groceries, args.url, args.events, args.days, args.verbose, args.dry_run)
|
||||
shops, products = get_existing_data(args.url)
|
||||
events = create_shopping_events(shops, products, args.url, args.events, args.days, args.verbose, args.dry_run)
|
||||
else:
|
||||
# Create all data
|
||||
shops = create_shops(args.url, args.verbose, args.dry_run)
|
||||
groceries = create_groceries(args.url, args.verbose, args.dry_run)
|
||||
if shops and groceries:
|
||||
events = create_shopping_events(shops, groceries, args.url, args.events, args.days, args.verbose, args.dry_run)
|
||||
products = create_products(args.url, args.verbose, args.dry_run)
|
||||
if shops and products:
|
||||
events = create_shopping_events(shops, products, args.url, args.events, args.days, args.verbose, args.dry_run)
|
||||
|
||||
# Print summary
|
||||
if not args.dry_run:
|
||||
print_summary(shops, groceries, events)
|
||||
print_summary(shops, products, events)
|
||||
|
||||
if args.dry_run:
|
||||
print("\n🔍 Dry run completed. Use without --dry-run to actually create the data.")
|
||||
|
||||
@ -11,7 +11,7 @@ DATABASE_URL = os.getenv("DATABASE_URL")
|
||||
|
||||
if not DATABASE_URL:
|
||||
# Default to SQLite for development if no PostgreSQL URL is provided
|
||||
DATABASE_URL = "sqlite:///./grocery_tracker.db"
|
||||
DATABASE_URL = "sqlite:///./product_tracker.db"
|
||||
print("🔄 Using SQLite database for development")
|
||||
else:
|
||||
print(f"🐘 Using PostgreSQL database")
|
||||
|
||||
@ -1,9 +1,9 @@
|
||||
# Database Configuration
|
||||
# Option 1: PostgreSQL (for production)
|
||||
# DATABASE_URL=postgresql://username:password@localhost:5432/grocery_tracker
|
||||
# DATABASE_URL=postgresql://username:password@localhost:5432/product_tracker
|
||||
|
||||
# Option 2: SQLite (for development - default if DATABASE_URL is not set)
|
||||
# DATABASE_URL=sqlite:///./grocery_tracker.db
|
||||
# DATABASE_URL=sqlite:///./product_tracker.db
|
||||
|
||||
# Authentication (optional for basic setup)
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
146
backend/main.py
146
backend/main.py
@ -10,8 +10,8 @@ from database import engine, get_db
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Grocery Tracker API",
|
||||
description="API for tracking grocery prices and shopping events",
|
||||
title="Product Tracker API",
|
||||
description="API for tracking product prices and shopping events",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
@ -25,22 +25,22 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> schemas.ShoppingEventResponse:
|
||||
"""Build a shopping event response with groceries from the association table"""
|
||||
# Get groceries with their event-specific data
|
||||
grocery_data = db.execute(
|
||||
"""Build a shopping event response with products from the association table"""
|
||||
# Get products with their event-specific data
|
||||
product_data = db.execute(
|
||||
text("""
|
||||
SELECT g.id, g.name, g.category, g.organic, g.weight, g.weight_unit,
|
||||
seg.amount, seg.price
|
||||
FROM groceries g
|
||||
JOIN shopping_event_groceries seg ON g.id = seg.grocery_id
|
||||
WHERE seg.shopping_event_id = :event_id
|
||||
SELECT p.id, p.name, p.category, p.organic, p.weight, p.weight_unit,
|
||||
sep.amount, sep.price
|
||||
FROM products p
|
||||
JOIN shopping_event_products sep ON p.id = sep.product_id
|
||||
WHERE sep.shopping_event_id = :event_id
|
||||
"""),
|
||||
{"event_id": event.id}
|
||||
).fetchall()
|
||||
|
||||
# Convert to GroceryWithEventData objects
|
||||
groceries_with_data = [
|
||||
schemas.GroceryWithEventData(
|
||||
# Convert to ProductWithEventData objects
|
||||
products_with_data = [
|
||||
schemas.ProductWithEventData(
|
||||
id=row.id,
|
||||
name=row.name,
|
||||
category=row.category,
|
||||
@ -50,7 +50,7 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
||||
amount=row.amount,
|
||||
price=row.price
|
||||
)
|
||||
for row in grocery_data
|
||||
for row in product_data
|
||||
]
|
||||
|
||||
return schemas.ShoppingEventResponse(
|
||||
@ -61,58 +61,58 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
|
||||
notes=event.notes,
|
||||
created_at=event.created_at,
|
||||
shop=event.shop,
|
||||
groceries=groceries_with_data
|
||||
products=products_with_data
|
||||
)
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Grocery Tracker API", "version": "1.0.0"}
|
||||
return {"message": "Product Tracker API", "version": "1.0.0"}
|
||||
|
||||
# Grocery endpoints
|
||||
@app.post("/groceries/", response_model=schemas.Grocery)
|
||||
def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)):
|
||||
db_grocery = models.Grocery(**grocery.dict())
|
||||
db.add(db_grocery)
|
||||
# Product endpoints
|
||||
@app.post("/products/", response_model=schemas.Product)
|
||||
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
|
||||
db_product = models.Product(**product.dict())
|
||||
db.add(db_product)
|
||||
db.commit()
|
||||
db.refresh(db_grocery)
|
||||
return db_grocery
|
||||
db.refresh(db_product)
|
||||
return db_product
|
||||
|
||||
@app.get("/groceries/", response_model=List[schemas.Grocery])
|
||||
def read_groceries(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
groceries = db.query(models.Grocery).offset(skip).limit(limit).all()
|
||||
return groceries
|
||||
@app.get("/products/", response_model=List[schemas.Product])
|
||||
def read_products(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
products = db.query(models.Product).offset(skip).limit(limit).all()
|
||||
return products
|
||||
|
||||
@app.get("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
||||
def read_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||
return grocery
|
||||
@app.get("/products/{product_id}", response_model=schemas.Product)
|
||||
def read_product(product_id: int, db: Session = Depends(get_db)):
|
||||
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
return product
|
||||
|
||||
@app.put("/groceries/{grocery_id}", response_model=schemas.Grocery)
|
||||
def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: Session = Depends(get_db)):
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||
@app.put("/products/{product_id}", response_model=schemas.Product)
|
||||
def update_product(product_id: int, product_update: schemas.ProductUpdate, db: Session = Depends(get_db)):
|
||||
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
update_data = grocery_update.dict(exclude_unset=True)
|
||||
update_data = product_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(grocery, field, value)
|
||||
setattr(product, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(grocery)
|
||||
return grocery
|
||||
db.refresh(product)
|
||||
return product
|
||||
|
||||
@app.delete("/groceries/{grocery_id}")
|
||||
def delete_grocery(grocery_id: int, db: Session = Depends(get_db)):
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail="Grocery not found")
|
||||
@app.delete("/products/{product_id}")
|
||||
def delete_product(product_id: int, db: Session = Depends(get_db)):
|
||||
product = db.query(models.Product).filter(models.Product.id == product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail="Product not found")
|
||||
|
||||
db.delete(grocery)
|
||||
db.delete(product)
|
||||
db.commit()
|
||||
return {"message": "Grocery deleted successfully"}
|
||||
return {"message": "Product deleted successfully"}
|
||||
|
||||
# Shop endpoints
|
||||
@app.post("/shops/", response_model=schemas.Shop)
|
||||
@ -178,19 +178,19 @@ def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depe
|
||||
db.commit()
|
||||
db.refresh(db_event)
|
||||
|
||||
# Add groceries to the event
|
||||
for grocery_item in event.groceries:
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
|
||||
# Add products to the event
|
||||
for product_item in event.products:
|
||||
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
|
||||
|
||||
# Insert into association table
|
||||
db.execute(
|
||||
models.shopping_event_groceries.insert().values(
|
||||
models.shopping_event_products.insert().values(
|
||||
shopping_event_id=db_event.id,
|
||||
grocery_id=grocery_item.grocery_id,
|
||||
amount=grocery_item.amount,
|
||||
price=grocery_item.price
|
||||
product_id=product_item.product_id,
|
||||
amount=product_item.amount,
|
||||
price=product_item.price
|
||||
)
|
||||
)
|
||||
|
||||
@ -228,26 +228,26 @@ def update_shopping_event(event_id: int, event_update: schemas.ShoppingEventCrea
|
||||
event.total_amount = event_update.total_amount
|
||||
event.notes = event_update.notes
|
||||
|
||||
# Remove existing grocery associations
|
||||
# Remove existing product associations
|
||||
db.execute(
|
||||
models.shopping_event_groceries.delete().where(
|
||||
models.shopping_event_groceries.c.shopping_event_id == event_id
|
||||
models.shopping_event_products.delete().where(
|
||||
models.shopping_event_products.c.shopping_event_id == event_id
|
||||
)
|
||||
)
|
||||
|
||||
# Add new grocery associations
|
||||
for grocery_item in event_update.groceries:
|
||||
grocery = db.query(models.Grocery).filter(models.Grocery.id == grocery_item.grocery_id).first()
|
||||
if grocery is None:
|
||||
raise HTTPException(status_code=404, detail=f"Grocery with id {grocery_item.grocery_id} not found")
|
||||
# Add new product associations
|
||||
for product_item in event_update.products:
|
||||
product = db.query(models.Product).filter(models.Product.id == product_item.product_id).first()
|
||||
if product is None:
|
||||
raise HTTPException(status_code=404, detail=f"Product with id {product_item.product_id} not found")
|
||||
|
||||
# Insert into association table
|
||||
db.execute(
|
||||
models.shopping_event_groceries.insert().values(
|
||||
models.shopping_event_products.insert().values(
|
||||
shopping_event_id=event_id,
|
||||
grocery_id=grocery_item.grocery_id,
|
||||
amount=grocery_item.amount,
|
||||
price=grocery_item.price
|
||||
product_id=product_item.product_id,
|
||||
amount=product_item.amount,
|
||||
price=product_item.price
|
||||
)
|
||||
)
|
||||
|
||||
@ -261,10 +261,10 @@ def delete_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||
|
||||
# Delete grocery associations first
|
||||
# Delete product associations first
|
||||
db.execute(
|
||||
models.shopping_event_groceries.delete().where(
|
||||
models.shopping_event_groceries.c.shopping_event_id == event_id
|
||||
models.shopping_event_products.delete().where(
|
||||
models.shopping_event_products.c.shopping_event_id == event_id
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
@ -6,19 +6,19 @@ from datetime import datetime
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Association table for many-to-many relationship between shopping events and groceries
|
||||
shopping_event_groceries = Table(
|
||||
'shopping_event_groceries',
|
||||
# Association table for many-to-many relationship between shopping events and products
|
||||
shopping_event_products = Table(
|
||||
'shopping_event_products',
|
||||
Base.metadata,
|
||||
Column('id', Integer, primary_key=True, autoincrement=True), # Artificial primary key
|
||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), nullable=False),
|
||||
Column('grocery_id', Integer, ForeignKey('groceries.id'), nullable=False),
|
||||
Column('amount', Float, nullable=False), # Amount of this grocery bought in this event
|
||||
Column('price', Float, nullable=False) # Price of this grocery at the time of this shopping event
|
||||
Column('product_id', Integer, ForeignKey('products.id'), nullable=False),
|
||||
Column('amount', Float, nullable=False), # Amount of this product bought in this event
|
||||
Column('price', Float, nullable=False) # Price of this product at the time of this shopping event
|
||||
)
|
||||
|
||||
class Grocery(Base):
|
||||
__tablename__ = "groceries"
|
||||
class Product(Base):
|
||||
__tablename__ = "products"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
@ -30,7 +30,7 @@ class Grocery(Base):
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_groceries, back_populates="groceries")
|
||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_products, back_populates="products")
|
||||
|
||||
class Shop(Base):
|
||||
__tablename__ = "shops"
|
||||
@ -58,4 +58,4 @@ class ShoppingEvent(Base):
|
||||
|
||||
# Relationships
|
||||
shop = relationship("Shop", back_populates="shopping_events")
|
||||
groceries = relationship("Grocery", secondary=shopping_event_groceries, back_populates="shopping_events")
|
||||
products = relationship("Product", secondary=shopping_event_products, back_populates="shopping_events")
|
||||
@ -22,7 +22,7 @@ def main():
|
||||
backend_dir = Path(__file__).parent
|
||||
os.chdir(backend_dir)
|
||||
|
||||
print("🍃 Starting Grocery Tracker Backend Development Server")
|
||||
print("🍃 Starting Product Tracker Backend Development Server")
|
||||
print("=" * 50)
|
||||
|
||||
# Check if virtual environment exists
|
||||
|
||||
@ -3,24 +3,24 @@ from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Base schemas
|
||||
class GroceryBase(BaseModel):
|
||||
class ProductBase(BaseModel):
|
||||
name: str
|
||||
category: str
|
||||
organic: bool = False
|
||||
weight: Optional[float] = None
|
||||
weight_unit: str = "g"
|
||||
|
||||
class GroceryCreate(GroceryBase):
|
||||
class ProductCreate(ProductBase):
|
||||
pass
|
||||
|
||||
class GroceryUpdate(BaseModel):
|
||||
class ProductUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
category: Optional[str] = None
|
||||
organic: Optional[bool] = None
|
||||
weight: Optional[float] = None
|
||||
weight_unit: Optional[str] = None
|
||||
|
||||
class Grocery(GroceryBase):
|
||||
class Product(ProductBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
@ -51,12 +51,12 @@ class Shop(ShopBase):
|
||||
from_attributes = True
|
||||
|
||||
# Shopping Event schemas
|
||||
class GroceryInEvent(BaseModel):
|
||||
grocery_id: int
|
||||
class ProductInEvent(BaseModel):
|
||||
product_id: int
|
||||
amount: float = Field(..., gt=0)
|
||||
price: float = Field(..., ge=0) # Price at the time of this shopping event (allow free items)
|
||||
|
||||
class GroceryWithEventData(BaseModel):
|
||||
class ProductWithEventData(BaseModel):
|
||||
id: int
|
||||
name: str
|
||||
category: str
|
||||
@ -76,21 +76,21 @@ class ShoppingEventBase(BaseModel):
|
||||
notes: Optional[str] = None
|
||||
|
||||
class ShoppingEventCreate(ShoppingEventBase):
|
||||
groceries: List[GroceryInEvent] = []
|
||||
products: List[ProductInEvent] = []
|
||||
|
||||
class ShoppingEventUpdate(BaseModel):
|
||||
shop_id: Optional[int] = None
|
||||
date: Optional[datetime] = None
|
||||
total_amount: Optional[float] = Field(None, ge=0)
|
||||
notes: Optional[str] = None
|
||||
groceries: Optional[List[GroceryInEvent]] = None
|
||||
products: Optional[List[ProductInEvent]] = None
|
||||
|
||||
class ShoppingEventResponse(ShoppingEventBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
shop: Shop
|
||||
groceries: List[GroceryWithEventData] = []
|
||||
products: List[ProductWithEventData] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
603
database_schema dev.drawio
Normal file
603
database_schema dev.drawio
Normal file
@ -0,0 +1,603 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||
<mxGraphModel dx="999" dy="529" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
<mxCell id="1" parent="0"/>
|
||||
<mxCell id="shop-event-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="71" target="43" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="300" y="470" as="sourcePoint"/>
|
||||
<mxPoint x="350" y="420" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="shop-event-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="960" y="399" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="event-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="40" target="99" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="620" y="310" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="270" as="targetPoint"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="event-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="900" y="230" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="161" target="102" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="1020" y="550" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="290" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="product-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="390" y="160" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="390" y="440" width="180" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="4" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="5" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="3" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="6" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="7" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="8" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="6" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="144" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="145" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="144" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="146" value="category_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="144" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="21" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="22" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="21" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="23" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="21" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="15" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="2" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="16" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="15" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="17" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="15" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="39" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_events</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="580" y="150" width="180" height="240" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="40" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="39" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="41" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="42" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="40" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="43" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="44" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="45" value="shop_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="43" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="46" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="47" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="48" value="date: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="46" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="49" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="50" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="51" value="total_amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="49" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="52" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="53" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="52" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="54" value="notes: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="52" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="58" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="59" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="58" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="60" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="58" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="111" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="39" vertex="1">
|
||||
<mxGeometry y="210" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="112" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="111" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="113" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="111" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="70" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shops</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="80" y="120" width="180" height="210" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="71" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="70" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="72" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="73" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="71" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="74" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="75" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="76" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="74" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="77" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="78" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="79" value="city: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="77" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="80" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="81" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="82" value="address: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="80" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="89" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="70" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="90" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="89" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="91" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="89" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="92" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;strokeColor=#b85450;" parent="70" vertex="1">
|
||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="93" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="92" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="94" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="92" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="1090" y="260" width="240" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
|
||||
<mxGeometry y="30" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="97" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="98" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="96" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="99" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="60" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="100" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="101" value="shopping_event_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="99" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="102" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="90" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="103" value="FK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="105" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="120" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="106" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="107" value="amount: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="105" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="108" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="95" vertex="1">
|
||||
<mxGeometry y="150" width="240" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="109" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="108" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="110" value="price: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="108" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="119" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">grocerie_categories</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="80" y="440" width="180" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="120" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="119" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="121" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="120" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="122" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="120" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="123" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="124" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="123" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="125" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="123" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="138" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="139" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="138" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="140" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="138" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="141" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="119" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="142" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="141" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="143" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="141" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="160" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="710" y="445" width="180" height="300" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="161" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="160" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="162" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="161" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="163" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="161" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="164" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="165" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="164" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="166" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="164" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="170" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="171" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="170" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="172" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="170" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="188" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="189" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="188" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="190" value="brand_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="188" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="173" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="150" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="174" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="173" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="175" value="organic: BOOLEAN" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="173" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="176" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="180" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="177" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="176" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="178" value="weight: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="176" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="179" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="210" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="180" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="179" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="181" value="weight_unit: FLOAT" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="179" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="182" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="240" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="183" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="182" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="184" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="182" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="185" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="160" vertex="1">
|
||||
<mxGeometry y="270" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="186" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="185" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="187" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="185" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="191" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">brands</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="390" y="790" width="180" height="150" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="192" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="191" vertex="1">
|
||||
<mxGeometry y="30" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="193" value="PK" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;fontStyle=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="192" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="194" value="id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;fontStyle=5;overflow=hidden;whiteSpace=wrap;html=1;" parent="192" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="195" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="191" vertex="1">
|
||||
<mxGeometry y="60" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="196" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;whiteSpace=wrap;html=1;" parent="195" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="197" value="name: STRING" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="195" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="213" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="191" vertex="1">
|
||||
<mxGeometry y="90" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="214" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="213" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="215" value="created_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="213" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="216" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=0;" parent="191" vertex="1">
|
||||
<mxGeometry y="120" width="180" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="217" value="" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;editable=1;overflow=hidden;" parent="216" vertex="1">
|
||||
<mxGeometry width="30" height="30" as="geometry">
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="218" value="updated_at: DATETIME" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;" parent="216" vertex="1">
|
||||
<mxGeometry x="30" width="150" height="30" as="geometry">
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="219" value="" style="endArrow=ERmany;html=1;rounded=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;" parent="1" source="192" target="188" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="900" y="500" as="sourcePoint"/>
|
||||
<mxPoint x="1100" y="375" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="220" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="3" target="170" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="580" y="845" as="sourcePoint"/>
|
||||
<mxPoint x="670" y="560" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="221" value="" style="endArrow=ERmany;html=1;rounded=0;startArrow=ERone;startFill=0;endFill=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;" parent="1" source="120" target="144" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="580" y="495" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="560" as="targetPoint"/>
|
||||
<Array as="points"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
</root>
|
||||
</mxGraphModel>
|
||||
</diagram>
|
||||
</mxfile>
|
||||
@ -1,5 +1,5 @@
|
||||
<mxfile host="65bd71144e">
|
||||
<diagram name="Grocery Tracker Database Schema" id="database-schema">
|
||||
<diagram name="Product Tracker Database Schema" id="database-schema">
|
||||
<mxGraphModel dx="547" dy="520" grid="1" gridSize="10" guides="1" tooltips="1" connect="1" arrows="1" fold="1" page="1" pageScale="1" pageWidth="1169" pageHeight="827" math="0" shadow="0">
|
||||
<root>
|
||||
<mxCell id="0"/>
|
||||
@ -22,7 +22,7 @@
|
||||
<mxCell id="event-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="650" y="280" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="grocery-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
|
||||
<mxCell id="product-association-relation" value="" style="endArrow=ERmany;html=1;rounded=0;exitX=1;exitY=0.5;exitDx=0;exitDy=0;entryX=0;entryY=0.5;entryDx=0;entryDy=0;startArrow=ERone;startFill=0;endFill=0;" parent="1" source="3" target="102" edge="1">
|
||||
<mxGeometry width="50" height="50" relative="1" as="geometry">
|
||||
<mxPoint x="280" y="150" as="sourcePoint"/>
|
||||
<mxPoint x="720" y="290" as="targetPoint"/>
|
||||
@ -32,7 +32,7 @@
|
||||
</Array>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="grocery-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxCell id="product-association-label" value="1:N" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="630" y="220" width="40" height="30" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="legend" value="" style="swimlane;fontStyle=1;childLayout=stackLayout;horizontal=1;startSize=30;horizontalStack=0;resizeParent=1;resizeParentMax=0;resizeLast=0;collapsible=1;marginBottom=0;whiteSpace=wrap;html=1;fillColor=#f5f5f5;strokeColor=#666666;" parent="1" vertex="1">
|
||||
@ -50,10 +50,10 @@
|
||||
<mxCell id="legend-relation" value="1:N = One-to-Many Relationship" style="text;strokeColor=none;fillColor=none;align=left;verticalAlign=middle;spacingLeft=4;spacingRight=4;overflow=hidden;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;rotatable=0;whiteSpace=wrap;html=1;" parent="legend" vertex="1">
|
||||
<mxGeometry y="90" width="300" height="20" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="diagram-title" value="Grocery Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxCell id="diagram-title" value="Product Tracker Database Schema" style="text;html=1;align=center;verticalAlign=middle;resizable=0;points=[];autosize=1;strokeColor=none;fillColor=none;fontSize=20;fontStyle=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="400" y="20" width="320" height="40" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxCell id="2" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="70" y="70" width="180" height="270" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="3" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="2" vertex="1">
|
||||
@ -335,7 +335,7 @@
|
||||
<mxRectangle width="150" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_groceries</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxCell id="95" value="<span style="color: rgb(0, 0, 0); text-wrap-mode: wrap;">shopping_event_products</span>" style="shape=table;startSize=30;container=1;collapsible=1;childLayout=tableLayout;fixedRows=1;rowLines=0;fontStyle=1;align=center;resizeLast=1;html=1;" parent="1" vertex="1">
|
||||
<mxGeometry x="810" y="200" width="240" height="180" as="geometry"/>
|
||||
</mxCell>
|
||||
<mxCell id="96" value="" style="shape=tableRow;horizontal=0;startSize=0;swimlaneHead=0;swimlaneBody=0;fillColor=none;collapsible=0;dropTarget=0;points=[[0,0.5],[1,0.5]];portConstraint=eastwest;top=0;left=0;right=0;bottom=1;" parent="95" vertex="1">
|
||||
@ -372,7 +372,7 @@
|
||||
<mxRectangle width="30" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
</mxCell>
|
||||
<mxCell id="104" value="grocery_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||
<mxCell id="104" value="product_id: INTEGER" style="shape=partialRectangle;connectable=0;fillColor=none;top=0;left=0;bottom=0;right=0;align=left;spacingLeft=6;overflow=hidden;whiteSpace=wrap;html=1;" parent="102" vertex="1">
|
||||
<mxGeometry x="30" width="210" height="30" as="geometry">
|
||||
<mxRectangle width="210" height="30" as="alternateBounds"/>
|
||||
</mxGeometry>
|
||||
|
||||
4
frontend/package-lock.json
generated
4
frontend/package-lock.json
generated
@ -1,11 +1,11 @@
|
||||
{
|
||||
"name": "grocery-tracker-frontend",
|
||||
"name": "product-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "grocery-tracker-frontend",
|
||||
"name": "product-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"dependencies": {
|
||||
"@types/node": "^20.10.5",
|
||||
|
||||
@ -1,5 +1,5 @@
|
||||
{
|
||||
"name": "grocery-tracker-frontend",
|
||||
"name": "product-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
|
||||
@ -7,9 +7,9 @@
|
||||
<meta name="theme-color" content="#000000" />
|
||||
<meta
|
||||
name="description"
|
||||
content="Track grocery prices and shopping events"
|
||||
content="Track product prices and shopping events"
|
||||
/>
|
||||
<title>Grocery Tracker</title>
|
||||
<title>Product Tracker</title>
|
||||
</head>
|
||||
<body>
|
||||
<noscript>You need to enable JavaScript to run this app.</noscript>
|
||||
|
||||
@ -1,6 +1,6 @@
|
||||
#!/bin/bash
|
||||
|
||||
echo "🎯 Setting up Grocery Tracker Frontend"
|
||||
echo "🎯 Setting up Product Tracker Frontend"
|
||||
echo "======================================"
|
||||
|
||||
# Check if Node.js is installed
|
||||
|
||||
@ -1,67 +1,64 @@
|
||||
import React from 'react';
|
||||
import { BrowserRouter as Router, Routes, Route, Link } from 'react-router-dom';
|
||||
import GroceryList from './components/GroceryList';
|
||||
import ShopList from './components/ShopList';
|
||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import { BrowserRouter as Router, Routes, Route, Link, useLocation } from 'react-router-dom';
|
||||
import Dashboard from './components/Dashboard';
|
||||
import ProductList from './components/ProductList';
|
||||
import ShopList from './components/ShopList';
|
||||
import ShoppingEventList from './components/ShoppingEventList';
|
||||
import ShoppingEventForm from './components/ShoppingEventForm';
|
||||
|
||||
function Navigation() {
|
||||
const location = useLocation();
|
||||
|
||||
const isActive = (path: string) => {
|
||||
return location.pathname === path;
|
||||
};
|
||||
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-50">
|
||||
{/* Navigation */}
|
||||
<nav className="bg-white shadow-lg">
|
||||
<div className="max-w-7xl mx-auto px-4">
|
||||
<div className="flex justify-between h-16">
|
||||
<div className="flex">
|
||||
<div className="flex-shrink-0 flex items-center">
|
||||
<h1 className="text-xl font-bold text-gray-800">
|
||||
🛒 Grocery Tracker
|
||||
</h1>
|
||||
</div>
|
||||
<div className="hidden sm:ml-6 sm:flex sm:space-x-8">
|
||||
<nav className="bg-blue-600 text-white p-4">
|
||||
<div className="container mx-auto flex justify-between items-center">
|
||||
<Link to="/" className="text-xl font-bold">
|
||||
Product Tracker
|
||||
</Link>
|
||||
<div className="space-x-4">
|
||||
<Link
|
||||
to="/"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
className={`px-3 py-2 rounded ${isActive('/') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Dashboard
|
||||
</Link>
|
||||
<Link
|
||||
to="/groceries"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
to="/products"
|
||||
className={`px-3 py-2 rounded ${isActive('/products') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Groceries
|
||||
Products
|
||||
</Link>
|
||||
<Link
|
||||
to="/shops"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
className={`px-3 py-2 rounded ${isActive('/shops') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Shops
|
||||
</Link>
|
||||
<Link
|
||||
to="/shopping-events"
|
||||
className="text-gray-500 hover:text-gray-700 inline-flex items-center px-1 pt-1 text-sm font-medium"
|
||||
className={`px-3 py-2 rounded ${isActive('/shopping-events') ? 'bg-blue-800' : 'hover:bg-blue-700'}`}
|
||||
>
|
||||
Shopping Events
|
||||
</Link>
|
||||
<Link
|
||||
to="/add-purchase"
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white inline-flex items-center px-3 py-2 text-sm font-medium rounded-md"
|
||||
>
|
||||
Add New Event
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
|
||||
{/* Main Content */}
|
||||
<main className="max-w-7xl mx-auto py-6 sm:px-6 lg:px-8">
|
||||
function App() {
|
||||
return (
|
||||
<Router>
|
||||
<div className="min-h-screen bg-gray-100">
|
||||
<Navigation />
|
||||
<main className="container mx-auto py-8 px-4">
|
||||
<Routes>
|
||||
<Route path="/" element={<Dashboard />} />
|
||||
<Route path="/groceries" element={<GroceryList />} />
|
||||
<Route path="/products" element={<ProductList />} />
|
||||
<Route path="/shops" element={<ShopList />} />
|
||||
<Route path="/shopping-events" element={<ShoppingEventList />} />
|
||||
<Route path="/shopping-events/:id/edit" element={<ShoppingEventForm />} />
|
||||
|
||||
@ -1,15 +1,15 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { groceryApi } from '../services/api';
|
||||
import { Grocery } from '../types';
|
||||
import { productApi } from '../services/api';
|
||||
import { Product } from '../types';
|
||||
|
||||
interface AddGroceryModalProps {
|
||||
interface AddProductModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onGroceryAdded: () => void;
|
||||
editGrocery?: Grocery | null;
|
||||
onProductAdded: () => void;
|
||||
editProduct?: Product | null;
|
||||
}
|
||||
|
||||
interface GroceryFormData {
|
||||
interface ProductFormData {
|
||||
name: string;
|
||||
category: string;
|
||||
organic: boolean;
|
||||
@ -17,8 +17,8 @@ interface GroceryFormData {
|
||||
weight_unit: string;
|
||||
}
|
||||
|
||||
const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGroceryAdded, editGrocery }) => {
|
||||
const [formData, setFormData] = useState<GroceryFormData>({
|
||||
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct }) => {
|
||||
const [formData, setFormData] = useState<ProductFormData>({
|
||||
name: '',
|
||||
category: '',
|
||||
organic: false,
|
||||
@ -37,16 +37,16 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
|
||||
// Populate form when editing
|
||||
useEffect(() => {
|
||||
if (editGrocery) {
|
||||
if (editProduct) {
|
||||
setFormData({
|
||||
name: editGrocery.name,
|
||||
category: editGrocery.category,
|
||||
organic: editGrocery.organic,
|
||||
weight: editGrocery.weight,
|
||||
weight_unit: editGrocery.weight_unit
|
||||
name: editProduct.name,
|
||||
category: editProduct.category,
|
||||
organic: editProduct.organic,
|
||||
weight: editProduct.weight,
|
||||
weight_unit: editProduct.weight_unit
|
||||
});
|
||||
} else {
|
||||
// Reset form for adding new grocery
|
||||
// Reset form for adding new product
|
||||
setFormData({
|
||||
name: '',
|
||||
category: '',
|
||||
@ -56,7 +56,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
});
|
||||
}
|
||||
setError('');
|
||||
}, [editGrocery, isOpen]);
|
||||
}, [editProduct, isOpen]);
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
@ -69,17 +69,17 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
setLoading(true);
|
||||
setError('');
|
||||
|
||||
const groceryData = {
|
||||
const productData = {
|
||||
...formData,
|
||||
weight: formData.weight || undefined
|
||||
};
|
||||
|
||||
if (editGrocery) {
|
||||
// Update existing grocery
|
||||
await groceryApi.update(editGrocery.id, groceryData);
|
||||
if (editProduct) {
|
||||
// Update existing product
|
||||
await productApi.update(editProduct.id, productData);
|
||||
} else {
|
||||
// Create new grocery
|
||||
await groceryApi.create(groceryData);
|
||||
// Create new product
|
||||
await productApi.create(productData);
|
||||
}
|
||||
|
||||
// Reset form
|
||||
@ -91,11 +91,11 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
weight_unit: 'piece'
|
||||
});
|
||||
|
||||
onGroceryAdded();
|
||||
onProductAdded();
|
||||
onClose();
|
||||
} catch (err) {
|
||||
setError(`Failed to ${editGrocery ? 'update' : 'add'} grocery. Please try again.`);
|
||||
console.error(`Error ${editGrocery ? 'updating' : 'adding'} grocery:`, err);
|
||||
setError(`Failed to ${editProduct ? 'update' : 'add'} product. Please try again.`);
|
||||
console.error(`Error ${editProduct ? 'updating' : 'adding'} product:`, err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
@ -119,7 +119,7 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
<div className="mt-3">
|
||||
<div className="flex justify-between items-center mb-4">
|
||||
<h3 className="text-lg font-medium text-gray-900">
|
||||
{editGrocery ? 'Edit Grocery' : 'Add New Grocery'}
|
||||
{editProduct ? 'Edit Product' : 'Add New Product'}
|
||||
</h3>
|
||||
<button
|
||||
onClick={onClose}
|
||||
@ -236,8 +236,8 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-md disabled:opacity-50 disabled:cursor-not-allowed"
|
||||
>
|
||||
{loading
|
||||
? (editGrocery ? 'Updating...' : 'Adding...')
|
||||
: (editGrocery ? 'Update Grocery' : 'Add Grocery')
|
||||
? (editProduct ? 'Updating...' : 'Adding...')
|
||||
: (editProduct ? 'Update Product' : 'Add Product')
|
||||
}
|
||||
</button>
|
||||
</div>
|
||||
@ -248,4 +248,4 @@ const AddGroceryModal: React.FC<AddGroceryModalProps> = ({ isOpen, onClose, onGr
|
||||
);
|
||||
};
|
||||
|
||||
export default AddGroceryModal;
|
||||
export default AddProductModal;
|
||||
@ -32,7 +32,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Dashboard</h1>
|
||||
<p className="text-gray-600">Welcome to your grocery tracker!</p>
|
||||
<p className="text-gray-600">Welcome to your product tracker!</p>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-4 gap-4">
|
||||
@ -102,7 +102,7 @@ const Dashboard: React.FC = () => {
|
||||
<div className="p-6">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<button
|
||||
onClick={() => navigate('/add-purchase')}
|
||||
onClick={() => navigate('/shopping-events')}
|
||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-blue-100 rounded-md mr-3">
|
||||
@ -117,7 +117,7 @@ const Dashboard: React.FC = () => {
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => navigate('/groceries?add=true')}
|
||||
onClick={() => navigate('/products?add=true')}
|
||||
className="flex items-center p-4 border border-gray-300 rounded-lg hover:bg-gray-50 transition-colors"
|
||||
>
|
||||
<div className="p-2 bg-green-100 rounded-md mr-3">
|
||||
@ -126,8 +126,8 @@ const Dashboard: React.FC = () => {
|
||||
</svg>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-medium text-gray-900">Add Grocery</p>
|
||||
<p className="text-sm text-gray-600">Add a new grocery item</p>
|
||||
<p className="font-medium text-gray-900">Add Product</p>
|
||||
<p className="text-sm text-gray-600">Add a new product item</p>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
@ -181,9 +181,9 @@ const Dashboard: React.FC = () => {
|
||||
<p className="text-sm text-gray-600 mt-1">
|
||||
{new Date(event.date).toLocaleDateString()}
|
||||
</p>
|
||||
{event.groceries.length > 0 && (
|
||||
{event.products.length > 0 && (
|
||||
<p className="text-sm text-gray-500 mt-1">
|
||||
{event.groceries.length} item{event.groceries.length !== 1 ? 's' : ''}
|
||||
{event.products.length} item{event.products.length !== 1 ? 's' : ''}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
@ -1,22 +1,22 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { Grocery } from '../types';
|
||||
import { groceryApi } from '../services/api';
|
||||
import AddGroceryModal from './AddGroceryModal';
|
||||
import { Product } from '../types';
|
||||
import { productApi } from '../services/api';
|
||||
import AddProductModal from './AddProductModal';
|
||||
import ConfirmDeleteModal from './ConfirmDeleteModal';
|
||||
|
||||
const GroceryList: React.FC = () => {
|
||||
const ProductList: React.FC = () => {
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState('');
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
const [editingGrocery, setEditingGrocery] = useState<Grocery | null>(null);
|
||||
const [deletingGrocery, setDeletingGrocery] = useState<Grocery | null>(null);
|
||||
const [editingProduct, setEditingProduct] = useState<Product | null>(null);
|
||||
const [deletingProduct, setDeletingProduct] = useState<Product | null>(null);
|
||||
const [deleteLoading, setDeleteLoading] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
fetchGroceries();
|
||||
fetchProducts();
|
||||
|
||||
// Check if we should auto-open the modal
|
||||
if (searchParams.get('add') === 'true') {
|
||||
@ -26,55 +26,55 @@ const GroceryList: React.FC = () => {
|
||||
}
|
||||
}, [searchParams, setSearchParams]);
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
const response = await productApi.getAll();
|
||||
setProducts(response.data);
|
||||
} catch (err) {
|
||||
setError('Failed to fetch groceries');
|
||||
console.error('Error fetching groceries:', err);
|
||||
setError('Failed to fetch products');
|
||||
console.error('Error fetching products:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleEdit = (grocery: Grocery) => {
|
||||
setEditingGrocery(grocery);
|
||||
const handleEdit = (product: Product) => {
|
||||
setEditingProduct(product);
|
||||
setIsModalOpen(true);
|
||||
};
|
||||
|
||||
const handleDelete = (grocery: Grocery) => {
|
||||
setDeletingGrocery(grocery);
|
||||
const handleDelete = (product: Product) => {
|
||||
setDeletingProduct(product);
|
||||
};
|
||||
|
||||
const confirmDelete = async () => {
|
||||
if (!deletingGrocery) return;
|
||||
if (!deletingProduct) return;
|
||||
|
||||
try {
|
||||
setDeleteLoading(true);
|
||||
await groceryApi.delete(deletingGrocery.id);
|
||||
setDeletingGrocery(null);
|
||||
fetchGroceries(); // Refresh the list
|
||||
await productApi.delete(deletingProduct.id);
|
||||
setDeletingProduct(null);
|
||||
fetchProducts(); // Refresh the list
|
||||
} catch (err) {
|
||||
console.error('Error deleting grocery:', err);
|
||||
setError('Failed to delete grocery. Please try again.');
|
||||
console.error('Error deleting product:', err);
|
||||
setError('Failed to delete product. Please try again.');
|
||||
} finally {
|
||||
setDeleteLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const handleGroceryAdded = () => {
|
||||
fetchGroceries(); // Refresh the list
|
||||
const handleProductAdded = () => {
|
||||
fetchProducts(); // Refresh the list
|
||||
};
|
||||
|
||||
const handleCloseModal = () => {
|
||||
setIsModalOpen(false);
|
||||
setEditingGrocery(null);
|
||||
setEditingProduct(null);
|
||||
};
|
||||
|
||||
const handleCloseDeleteModal = () => {
|
||||
setDeletingGrocery(null);
|
||||
setDeletingProduct(null);
|
||||
};
|
||||
|
||||
if (loading) {
|
||||
@ -88,15 +88,15 @@ const GroceryList: React.FC = () => {
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div className="flex justify-between items-center">
|
||||
<h1 className="text-2xl font-bold text-gray-900">Groceries</h1>
|
||||
<h1 className="text-2xl font-bold text-gray-900">Products</h1>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingGrocery(null);
|
||||
setEditingProduct(null);
|
||||
setIsModalOpen(true);
|
||||
}}
|
||||
className="bg-blue-500 hover:bg-blue-700 text-white font-bold py-2 px-4 rounded"
|
||||
>
|
||||
Add New Grocery
|
||||
Add New Product
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@ -107,13 +107,13 @@ const GroceryList: React.FC = () => {
|
||||
)}
|
||||
|
||||
<div className="bg-white shadow rounded-lg overflow-hidden">
|
||||
{groceries.length === 0 ? (
|
||||
{products.length === 0 ? (
|
||||
<div className="text-center py-12">
|
||||
<svg className="mx-auto h-12 w-12 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 48 48">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20 7l-8-4-8 4m16 0l-8 4m8-4v10l-8 4m0-10L4 7m8 4v10M4 7v10l8 4" />
|
||||
</svg>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No groceries</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first grocery item.</p>
|
||||
<h3 className="mt-2 text-sm font-medium text-gray-900">No products</h3>
|
||||
<p className="mt-1 text-sm text-gray-500">Get started by adding your first product item.</p>
|
||||
</div>
|
||||
) : (
|
||||
<table className="min-w-full divide-y divide-gray-200">
|
||||
@ -137,39 +137,39 @@ const GroceryList: React.FC = () => {
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="bg-white divide-y divide-gray-200">
|
||||
{groceries.map((grocery) => (
|
||||
<tr key={grocery.id} className="hover:bg-gray-50">
|
||||
{products.map((product) => (
|
||||
<tr key={product.id} className="hover:bg-gray-50">
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<div className="text-sm font-medium text-gray-900">
|
||||
{grocery.name} {grocery.organic ? '🌱' : ''}
|
||||
{product.name} {product.organic ? '🌱' : ''}
|
||||
</div>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className="px-2 inline-flex text-xs leading-5 font-semibold rounded-full bg-blue-100 text-blue-800">
|
||||
{grocery.category}
|
||||
{product.category}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
|
||||
{grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : '-'}
|
||||
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap">
|
||||
<span className={`px-2 inline-flex text-xs leading-5 font-semibold rounded-full ${
|
||||
grocery.organic
|
||||
product.organic
|
||||
? 'bg-green-100 text-green-800'
|
||||
: 'bg-gray-100 text-gray-800'
|
||||
}`}>
|
||||
{grocery.organic ? 'Organic' : 'Conventional'}
|
||||
{product.organic ? 'Organic' : 'Conventional'}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
|
||||
<button
|
||||
onClick={() => handleEdit(grocery)}
|
||||
onClick={() => handleEdit(product)}
|
||||
className="text-indigo-600 hover:text-indigo-900 mr-3"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(grocery)}
|
||||
onClick={() => handleDelete(product)}
|
||||
className="text-red-600 hover:text-red-900"
|
||||
>
|
||||
Delete
|
||||
@ -182,23 +182,23 @@ const GroceryList: React.FC = () => {
|
||||
)}
|
||||
</div>
|
||||
|
||||
<AddGroceryModal
|
||||
<AddProductModal
|
||||
isOpen={isModalOpen}
|
||||
onClose={handleCloseModal}
|
||||
onGroceryAdded={handleGroceryAdded}
|
||||
editGrocery={editingGrocery}
|
||||
onProductAdded={handleProductAdded}
|
||||
editProduct={editingProduct}
|
||||
/>
|
||||
|
||||
<ConfirmDeleteModal
|
||||
isOpen={!!deletingGrocery}
|
||||
isOpen={!!deletingProduct}
|
||||
onClose={handleCloseDeleteModal}
|
||||
onConfirm={confirmDelete}
|
||||
title="Delete Grocery"
|
||||
message={`Are you sure you want to delete "${deletingGrocery?.name}"? This action cannot be undone.`}
|
||||
title="Delete Product"
|
||||
message={`Are you sure you want to delete "${deletingProduct?.name}"? This action cannot be undone.`}
|
||||
isLoading={deleteLoading}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default GroceryList;
|
||||
export default ProductList;
|
||||
@ -1,13 +1,13 @@
|
||||
import React, { useState, useEffect } from 'react';
|
||||
import { useParams, useNavigate } from 'react-router-dom';
|
||||
import { Shop, Grocery, ShoppingEventCreate, GroceryInEvent } from '../types';
|
||||
import { shopApi, groceryApi, shoppingEventApi } from '../services/api';
|
||||
import { Shop, Product, ShoppingEventCreate, ProductInEvent } from '../types';
|
||||
import { shopApi, productApi, shoppingEventApi } from '../services/api';
|
||||
|
||||
const ShoppingEventForm: React.FC = () => {
|
||||
const { id } = useParams<{ id: string }>();
|
||||
const navigate = useNavigate();
|
||||
const [shops, setShops] = useState<Shop[]>([]);
|
||||
const [groceries, setGroceries] = useState<Grocery[]>([]);
|
||||
const [products, setProducts] = useState<Product[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [loadingEvent, setLoadingEvent] = useState(false);
|
||||
const [message, setMessage] = useState('');
|
||||
@ -19,12 +19,12 @@ const ShoppingEventForm: React.FC = () => {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
total_amount: undefined,
|
||||
notes: '',
|
||||
groceries: []
|
||||
products: []
|
||||
});
|
||||
|
||||
const [selectedGroceries, setSelectedGroceries] = useState<GroceryInEvent[]>([]);
|
||||
const [newGroceryItem, setNewGroceryItem] = useState<GroceryInEvent>({
|
||||
grocery_id: 0,
|
||||
const [selectedProducts, setSelectedProducts] = useState<ProductInEvent[]>([]);
|
||||
const [newProductItem, setNewProductItem] = useState<ProductInEvent>({
|
||||
product_id: 0,
|
||||
amount: 1,
|
||||
price: 0
|
||||
});
|
||||
@ -32,28 +32,28 @@ const ShoppingEventForm: React.FC = () => {
|
||||
|
||||
useEffect(() => {
|
||||
fetchShops();
|
||||
fetchGroceries();
|
||||
fetchProducts();
|
||||
if (isEditMode && id) {
|
||||
fetchShoppingEvent(parseInt(id));
|
||||
}
|
||||
}, [id, isEditMode]);
|
||||
|
||||
// Calculate total amount from selected groceries
|
||||
const calculateTotal = (groceries: GroceryInEvent[]): number => {
|
||||
const total = groceries.reduce((total, item) => total + (item.amount * item.price), 0);
|
||||
// Calculate total amount from selected products
|
||||
const calculateTotal = (products: ProductInEvent[]): number => {
|
||||
const total = products.reduce((total, item) => total + (item.amount * item.price), 0);
|
||||
return Math.round(total * 100) / 100; // Round to 2 decimal places to avoid floating-point errors
|
||||
};
|
||||
|
||||
// Update total amount whenever selectedGroceries changes
|
||||
// Update total amount whenever selectedProducts changes
|
||||
useEffect(() => {
|
||||
if (autoCalculate) {
|
||||
const calculatedTotal = calculateTotal(selectedGroceries);
|
||||
const calculatedTotal = calculateTotal(selectedProducts);
|
||||
setFormData(prev => ({
|
||||
...prev,
|
||||
total_amount: calculatedTotal > 0 ? calculatedTotal : undefined
|
||||
}));
|
||||
}
|
||||
}, [selectedGroceries, autoCalculate]);
|
||||
}, [selectedProducts, autoCalculate]);
|
||||
|
||||
const fetchShops = async () => {
|
||||
try {
|
||||
@ -64,12 +64,12 @@ const ShoppingEventForm: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const fetchGroceries = async () => {
|
||||
const fetchProducts = async () => {
|
||||
try {
|
||||
const response = await groceryApi.getAll();
|
||||
setGroceries(response.data);
|
||||
const response = await productApi.getAll();
|
||||
setProducts(response.data);
|
||||
} catch (error) {
|
||||
console.error('Error fetching groceries:', error);
|
||||
console.error('Error fetching products:', error);
|
||||
}
|
||||
};
|
||||
|
||||
@ -86,15 +86,15 @@ const ShoppingEventForm: React.FC = () => {
|
||||
formattedDate = event.date.split('T')[0];
|
||||
}
|
||||
|
||||
// Map groceries to the format we need
|
||||
const mappedGroceries = event.groceries.map(g => ({
|
||||
grocery_id: g.id,
|
||||
amount: g.amount,
|
||||
price: g.price
|
||||
// Map products to the format we need
|
||||
const mappedProducts = event.products.map(p => ({
|
||||
product_id: p.id,
|
||||
amount: p.amount,
|
||||
price: p.price
|
||||
}));
|
||||
|
||||
// Calculate the sum of all groceries
|
||||
const calculatedTotal = calculateTotal(mappedGroceries);
|
||||
// Calculate the sum of all products
|
||||
const calculatedTotal = calculateTotal(mappedProducts);
|
||||
|
||||
// Check if existing total matches calculated total (with small tolerance for floating point)
|
||||
const existingTotal = event.total_amount || 0;
|
||||
@ -105,10 +105,10 @@ const ShoppingEventForm: React.FC = () => {
|
||||
date: formattedDate,
|
||||
total_amount: event.total_amount,
|
||||
notes: event.notes || '',
|
||||
groceries: []
|
||||
products: []
|
||||
});
|
||||
|
||||
setSelectedGroceries(mappedGroceries);
|
||||
setSelectedProducts(mappedProducts);
|
||||
setAutoCalculate(totalMatches); // Enable auto-calc if totals match, disable if they don't
|
||||
} catch (error) {
|
||||
console.error('Error fetching shopping event:', error);
|
||||
@ -118,27 +118,27 @@ const ShoppingEventForm: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const addGroceryToEvent = () => {
|
||||
if (newGroceryItem.grocery_id > 0 && newGroceryItem.amount > 0 && newGroceryItem.price >= 0) {
|
||||
setSelectedGroceries([...selectedGroceries, { ...newGroceryItem }]);
|
||||
setNewGroceryItem({ grocery_id: 0, amount: 1, price: 0 });
|
||||
const addProductToEvent = () => {
|
||||
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
|
||||
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
|
||||
setNewProductItem({ product_id: 0, amount: 1, price: 0 });
|
||||
}
|
||||
};
|
||||
|
||||
const removeGroceryFromEvent = (index: number) => {
|
||||
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
|
||||
const removeProductFromEvent = (index: number) => {
|
||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const editGroceryFromEvent = (index: number) => {
|
||||
const groceryToEdit = selectedGroceries[index];
|
||||
// Load the grocery data into the input fields
|
||||
setNewGroceryItem({
|
||||
grocery_id: groceryToEdit.grocery_id,
|
||||
amount: groceryToEdit.amount,
|
||||
price: groceryToEdit.price
|
||||
const editProductFromEvent = (index: number) => {
|
||||
const productToEdit = selectedProducts[index];
|
||||
// Load the product data into the input fields
|
||||
setNewProductItem({
|
||||
product_id: productToEdit.product_id,
|
||||
amount: productToEdit.amount,
|
||||
price: productToEdit.price
|
||||
});
|
||||
// Remove the item from the selected list
|
||||
setSelectedGroceries(selectedGroceries.filter((_, i) => i !== index));
|
||||
setSelectedProducts(selectedProducts.filter((_, i) => i !== index));
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
@ -149,7 +149,7 @@ const ShoppingEventForm: React.FC = () => {
|
||||
try {
|
||||
const eventData = {
|
||||
...formData,
|
||||
groceries: selectedGroceries
|
||||
products: selectedProducts
|
||||
};
|
||||
|
||||
if (isEditMode) {
|
||||
@ -173,9 +173,9 @@ const ShoppingEventForm: React.FC = () => {
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
total_amount: undefined,
|
||||
notes: '',
|
||||
groceries: []
|
||||
products: []
|
||||
});
|
||||
setSelectedGroceries([]);
|
||||
setSelectedProducts([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Full error object:', error);
|
||||
@ -185,13 +185,13 @@ const ShoppingEventForm: React.FC = () => {
|
||||
}
|
||||
};
|
||||
|
||||
const getGroceryName = (id: number) => {
|
||||
const grocery = groceries.find(g => g.id === id);
|
||||
if (!grocery) return 'Unknown';
|
||||
const getProductName = (id: number) => {
|
||||
const product = products.find(p => p.id === id);
|
||||
if (!product) return 'Unknown';
|
||||
|
||||
const weightInfo = grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit;
|
||||
const organicEmoji = grocery.organic ? ' 🌱' : '';
|
||||
return `${grocery.name}${organicEmoji} ${weightInfo}`;
|
||||
const weightInfo = product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit;
|
||||
const organicEmoji = product.organic ? ' 🌱' : '';
|
||||
return `${product.name}${organicEmoji} ${weightInfo}`;
|
||||
};
|
||||
|
||||
if (loadingEvent) {
|
||||
@ -265,25 +265,25 @@ const ShoppingEventForm: React.FC = () => {
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Add Groceries Section */}
|
||||
{/* Add Products Section */}
|
||||
<div>
|
||||
<label className="block text-sm font-medium text-gray-700 mb-2">
|
||||
Add Groceries
|
||||
Add Products
|
||||
</label>
|
||||
<div className="flex space-x-2 mb-4">
|
||||
<div className="flex-1">
|
||||
<label className="block text-xs font-medium text-gray-700 mb-1">
|
||||
Grocery
|
||||
Product
|
||||
</label>
|
||||
<select
|
||||
value={newGroceryItem.grocery_id}
|
||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, grocery_id: parseInt(e.target.value)})}
|
||||
value={newProductItem.product_id}
|
||||
onChange={(e) => setNewProductItem({...newProductItem, product_id: parseInt(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
>
|
||||
<option value={0}>Select a grocery</option>
|
||||
{groceries.map(grocery => (
|
||||
<option key={grocery.id} value={grocery.id}>
|
||||
{grocery.name}{grocery.organic ? '🌱' : ''} ({grocery.category}) {grocery.weight ? `${grocery.weight}${grocery.weight_unit}` : grocery.weight_unit}
|
||||
<option value={0}>Select a product</option>
|
||||
{products.map(product => (
|
||||
<option key={product.id} value={product.id}>
|
||||
{product.name}{product.organic ? '🌱' : ''} ({product.category}) {product.weight ? `${product.weight}${product.weight_unit}` : product.weight_unit}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@ -297,8 +297,8 @@ const ShoppingEventForm: React.FC = () => {
|
||||
step="1"
|
||||
min="1"
|
||||
placeholder="1"
|
||||
value={newGroceryItem.amount}
|
||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, amount: parseFloat(e.target.value)})}
|
||||
value={newProductItem.amount}
|
||||
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
@ -311,15 +311,15 @@ const ShoppingEventForm: React.FC = () => {
|
||||
step="0.01"
|
||||
min="0"
|
||||
placeholder="0.00"
|
||||
value={newGroceryItem.price}
|
||||
onChange={(e) => setNewGroceryItem({...newGroceryItem, price: parseFloat(e.target.value)})}
|
||||
value={newProductItem.price}
|
||||
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
|
||||
className="w-full border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex items-end">
|
||||
<button
|
||||
type="button"
|
||||
onClick={addGroceryToEvent}
|
||||
onClick={addProductToEvent}
|
||||
className="bg-green-500 hover:bg-green-700 text-white px-4 py-2 rounded-md"
|
||||
>
|
||||
Add
|
||||
@ -327,15 +327,15 @@ const ShoppingEventForm: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Selected Groceries List */}
|
||||
{selectedGroceries.length > 0 && (
|
||||
{/* Selected Products List */}
|
||||
{selectedProducts.length > 0 && (
|
||||
<div className="bg-gray-50 rounded-md p-4">
|
||||
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
|
||||
{selectedGroceries.map((item, index) => (
|
||||
{selectedProducts.map((item, index) => (
|
||||
<div key={index} className="flex justify-between items-center py-2 border-b last:border-b-0">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm text-gray-900">
|
||||
{getGroceryName(item.grocery_id)}
|
||||
{getProductName(item.product_id)}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
|
||||
@ -344,14 +344,14 @@ const ShoppingEventForm: React.FC = () => {
|
||||
<div className="flex space-x-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => editGroceryFromEvent(index)}
|
||||
onClick={() => editProductFromEvent(index)}
|
||||
className="text-blue-500 hover:text-blue-700"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => removeGroceryFromEvent(index)}
|
||||
onClick={() => removeProductFromEvent(index)}
|
||||
className="text-red-500 hover:text-red-700"
|
||||
>
|
||||
Remove
|
||||
@ -431,7 +431,7 @@ const ShoppingEventForm: React.FC = () => {
|
||||
)}
|
||||
<button
|
||||
type="submit"
|
||||
disabled={loading || formData.shop_id === 0 || selectedGroceries.length === 0}
|
||||
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
|
||||
className={`px-4 py-2 text-sm font-medium text-white rounded-md disabled:opacity-50 disabled:cursor-not-allowed ${
|
||||
isEditMode
|
||||
? 'bg-blue-600 hover:bg-blue-700'
|
||||
|
||||
@ -109,17 +109,17 @@ const ShoppingEventList: React.FC = () => {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{event.groceries.length > 0 && (
|
||||
{event.products.length > 0 && (
|
||||
<div className="mb-4">
|
||||
<h4 className="text-sm font-medium text-gray-700 mb-2">Items Purchased:</h4>
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-2">
|
||||
{event.groceries.map((grocery) => (
|
||||
<div key={grocery.id} className="bg-gray-50 rounded px-3 py-2">
|
||||
{event.products.map((product) => (
|
||||
<div key={product.id} className="bg-gray-50 rounded px-3 py-2">
|
||||
<div className="text-sm text-gray-900">
|
||||
{grocery.name} {grocery.organic ? '🌱' : ''}
|
||||
{product.name} {product.organic ? '🌱' : ''}
|
||||
</div>
|
||||
<div className="text-xs text-gray-600">
|
||||
{grocery.amount} × ${grocery.price.toFixed(2)} = ${(grocery.amount * grocery.price).toFixed(2)}
|
||||
{product.amount} × ${product.price.toFixed(2)} = ${(product.amount * product.price).toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
@ -1,23 +1,34 @@
|
||||
import axios from 'axios';
|
||||
import { Grocery, GroceryCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
|
||||
import { Product, ProductCreate, Shop, ShopCreate, ShoppingEvent, ShoppingEventCreate } from '../types';
|
||||
|
||||
const BASE_URL = 'http://localhost:8000';
|
||||
const API_BASE_URL = 'http://localhost:8000';
|
||||
|
||||
const api = axios.create({
|
||||
baseURL: BASE_URL,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
});
|
||||
const api = {
|
||||
get: <T>(url: string): Promise<{ data: T }> =>
|
||||
fetch(`${API_BASE_URL}${url}`).then(res => res.json()).then(data => ({ data })),
|
||||
post: <T>(url: string, body: any): Promise<{ data: T }> =>
|
||||
fetch(`${API_BASE_URL}${url}`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(res => res.json()).then(data => ({ data })),
|
||||
put: <T>(url: string, body: any): Promise<{ data: T }> =>
|
||||
fetch(`${API_BASE_URL}${url}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}).then(res => res.json()).then(data => ({ data })),
|
||||
delete: (url: string): Promise<void> =>
|
||||
fetch(`${API_BASE_URL}${url}`, { method: 'DELETE' }).then(() => {}),
|
||||
};
|
||||
|
||||
// Grocery API functions
|
||||
export const groceryApi = {
|
||||
getAll: () => api.get<Grocery[]>('/groceries/'),
|
||||
getById: (id: number) => api.get<Grocery>(`/groceries/${id}`),
|
||||
create: (grocery: GroceryCreate) => api.post<Grocery>('/groceries/', grocery),
|
||||
update: (id: number, grocery: Partial<GroceryCreate>) =>
|
||||
api.put<Grocery>(`/groceries/${id}`, grocery),
|
||||
delete: (id: number) => api.delete(`/groceries/${id}`),
|
||||
// Product API functions
|
||||
export const productApi = {
|
||||
getAll: () => api.get<Product[]>('/products/'),
|
||||
getById: (id: number) => api.get<Product>(`/products/${id}`),
|
||||
create: (product: ProductCreate) => api.post<Product>('/products/', product),
|
||||
update: (id: number, product: Partial<ProductCreate>) =>
|
||||
api.put<Product>(`/products/${id}`, product),
|
||||
delete: (id: number) => api.delete(`/products/${id}`),
|
||||
};
|
||||
|
||||
// Shop API functions
|
||||
@ -34,8 +45,7 @@ export const shopApi = {
|
||||
export const shoppingEventApi = {
|
||||
getAll: () => api.get<ShoppingEvent[]>('/shopping-events/'),
|
||||
getById: (id: number) => api.get<ShoppingEvent>(`/shopping-events/${id}`),
|
||||
create: (event: ShoppingEventCreate) =>
|
||||
api.post<ShoppingEvent>('/shopping-events/', event),
|
||||
create: (event: ShoppingEventCreate) => api.post<ShoppingEvent>('/shopping-events/', event),
|
||||
update: (id: number, event: ShoppingEventCreate) =>
|
||||
api.put<ShoppingEvent>(`/shopping-events/${id}`, event),
|
||||
delete: (id: number) => api.delete(`/shopping-events/${id}`),
|
||||
|
||||
@ -1,4 +1,4 @@
|
||||
export interface Grocery {
|
||||
export interface Product {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
@ -9,7 +9,7 @@ export interface Grocery {
|
||||
updated_at?: string;
|
||||
}
|
||||
|
||||
export interface GroceryCreate {
|
||||
export interface ProductCreate {
|
||||
name: string;
|
||||
category: string;
|
||||
organic: boolean;
|
||||
@ -32,13 +32,13 @@ export interface ShopCreate {
|
||||
address?: string | null;
|
||||
}
|
||||
|
||||
export interface GroceryInEvent {
|
||||
grocery_id: number;
|
||||
export interface ProductInEvent {
|
||||
product_id: number;
|
||||
amount: number;
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface GroceryWithEventData {
|
||||
export interface ProductWithEventData {
|
||||
id: number;
|
||||
name: string;
|
||||
category: string;
|
||||
@ -58,7 +58,7 @@ export interface ShoppingEvent {
|
||||
created_at: string;
|
||||
updated_at?: string;
|
||||
shop: Shop;
|
||||
groceries: GroceryWithEventData[];
|
||||
products: ProductWithEventData[];
|
||||
}
|
||||
|
||||
export interface ShoppingEventCreate {
|
||||
@ -66,7 +66,7 @@ export interface ShoppingEventCreate {
|
||||
date?: string;
|
||||
total_amount?: number;
|
||||
notes?: string;
|
||||
groceries: GroceryInEvent[];
|
||||
products: ProductInEvent[];
|
||||
}
|
||||
|
||||
export interface CategoryStats {
|
||||
|
||||
20
package.json
20
package.json
@ -1,15 +1,21 @@
|
||||
{
|
||||
"name": "grocery-tracker-frontend",
|
||||
"version": "1.0.0",
|
||||
"description": "React frontend for grocery price tracking application",
|
||||
"private": true,
|
||||
"name": "product-tracker-frontend",
|
||||
"version": "0.1.0",
|
||||
"description": "React frontend for product price tracking application",
|
||||
"main": "index.js",
|
||||
"scripts": {
|
||||
"dev": "cd frontend && npm run dev",
|
||||
"build": "cd frontend && npm run build",
|
||||
"install:frontend": "cd frontend && npm install",
|
||||
"setup": "npm run install:frontend"
|
||||
"setup": "npm run install:frontend",
|
||||
"test": "echo \"Error: no test specified\" && exit 1"
|
||||
},
|
||||
"keywords": ["grocery", "price-tracking", "shopping", "react", "fastapi", "python"],
|
||||
"keywords": ["product", "price-tracking", "shopping", "react", "fastapi", "python"],
|
||||
"author": "",
|
||||
"license": "MIT"
|
||||
"license": "ISC",
|
||||
"dependencies": {},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": ""
|
||||
}
|
||||
}
|
||||
Loading…
x
Reference in New Issue
Block a user