diff --git a/DOCKER_DEPLOYMENT.md b/DOCKER_DEPLOYMENT.md new file mode 100644 index 0000000..daae38e --- /dev/null +++ b/DOCKER_DEPLOYMENT.md @@ -0,0 +1,370 @@ +# Docker Deployment Guide + +This guide explains how to deploy your Groceries/Product Tracker application using Docker Compose. + +## Prerequisites + +- Docker Engine 20.10+ +- Docker Compose 2.0+ +- Git (for cloning from your Gitea repository) + +## Quick Start + +1. **Clone your repository from Gitea:** + ```bash + git clone + cd groceries + ``` + +2. **Set up environment variables:** + ```bash + cp docker.env.example .env + # Edit .env with your production values + nano .env + ``` + +3. **Build and start all services:** + ```bash + docker-compose up -d + ``` + +4. **Access your application:** + - Frontend: http://localhost + - Backend API: http://localhost:8000 + - API Documentation: http://localhost:8000/docs + +## Architecture + +The Docker Compose setup includes: + +- **PostgreSQL Database** (port 5432) +- **FastAPI Backend** (port 8000) +- **React Frontend with Nginx** (port 80) + +## Environment Configuration + +### Required Environment Variables + +Create a `.env` file in the root directory: + +```bash +# Database Configuration +DB_PASSWORD=your_secure_database_password + +# Backend Configuration +SECRET_KEY=your-super-secret-key-for-jwt-tokens-make-it-very-long-and-random +DEBUG=False +``` + +### Optional Configuration + +You can override default ports by adding to your `.env` file: + +```bash +# Custom ports (optional) +FRONTEND_PORT=3000 +BACKEND_PORT=8001 +DB_PORT=5433 +``` + +Then update the docker-compose.yml ports section accordingly. + +## Deployment Commands + +### Development Deployment + +```bash +# Start all services +docker-compose up + +# Start in background +docker-compose up -d + +# View logs +docker-compose logs -f + +# View logs for specific service +docker-compose logs -f backend +``` + +### Production Deployment + +1. **Set production environment:** + ```bash + export COMPOSE_FILE=docker-compose.yml + export COMPOSE_PROJECT_NAME=groceries_prod + ``` + +2. **Use production environment file:** + ```bash + cp docker.env.example .env.prod + # Edit .env.prod with production values + docker-compose --env-file .env.prod up -d + ``` + +3. **Run database migrations:** + ```bash + docker-compose exec backend alembic upgrade head + ``` + +## Database Management + +### Initial Setup + +The database will be automatically created when you first start the services. To run migrations: + +```bash +# Run migrations +docker-compose exec backend alembic upgrade head + +# Create new migration (if you've made model changes) +docker-compose exec backend alembic revision --autogenerate -m "Description" +``` + +### Backup and Restore + +```bash +# Backup database +docker-compose exec db pg_dump -U product_user product_tracker > backup.sql + +# Restore database +docker-compose exec -T db psql -U product_user product_tracker < backup.sql +``` + +## Service Management + +### Individual Service Control + +```bash +# Restart specific service +docker-compose restart backend + +# Rebuild and restart service +docker-compose up -d --build backend + +# Scale services (if needed) +docker-compose up -d --scale backend=2 +``` + +### Updating the Application + +```bash +# Pull latest code from Gitea +git pull origin main + +# Rebuild and restart services +docker-compose down +docker-compose up -d --build +``` + +## Monitoring and Logs + +### View Logs + +```bash +# All services +docker-compose logs -f + +# Specific service +docker-compose logs -f backend +docker-compose logs -f frontend +docker-compose logs -f db + +# Last 100 lines +docker-compose logs --tail=100 backend +``` + +### Health Checks + +The services include health checks. Check status: + +```bash +docker-compose ps +``` + +### Resource Usage + +```bash +# View resource usage +docker stats + +# View specific containers +docker stats groceries_backend groceries_frontend groceries_db +``` + +## Troubleshooting + +### Common Issues + +1. **Port conflicts:** + ```bash + # Check what's using the port + lsof -i :80 + lsof -i :8000 + lsof -i :5432 + + # Change ports in .env file or docker-compose.yml + ``` + +2. **Database connection issues:** + ```bash + # Check database logs + docker-compose logs db + + # Test database connection + docker-compose exec db psql -U product_user -d product_tracker -c "SELECT 1;" + ``` + +3. **Backend not starting:** + ```bash + # Check backend logs + docker-compose logs backend + + # Exec into container to debug + docker-compose exec backend bash + ``` + +4. **Frontend not loading:** + ```bash + # Check nginx logs + docker-compose logs frontend + + # Verify build completed successfully + docker-compose exec frontend ls -la /usr/share/nginx/html + ``` + +### Reset Everything + +```bash +# Stop and remove all containers, networks, and volumes +docker-compose down -v + +# Remove all images +docker-compose down --rmi all + +# Start fresh +docker-compose up -d --build +``` + +## Security Considerations + +### Production Security + +1. **Change default passwords:** + - Update `DB_PASSWORD` in `.env` + - Generate a strong `SECRET_KEY` + +2. **Use HTTPS:** + - Add SSL certificates + - Configure nginx for HTTPS + - Update docker-compose.yml with SSL configuration + +3. **Network security:** + - Remove port mappings for internal services + - Use Docker secrets for sensitive data + - Configure firewall rules + +4. **Regular updates:** + - Keep Docker images updated + - Update application dependencies + - Monitor security advisories + +### Example Production docker-compose.yml + +For production, consider: + +```yaml +# Remove port mappings for internal services +services: + db: + # Remove: ports: - "5432:5432" + + backend: + # Remove: ports: - "8000:8000" + + frontend: + ports: + - "443:443" # HTTPS + - "80:80" # HTTP redirect +``` + +## Gitea Integration + +### Automated Deployment + +You can set up automated deployment from your Gitea repository: + +1. **Create a webhook** in your Gitea repository +2. **Set up a deployment script** on your server: + +```bash +#!/bin/bash +# deploy.sh +cd /path/to/your/app +git pull origin main +docker-compose down +docker-compose up -d --build +``` + +3. **Configure webhook** to trigger the deployment script + +### CI/CD Pipeline + +Consider setting up Gitea Actions or external CI/CD: + +```yaml +# .gitea/workflows/deploy.yml +name: Deploy +on: + push: + branches: [main] +jobs: + deploy: + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + - name: Deploy to server + run: | + # Your deployment commands + docker-compose up -d --build +``` + +## Performance Optimization + +### Production Optimizations + +1. **Use multi-stage builds** (already implemented) +2. **Enable gzip compression** in nginx +3. **Add caching headers** (already configured) +4. **Use connection pooling** for database +5. **Configure resource limits:** + +```yaml +services: + backend: + deploy: + resources: + limits: + memory: 512M + cpus: '0.5' +``` + +## Backup Strategy + +### Automated Backups + +```bash +#!/bin/bash +# backup.sh +DATE=$(date +%Y%m%d_%H%M%S) +docker-compose exec -T db pg_dump -U product_user product_tracker > "backup_${DATE}.sql" +# Upload to cloud storage or remote location +``` + +Set up a cron job: +```bash +# Run daily at 2 AM +0 2 * * * /path/to/backup.sh +``` + +This setup provides a robust, production-ready deployment of your groceries application using Docker Compose! \ No newline at end of file diff --git a/backend/Dockerfile b/backend/Dockerfile new file mode 100644 index 0000000..7da2496 --- /dev/null +++ b/backend/Dockerfile @@ -0,0 +1,28 @@ +FROM python:3.11-slim + +WORKDIR /app + +# Install system dependencies +RUN apt-get update && apt-get install -y \ + gcc \ + curl \ + && rm -rf /var/lib/apt/lists/* + +# Copy requirements first for better caching +COPY requirements.txt . + +# Install Python dependencies +RUN pip install --no-cache-dir -r requirements.txt + +# Copy application code +COPY . . + +# Create a non-root user +RUN useradd -m -u 1000 appuser && chown -R appuser:appuser /app +USER appuser + +# Expose port +EXPOSE 8000 + +# Command to run the application +CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8000"] \ No newline at end of file diff --git a/backend/main.py b/backend/main.py index 7084690..83b2a2e 100644 --- a/backend/main.py +++ b/backend/main.py @@ -31,13 +31,16 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s text(""" SELECT p.id, p.name, p.organic, p.weight, p.weight_unit, sep.amount, sep.price, - g.id as grocery_id, g.name as grocery_name, g.category as grocery_category, + g.id as grocery_id, g.name as grocery_name, g.created_at as grocery_created_at, g.updated_at as grocery_updated_at, + gc.id as category_id, gc.name as category_name, + gc.created_at as category_created_at, gc.updated_at as category_updated_at, b.id as brand_id, b.name as brand_name, b.created_at as brand_created_at, b.updated_at as brand_updated_at FROM products p JOIN shopping_event_products sep ON p.id = sep.product_id JOIN groceries g ON p.grocery_id = g.id + JOIN grocery_categories gc ON g.category_id = gc.id LEFT JOIN brands b ON p.brand_id = b.id WHERE sep.shopping_event_id = :event_id """), @@ -47,12 +50,20 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s # Convert to ProductWithEventData objects products_with_data = [] for row in product_data: + category = schemas.GroceryCategory( + id=row.category_id, + name=row.category_name, + created_at=row.category_created_at, + updated_at=row.category_updated_at + ) + grocery = schemas.Grocery( id=row.grocery_id, name=row.grocery_name, - category=row.grocery_category, + category_id=row.category_id, created_at=row.grocery_created_at, - updated_at=row.grocery_updated_at + updated_at=row.grocery_updated_at, + category=category ) brand = None @@ -261,9 +272,67 @@ def delete_brand(brand_id: int, db: Session = Depends(get_db)): db.commit() return {"message": "Brand deleted successfully"} +# Grocery Category endpoints +@app.post("/grocery-categories/", response_model=schemas.GroceryCategory) +def create_grocery_category(category: schemas.GroceryCategoryCreate, db: Session = Depends(get_db)): + db_category = models.GroceryCategory(**category.dict()) + db.add(db_category) + db.commit() + db.refresh(db_category) + return db_category + +@app.get("/grocery-categories/", response_model=List[schemas.GroceryCategory]) +def read_grocery_categories(skip: int = 0, limit: int = 100, db: Session = Depends(get_db)): + categories = db.query(models.GroceryCategory).offset(skip).limit(limit).all() + return categories + +@app.get("/grocery-categories/{category_id}", response_model=schemas.GroceryCategory) +def read_grocery_category(category_id: int, db: Session = Depends(get_db)): + category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first() + if category is None: + raise HTTPException(status_code=404, detail="Grocery category not found") + return category + +@app.put("/grocery-categories/{category_id}", response_model=schemas.GroceryCategory) +def update_grocery_category(category_id: int, category_update: schemas.GroceryCategoryUpdate, db: Session = Depends(get_db)): + category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first() + if category is None: + raise HTTPException(status_code=404, detail="Grocery category not found") + + update_data = category_update.dict(exclude_unset=True) + for field, value in update_data.items(): + setattr(category, field, value) + + db.commit() + db.refresh(category) + return category + +@app.delete("/grocery-categories/{category_id}") +def delete_grocery_category(category_id: int, db: Session = Depends(get_db)): + category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == category_id).first() + if category is None: + raise HTTPException(status_code=404, detail="Grocery category not found") + + # Check if any groceries reference this category + groceries_with_category = db.query(models.Grocery).filter(models.Grocery.category_id == category_id).first() + if groceries_with_category: + raise HTTPException( + status_code=400, + detail="Cannot delete category: groceries are still associated with this category" + ) + + db.delete(category) + db.commit() + return {"message": "Grocery category deleted successfully"} + # Grocery endpoints @app.post("/groceries/", response_model=schemas.Grocery) def create_grocery(grocery: schemas.GroceryCreate, db: Session = Depends(get_db)): + # Validate category exists + category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == grocery.category_id).first() + if category is None: + raise HTTPException(status_code=404, detail="Grocery category not found") + db_grocery = models.Grocery(**grocery.dict()) db.add(db_grocery) db.commit() @@ -289,6 +358,13 @@ def update_grocery(grocery_id: int, grocery_update: schemas.GroceryUpdate, db: S raise HTTPException(status_code=404, detail="Grocery not found") update_data = grocery_update.dict(exclude_unset=True) + + # Validate category exists if category_id is being updated + if 'category_id' in update_data: + category = db.query(models.GroceryCategory).filter(models.GroceryCategory.id == update_data['category_id']).first() + if category is None: + raise HTTPException(status_code=404, detail="Grocery category not found") + for field, value in update_data.items(): setattr(grocery, field, value) diff --git a/backend/models.py b/backend/models.py index fdda06c..4d014f0 100644 --- a/backend/models.py +++ b/backend/models.py @@ -28,16 +28,28 @@ class Brand(Base): # Relationships products = relationship("Product", back_populates="brand") +class GroceryCategory(Base): + __tablename__ = "grocery_categories" + + id = Column(Integer, primary_key=True, index=True) + name = Column(String, nullable=False, index=True) + created_at = Column(DateTime(timezone=True), server_default=func.now()) + updated_at = Column(DateTime(timezone=True), onupdate=func.now()) + + # Relationships + groceries = relationship("Grocery", back_populates="category") + class Grocery(Base): __tablename__ = "groceries" id = Column(Integer, primary_key=True, index=True) name = Column(String, nullable=False, index=True) - category = Column(String, nullable=False) + category_id = Column(Integer, ForeignKey("grocery_categories.id"), nullable=False) created_at = Column(DateTime(timezone=True), server_default=func.now()) updated_at = Column(DateTime(timezone=True), onupdate=func.now()) # Relationships + category = relationship("GroceryCategory", back_populates="groceries") products = relationship("Product", back_populates="grocery") class Product(Base): diff --git a/backend/schemas.py b/backend/schemas.py index 328c415..840a884 100644 --- a/backend/schemas.py +++ b/backend/schemas.py @@ -20,22 +20,41 @@ class Brand(BrandBase): class Config: from_attributes = True +# Grocery Category schemas +class GroceryCategoryBase(BaseModel): + name: str + +class GroceryCategoryCreate(GroceryCategoryBase): + pass + +class GroceryCategoryUpdate(BaseModel): + name: Optional[str] = None + +class GroceryCategory(GroceryCategoryBase): + id: int + created_at: datetime + updated_at: Optional[datetime] = None + + class Config: + from_attributes = True + # Grocery schemas class GroceryBase(BaseModel): name: str - category: str + category_id: int class GroceryCreate(GroceryBase): pass class GroceryUpdate(BaseModel): name: Optional[str] = None - category: Optional[str] = None + category_id: Optional[int] = None class Grocery(GroceryBase): id: int created_at: datetime updated_at: Optional[datetime] = None + category: GroceryCategory class Config: from_attributes = True diff --git a/database_schema dev.drawio b/database_schema dev.drawio deleted file mode 100644 index e3a63dc..0000000 --- a/database_schema dev.drawio +++ /dev/null @@ -1,603 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/database_schema.drawio b/database_schema.drawio index 3a76800..595855b 100644 --- a/database_schema.drawio +++ b/database_schema.drawio @@ -1,6 +1,6 @@ - + @@ -451,81 +451,143 @@ - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..fee76bb --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,73 @@ +version: '3.8' + +services: + # PostgreSQL Database + db: + image: postgres:15-alpine + container_name: groceries_db + environment: + POSTGRES_DB: product_tracker + POSTGRES_USER: product_user + POSTGRES_PASSWORD: ${DB_PASSWORD:-secure_password_123} + volumes: + - postgres_data:/var/lib/postgresql/data + - ./init-db.sql:/docker-entrypoint-initdb.d/init-db.sql:ro + ports: + - "5432:5432" + healthcheck: + test: ["CMD-SHELL", "pg_isready -U product_user -d product_tracker"] + interval: 10s + timeout: 5s + retries: 5 + restart: unless-stopped + + # FastAPI Backend + backend: + build: + context: ./backend + dockerfile: Dockerfile + container_name: groceries_backend + environment: + DATABASE_URL: postgresql://product_user:${DB_PASSWORD:-secure_password_123}@db:5432/product_tracker + SECRET_KEY: ${SECRET_KEY:-your-super-secret-key-change-this-in-production} + ALGORITHM: HS256 + ACCESS_TOKEN_EXPIRE_MINUTES: 30 + DEBUG: ${DEBUG:-False} + ports: + - "8000:8000" + depends_on: + db: + condition: service_healthy + volumes: + - ./backend:/app + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:8000/docs"] + interval: 30s + timeout: 10s + retries: 3 + + # React Frontend + frontend: + build: + context: ./frontend + dockerfile: Dockerfile + container_name: groceries_frontend + ports: + - "80:80" + depends_on: + - backend + restart: unless-stopped + healthcheck: + test: ["CMD", "curl", "-f", "http://localhost:80"] + interval: 30s + timeout: 10s + retries: 3 + +volumes: + postgres_data: + driver: local + +networks: + default: + name: groceries_network \ No newline at end of file diff --git a/docker.env.example b/docker.env.example new file mode 100644 index 0000000..4cb2bbb --- /dev/null +++ b/docker.env.example @@ -0,0 +1,11 @@ +# Database Configuration +DB_PASSWORD=secure_password_123 + +# Backend Configuration +SECRET_KEY=your-super-secret-key-change-this-in-production-make-it-very-long-and-random +DEBUG=False + +# Optional: Override default ports if needed +# FRONTEND_PORT=80 +# BACKEND_PORT=8000 +# DB_PORT=5432 \ No newline at end of file diff --git a/frontend/Dockerfile b/frontend/Dockerfile new file mode 100644 index 0000000..a995283 --- /dev/null +++ b/frontend/Dockerfile @@ -0,0 +1,31 @@ +# Build stage +FROM node:18-alpine as build + +WORKDIR /app + +# Copy package files +COPY package*.json ./ + +# Install dependencies +RUN npm ci --only=production + +# Copy source code +COPY . . + +# Build the application +RUN npm run build + +# Production stage +FROM nginx:alpine + +# Copy built app from build stage +COPY --from=build /app/build /usr/share/nginx/html + +# Copy nginx configuration +COPY nginx.conf /etc/nginx/conf.d/default.conf + +# Expose port +EXPOSE 80 + +# Start nginx +CMD ["nginx", "-g", "daemon off;"] \ No newline at end of file diff --git a/frontend/nginx.conf b/frontend/nginx.conf new file mode 100644 index 0000000..b865c03 --- /dev/null +++ b/frontend/nginx.conf @@ -0,0 +1,26 @@ +server { + listen 80; + server_name localhost; + root /usr/share/nginx/html; + index index.html; + + # Handle React Router + location / { + try_files $uri $uri/ /index.html; + } + + # Proxy API requests to backend + location /api/ { + proxy_pass http://backend:8000/; + proxy_set_header Host $host; + proxy_set_header X-Real-IP $remote_addr; + proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; + proxy_set_header X-Forwarded-Proto $scheme; + } + + # Handle static files + location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg)$ { + expires 1y; + add_header Cache-Control "public, immutable"; + } +} \ No newline at end of file diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 989a28b..87ef0b2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,12 +1,13 @@ import React from 'react'; 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 BrandList from './components/BrandList'; -import GroceryList from './components/GroceryList'; +import ProductList from './components/ProductList'; import ShoppingEventList from './components/ShoppingEventList'; import ShoppingEventForm from './components/ShoppingEventForm'; +import BrandList from './components/BrandList'; +import GroceryList from './components/GroceryList'; +import GroceryCategoryList from './components/GroceryCategoryList'; function Navigation() { const location = useLocation(); @@ -16,48 +17,81 @@ function Navigation() { }; return ( -