Initial Version
This commit is contained in:
38
backend/database.py
Normal file
38
backend/database.py
Normal file
@@ -0,0 +1,38 @@
|
||||
from sqlalchemy import create_engine
|
||||
from sqlalchemy.orm import sessionmaker
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
import os
|
||||
from dotenv import load_dotenv
|
||||
|
||||
load_dotenv()
|
||||
|
||||
# Get database URL from environment with SQLite fallback for development
|
||||
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"
|
||||
print("🔄 Using SQLite database for development")
|
||||
else:
|
||||
print(f"🐘 Using PostgreSQL database")
|
||||
|
||||
# Configure engine based on database type
|
||||
if DATABASE_URL.startswith("sqlite"):
|
||||
engine = create_engine(
|
||||
DATABASE_URL,
|
||||
connect_args={"check_same_thread": False} # Needed for SQLite
|
||||
)
|
||||
else:
|
||||
engine = create_engine(DATABASE_URL)
|
||||
|
||||
SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine)
|
||||
|
||||
Base = declarative_base()
|
||||
|
||||
# Dependency to get database session
|
||||
def get_db():
|
||||
db = SessionLocal()
|
||||
try:
|
||||
yield db
|
||||
finally:
|
||||
db.close()
|
||||
14
backend/env.example
Normal file
14
backend/env.example
Normal file
@@ -0,0 +1,14 @@
|
||||
# Database Configuration
|
||||
# Option 1: PostgreSQL (for production)
|
||||
# DATABASE_URL=postgresql://username:password@localhost:5432/grocery_tracker
|
||||
|
||||
# Option 2: SQLite (for development - default if DATABASE_URL is not set)
|
||||
# DATABASE_URL=sqlite:///./grocery_tracker.db
|
||||
|
||||
# Authentication (optional for basic setup)
|
||||
SECRET_KEY=your-secret-key-here
|
||||
ALGORITHM=HS256
|
||||
ACCESS_TOKEN_EXPIRE_MINUTES=30
|
||||
|
||||
# Development settings
|
||||
DEBUG=True
|
||||
160
backend/main.py
Normal file
160
backend/main.py
Normal file
@@ -0,0 +1,160 @@
|
||||
from fastapi import FastAPI, Depends, HTTPException, status
|
||||
from fastapi.middleware.cors import CORSMiddleware
|
||||
from sqlalchemy.orm import Session
|
||||
from typing import List
|
||||
import models, schemas
|
||||
from database import engine, get_db
|
||||
|
||||
# Create database tables
|
||||
models.Base.metadata.create_all(bind=engine)
|
||||
|
||||
app = FastAPI(
|
||||
title="Grocery Tracker API",
|
||||
description="API for tracking grocery prices and shopping events",
|
||||
version="1.0.0"
|
||||
)
|
||||
|
||||
# CORS middleware for React frontend
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
allow_origins=["http://localhost:3000", "http://localhost:5173"], # React dev servers
|
||||
allow_credentials=True,
|
||||
allow_methods=["*"],
|
||||
allow_headers=["*"],
|
||||
)
|
||||
|
||||
# Root endpoint
|
||||
@app.get("/")
|
||||
def read_root():
|
||||
return {"message": "Grocery 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)
|
||||
db.commit()
|
||||
db.refresh(db_grocery)
|
||||
return db_grocery
|
||||
|
||||
@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("/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.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")
|
||||
|
||||
update_data = grocery_update.dict(exclude_unset=True)
|
||||
for field, value in update_data.items():
|
||||
setattr(grocery, field, value)
|
||||
|
||||
db.commit()
|
||||
db.refresh(grocery)
|
||||
return grocery
|
||||
|
||||
@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")
|
||||
|
||||
db.delete(grocery)
|
||||
db.commit()
|
||||
return {"message": "Grocery deleted successfully"}
|
||||
|
||||
# Shop endpoints
|
||||
@app.post("/shops/", response_model=schemas.Shop)
|
||||
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):
|
||||
db_shop = models.Shop(**shop.dict())
|
||||
db.add(db_shop)
|
||||
db.commit()
|
||||
db.refresh(db_shop)
|
||||
return db_shop
|
||||
|
||||
@app.get("/shops/", response_model=List[schemas.Shop])
|
||||
def read_shops(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
shops = db.query(models.Shop).offset(skip).limit(limit).all()
|
||||
return shops
|
||||
|
||||
@app.get("/shops/{shop_id}", response_model=schemas.Shop)
|
||||
def read_shop(shop_id: int, db: Session = Depends(get_db)):
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
return shop
|
||||
|
||||
# Shopping Event endpoints
|
||||
@app.post("/shopping-events/", response_model=schemas.ShoppingEventResponse)
|
||||
def create_shopping_event(event: schemas.ShoppingEventCreate, db: Session = Depends(get_db)):
|
||||
# Verify shop exists
|
||||
shop = db.query(models.Shop).filter(models.Shop.id == event.shop_id).first()
|
||||
if shop is None:
|
||||
raise HTTPException(status_code=404, detail="Shop not found")
|
||||
|
||||
# Create shopping event
|
||||
db_event = models.ShoppingEvent(
|
||||
shop_id=event.shop_id,
|
||||
date=event.date,
|
||||
total_amount=event.total_amount,
|
||||
notes=event.notes
|
||||
)
|
||||
db.add(db_event)
|
||||
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")
|
||||
|
||||
# Insert into association table
|
||||
db.execute(
|
||||
models.shopping_event_groceries.insert().values(
|
||||
shopping_event_id=db_event.id,
|
||||
grocery_id=grocery_item.grocery_id,
|
||||
amount=grocery_item.amount
|
||||
)
|
||||
)
|
||||
|
||||
db.commit()
|
||||
db.refresh(db_event)
|
||||
return db_event
|
||||
|
||||
@app.get("/shopping-events/", response_model=List[schemas.ShoppingEventResponse])
|
||||
def read_shopping_events(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)):
|
||||
events = db.query(models.ShoppingEvent).offset(skip).limit(limit).all()
|
||||
return events
|
||||
|
||||
@app.get("/shopping-events/{event_id}", response_model=schemas.ShoppingEventResponse)
|
||||
def read_shopping_event(event_id: int, db: Session = Depends(get_db)):
|
||||
event = db.query(models.ShoppingEvent).filter(models.ShoppingEvent.id == event_id).first()
|
||||
if event is None:
|
||||
raise HTTPException(status_code=404, detail="Shopping event not found")
|
||||
return event
|
||||
|
||||
# Statistics endpoints
|
||||
@app.get("/stats/categories", response_model=List[schemas.CategoryStats])
|
||||
def get_category_stats(db: Session = Depends(get_db)):
|
||||
# This would need more complex SQL query - placeholder for now
|
||||
return []
|
||||
|
||||
@app.get("/stats/shops", response_model=List[schemas.ShopStats])
|
||||
def get_shop_stats(db: Session = Depends(get_db)):
|
||||
# This would need more complex SQL query - placeholder for now
|
||||
return []
|
||||
|
||||
if __name__ == "__main__":
|
||||
import uvicorn
|
||||
uvicorn.run(app, host="0.0.0.0", port=8000)
|
||||
58
backend/models.py
Normal file
58
backend/models.py
Normal file
@@ -0,0 +1,58 @@
|
||||
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Table
|
||||
from sqlalchemy.ext.declarative import declarative_base
|
||||
from sqlalchemy.orm import relationship
|
||||
from sqlalchemy.sql import func
|
||||
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',
|
||||
Base.metadata,
|
||||
Column('shopping_event_id', Integer, ForeignKey('shopping_events.id'), primary_key=True),
|
||||
Column('grocery_id', Integer, ForeignKey('groceries.id'), primary_key=True),
|
||||
Column('amount', Float, nullable=False) # Amount of this grocery bought in this event
|
||||
)
|
||||
|
||||
class Grocery(Base):
|
||||
__tablename__ = "groceries"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
price = Column(Float, nullable=False)
|
||||
category = Column(String, nullable=False)
|
||||
organic = Column(Boolean, default=False)
|
||||
weight = Column(Float, nullable=True) # in grams or kg
|
||||
weight_unit = Column(String, default="piece") # "g", "kg", "ml", "l", "piece"
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
|
||||
|
||||
# Relationships
|
||||
shopping_events = relationship("ShoppingEvent", secondary=shopping_event_groceries, back_populates="groceries")
|
||||
|
||||
class Shop(Base):
|
||||
__tablename__ = "shops"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
name = Column(String, nullable=False, index=True)
|
||||
city = Column(String, nullable=False)
|
||||
address = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
shopping_events = relationship("ShoppingEvent", back_populates="shop")
|
||||
|
||||
class ShoppingEvent(Base):
|
||||
__tablename__ = "shopping_events"
|
||||
|
||||
id = Column(Integer, primary_key=True, index=True)
|
||||
shop_id = Column(Integer, ForeignKey("shops.id"), nullable=False)
|
||||
date = Column(DateTime(timezone=True), nullable=False, default=datetime.utcnow)
|
||||
total_amount = Column(Float, nullable=True) # Total cost of the shopping event
|
||||
notes = Column(String, nullable=True)
|
||||
created_at = Column(DateTime(timezone=True), server_default=func.now())
|
||||
|
||||
# Relationships
|
||||
shop = relationship("Shop", back_populates="shopping_events")
|
||||
groceries = relationship("Grocery", secondary=shopping_event_groceries, back_populates="shopping_events")
|
||||
13
backend/requirements.txt
Normal file
13
backend/requirements.txt
Normal file
@@ -0,0 +1,13 @@
|
||||
fastapi>=0.104.1
|
||||
uvicorn[standard]>=0.24.0
|
||||
sqlalchemy>=2.0.23
|
||||
psycopg[binary]>=3.2.2
|
||||
alembic>=1.12.1
|
||||
pydantic>=2.5.0
|
||||
python-jose[cryptography]>=3.3.0
|
||||
passlib[bcrypt]>=1.7.4
|
||||
python-multipart>=0.0.6
|
||||
python-dotenv>=1.0.0
|
||||
pytest>=7.4.3
|
||||
pytest-asyncio>=0.21.1
|
||||
httpx>=0.25.2
|
||||
73
backend/run_dev.py
Normal file
73
backend/run_dev.py
Normal file
@@ -0,0 +1,73 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Development script to run the FastAPI server with database setup
|
||||
"""
|
||||
import os
|
||||
import sys
|
||||
import subprocess
|
||||
from pathlib import Path
|
||||
|
||||
def run_command(command, cwd=None):
|
||||
"""Run a command and return True if successful"""
|
||||
try:
|
||||
result = subprocess.run(command, shell=True, cwd=cwd, check=True)
|
||||
return result.returncode == 0
|
||||
except subprocess.CalledProcessError as e:
|
||||
print(f"Error running command: {command}")
|
||||
print(f"Error: {e}")
|
||||
return False
|
||||
|
||||
def main():
|
||||
# Ensure we're in the backend directory
|
||||
backend_dir = Path(__file__).parent
|
||||
os.chdir(backend_dir)
|
||||
|
||||
print("🍃 Starting Grocery Tracker Backend Development Server")
|
||||
print("=" * 50)
|
||||
|
||||
# Check if virtual environment exists
|
||||
venv_path = backend_dir / "venv"
|
||||
if not venv_path.exists():
|
||||
print("📦 Creating virtual environment...")
|
||||
if not run_command("python3 -m venv venv"):
|
||||
print("❌ Failed to create virtual environment")
|
||||
sys.exit(1)
|
||||
|
||||
# Install dependencies
|
||||
print("📦 Installing dependencies...")
|
||||
pip_cmd = "venv/bin/pip" if os.name != 'nt' else "venv\\Scripts\\pip"
|
||||
if not run_command(f"{pip_cmd} install -r requirements.txt"):
|
||||
print("❌ Failed to install dependencies")
|
||||
sys.exit(1)
|
||||
|
||||
# Create database tables
|
||||
print("🗄️ Creating database tables...")
|
||||
python_cmd = "venv/bin/python" if os.name != 'nt' else "venv\\Scripts\\python"
|
||||
create_tables_script = """
|
||||
from models import Base
|
||||
from database import engine
|
||||
Base.metadata.create_all(bind=engine)
|
||||
print("Database tables created successfully!")
|
||||
"""
|
||||
|
||||
with open("create_tables.py", "w") as f:
|
||||
f.write(create_tables_script)
|
||||
|
||||
if not run_command(f"{python_cmd} create_tables.py"):
|
||||
print("⚠️ Database tables creation failed (this is normal if database doesn't exist yet)")
|
||||
|
||||
# Clean up
|
||||
os.remove("create_tables.py")
|
||||
|
||||
# Start the server
|
||||
print("🚀 Starting FastAPI server...")
|
||||
print("📍 API will be available at: http://localhost:8000")
|
||||
print("📚 API docs will be available at: http://localhost:8000/docs")
|
||||
print("🛑 Press Ctrl+C to stop the server")
|
||||
print("=" * 50)
|
||||
|
||||
uvicorn_cmd = "venv/bin/uvicorn" if os.name != 'nt' else "venv\\Scripts\\uvicorn"
|
||||
run_command(f"{uvicorn_cmd} main:app --reload --host 0.0.0.0 --port 8000")
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
||||
95
backend/schemas.py
Normal file
95
backend/schemas.py
Normal file
@@ -0,0 +1,95 @@
|
||||
from pydantic import BaseModel, Field
|
||||
from typing import Optional, List
|
||||
from datetime import datetime
|
||||
|
||||
# Base schemas
|
||||
class GroceryBase(BaseModel):
|
||||
name: str
|
||||
price: float = Field(..., gt=0)
|
||||
category: str
|
||||
organic: bool = False
|
||||
weight: Optional[float] = None
|
||||
weight_unit: str = "g"
|
||||
|
||||
class GroceryCreate(GroceryBase):
|
||||
pass
|
||||
|
||||
class GroceryUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
price: Optional[float] = Field(None, gt=0)
|
||||
category: Optional[str] = None
|
||||
organic: Optional[bool] = None
|
||||
weight: Optional[float] = None
|
||||
weight_unit: Optional[str] = None
|
||||
|
||||
class Grocery(GroceryBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
updated_at: Optional[datetime] = None
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Shop schemas
|
||||
class ShopBase(BaseModel):
|
||||
name: str
|
||||
city: str
|
||||
address: Optional[str] = None
|
||||
|
||||
class ShopCreate(ShopBase):
|
||||
pass
|
||||
|
||||
class ShopUpdate(BaseModel):
|
||||
name: Optional[str] = None
|
||||
city: Optional[str] = None
|
||||
address: Optional[str] = None
|
||||
|
||||
class Shop(ShopBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Shopping Event schemas
|
||||
class GroceryInEvent(BaseModel):
|
||||
grocery_id: int
|
||||
amount: float = Field(..., gt=0)
|
||||
|
||||
class ShoppingEventBase(BaseModel):
|
||||
shop_id: int
|
||||
date: Optional[datetime] = None
|
||||
total_amount: Optional[float] = Field(None, ge=0)
|
||||
notes: Optional[str] = None
|
||||
|
||||
class ShoppingEventCreate(ShoppingEventBase):
|
||||
groceries: List[GroceryInEvent] = []
|
||||
|
||||
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
|
||||
|
||||
class ShoppingEventResponse(ShoppingEventBase):
|
||||
id: int
|
||||
created_at: datetime
|
||||
shop: Shop
|
||||
groceries: List[Grocery] = []
|
||||
|
||||
class Config:
|
||||
from_attributes = True
|
||||
|
||||
# Statistics schemas
|
||||
class CategoryStats(BaseModel):
|
||||
category: str
|
||||
total_spent: float
|
||||
item_count: int
|
||||
avg_price: float
|
||||
|
||||
class ShopStats(BaseModel):
|
||||
shop_name: str
|
||||
total_spent: float
|
||||
visit_count: int
|
||||
avg_per_visit: float
|
||||
Reference in New Issue
Block a user