Minor version bump (1.x.0) is appropriate because:

 New functionality added (soft delete system)
 Backward compatible (existing features unchanged)
 Significant enhancement (complete temporal tracking system)
 API additions (new endpoints, parameters)
 UI enhancements (new components, visual indicators)
This commit is contained in:
lasse 2025-05-30 09:49:26 +02:00
parent 56c3c16f6d
commit 0b42a74fe9
16 changed files with 1438 additions and 237 deletions

1
.gitignore vendored
View File

@ -156,6 +156,7 @@ dist/
# PostgreSQL
*.sql
!init-db.sql
!*_migration.sql
# Database dumps
*.dump

View File

@ -0,0 +1,122 @@
-- Migration script for adding temporal tables to products
-- Run this script to add versioning support to your existing database
--
-- Note: We use '9999-12-31' as the "far future" date to represent
-- the current/active version of a product. This is more portable than PostgreSQL's
-- 'infinity' and works well with both PostgreSQL and SQLite.
--
-- New products will use CURRENT_DATE as valid_from, but existing products
-- are set to '2025-05-01' as a baseline date for historical tracking.
BEGIN;
-- Step 1: Add temporal columns to existing products table
ALTER TABLE products
ADD COLUMN valid_from DATE DEFAULT CURRENT_DATE NOT NULL,
ADD COLUMN valid_to DATE DEFAULT '9999-12-31' NOT NULL,
ADD COLUMN deleted BOOLEAN DEFAULT FALSE NOT NULL;
-- Step 2: Create products_history table
CREATE TABLE products_history (
history_id SERIAL PRIMARY KEY,
id INTEGER NOT NULL,
name VARCHAR NOT NULL,
category_id INTEGER NOT NULL,
brand_id INTEGER,
organic BOOLEAN DEFAULT FALSE,
weight FLOAT,
weight_unit VARCHAR DEFAULT 'piece',
created_at TIMESTAMPTZ,
updated_at TIMESTAMPTZ,
valid_from DATE NOT NULL,
valid_to DATE NOT NULL,
deleted BOOLEAN DEFAULT FALSE NOT NULL,
operation CHAR(1) NOT NULL,
archived_at TIMESTAMPTZ DEFAULT NOW() NOT NULL
);
-- Step 3: Create indexes for performance
CREATE INDEX idx_products_history_id ON products_history(id);
CREATE INDEX idx_products_history_valid_period ON products_history(id, valid_from, valid_to);
CREATE INDEX idx_products_valid_period ON products(id, valid_from, valid_to);
-- Step 4: Create trigger function for automatic versioning
CREATE OR REPLACE FUNCTION products_versioning_trigger()
RETURNS TRIGGER AS $$
BEGIN
-- Handle DELETE operations
IF TG_OP = 'DELETE' THEN
-- Create history record for the deleted product
INSERT INTO products_history (
id, name, category_id, brand_id, organic, weight, weight_unit,
created_at, updated_at, valid_from, valid_to, deleted, operation
) VALUES (
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
OLD.valid_from, CURRENT_DATE, OLD.deleted, 'D'
);
RETURN OLD;
END IF;
-- Handle UPDATE operations
IF TG_OP = 'UPDATE' THEN
-- Check if any versioned fields have changed
IF (OLD.name IS DISTINCT FROM NEW.name OR
OLD.category_id IS DISTINCT FROM NEW.category_id OR
OLD.brand_id IS DISTINCT FROM NEW.brand_id OR
OLD.organic IS DISTINCT FROM NEW.organic OR
OLD.weight IS DISTINCT FROM NEW.weight OR
OLD.weight_unit IS DISTINCT FROM NEW.weight_unit OR
OLD.deleted IS DISTINCT FROM NEW.deleted) THEN
-- Determine the valid_to date for the history record
DECLARE
history_valid_to DATE;
BEGIN
-- If valid_from was manually changed, use that as the cutoff
-- Otherwise, use current date for automatic versioning
IF OLD.valid_from IS DISTINCT FROM NEW.valid_from THEN
history_valid_to = NEW.valid_from;
ELSE
history_valid_to = CURRENT_DATE;
-- For automatic versioning, update the valid_from to today
NEW.valid_from = CURRENT_DATE;
END IF;
-- Create history record with the old data
INSERT INTO products_history (
id, name, category_id, brand_id, organic, weight, weight_unit,
created_at, updated_at, valid_from, valid_to, deleted, operation
) VALUES (
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
OLD.valid_from, history_valid_to, OLD.deleted, 'U'
);
END;
-- Always ensure valid_to is set to far future for current version
NEW.valid_to = '9999-12-31';
NEW.updated_at = NOW();
END IF;
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
-- Step 5: Create the trigger
CREATE TRIGGER products_versioning_trigger
BEFORE UPDATE OR DELETE ON products
FOR EACH ROW
EXECUTE FUNCTION products_versioning_trigger();
-- Step 6: Initialize existing products with proper temporal data
UPDATE products
SET valid_from = '2025-05-01',
valid_to = '9999-12-31'
WHERE valid_from IS NULL OR valid_to IS NULL;
COMMIT;

View File

@ -0,0 +1,271 @@
# Soft Delete Implementation with Historical Tracking
## Overview
This implementation extends the existing temporal tables system to support soft deletes with proper historical tracking. When a product is "deleted", it's not actually removed from the database but marked as deleted with a timestamp, while maintaining full historical data.
## Key Features
### 1. Soft Delete Mechanism
- Products are marked as `deleted = true` instead of being physically removed
- Deletion creates a historical record of the product's state before deletion
- Deleted products get a new `valid_from` date set to the deletion date
- All historical versions remain intact for audit purposes
### 2. UI Enhancements
- **Product List**: "Show deleted" toggle next to "Add New Product" button
- **Visual Indicators**: Deleted products shown with:
- Red background tint and reduced opacity
- Strikethrough text
- 🗑️ emoji indicator
- Disabled edit/duplicate/delete actions
- **Shopping Events**: Products deleted before/on shopping date are automatically filtered out
### 3. API Behavior
- **Default**: Deleted products are hidden from all product listings
- **Optional**: `show_deleted=true` parameter shows all products including deleted ones
- **Shopping Events**: New endpoint `/products/available-for-shopping/{date}` filters products based on deletion status at specific date
## Database Schema Changes
### Products Table
```sql
ALTER TABLE products
ADD COLUMN deleted BOOLEAN DEFAULT FALSE NOT NULL;
```
### Products History Table
```sql
-- Already included in products_history table
deleted BOOLEAN DEFAULT FALSE NOT NULL
```
### Updated Trigger
The `products_versioning_trigger` now **consistently handles ALL historization**:
- **UPDATE operations**: Creates history records for both automatic and manual versioning
- **DELETE operations**: Creates history records when products are deleted
- **Smart versioning**: Automatically detects manual vs automatic versioning based on `valid_from` changes
- **Centralized logic**: All temporal logic is in the database trigger, not split between trigger and application
### Trigger Benefits
1. **Consistency**: All versioning operations follow the same pattern
2. **Reliability**: Database-level enforcement prevents inconsistencies
3. **Simplicity**: Application code just sets fields, trigger handles the rest
4. **Performance**: Single database operation handles both data update and history creation
## API Endpoints
### Modified Endpoints
#### `GET /products/`
- **New Parameter**: `show_deleted: bool = False`
- **Behavior**: Filters out deleted products by default
- **Usage**: `GET /products/?show_deleted=true` to include deleted products
#### `PUT /products/{id}` & `DELETE /products/{id}`
- **Enhanced**: Now properly handles soft delete with historical tracking
- **Validation**: Prevents operations on already-deleted products
### New Endpoints
#### `GET /products/available-for-shopping/{shopping_date}`
- **Purpose**: Get products that were available (not deleted) on a specific shopping date
- **Logic**: Returns products where:
- `deleted = false` (never deleted), OR
- `deleted = true` AND `valid_from > shopping_date` (deleted after shopping date)
- **Usage**: Used by shopping event modals to filter product lists
## Frontend Implementation
### ProductList Component
```typescript
// New state for toggle
const [showDeleted, setShowDeleted] = useState(false);
// Updated API call
const response = await productApi.getAll(showDeleted);
// Visual styling for deleted products
className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}
```
### AddShoppingEventModal Component
```typescript
// Dynamic product fetching based on shopping date
const response = formData.date
? await productApi.getAvailableForShopping(formData.date)
: await productApi.getAll(false);
// Refetch products when date changes
useEffect(() => {
if (isOpen && formData.date) {
fetchProducts();
}
}, [formData.date, isOpen]);
```
## Deletion Process Flow
### 1. User Initiates Delete
- User clicks "Delete" button on a product
- Confirmation modal appears
### 2. Backend Processing
```python
# Simple application code - trigger handles the complexity
product.deleted = True
product.valid_from = date.today() # Manual versioning date
product.updated_at = func.now()
# Trigger automatically:
# 1. Detects the change (deleted field + valid_from change)
# 2. Creates history record with old data (deleted=False, valid_to=today)
# 3. Ensures new record has valid_to='9999-12-31'
```
**Trigger Logic (Automatic)**:
- Detects manual versioning because `valid_from` changed
- Uses the new `valid_from` as the cutoff date for history
- Creates history record: `{...old_data, valid_to: new_valid_from, operation: 'U'}`
- No additional application logic needed
### 3. Frontend Updates
- Product list refreshes
- Deleted product appears with visual indicators (if "Show deleted" is enabled)
- Product becomes unavailable for new shopping events
## Historical Data Integrity
### Shopping Events
- **Guarantee**: Shopping events always show products exactly as they existed when purchased
- **Implementation**: Uses `/products/{id}/at/{date}` endpoint to fetch historical product state
- **Benefit**: Even if a product is deleted later, historical shopping events remain accurate
### Audit Trail
- **Complete History**: All product versions are preserved in `products_history`
- **Deletion Tracking**: History records show when and why products were deleted
- **Temporal Queries**: Can reconstruct product state at any point in time
## Usage Examples
### 1. View All Products (Default)
```bash
GET /products/
# Returns only non-deleted products
```
### 2. View All Products Including Deleted
```bash
GET /products/?show_deleted=true
# Returns all products with deleted status
```
### 3. Get Products Available for Shopping on Specific Date
```bash
GET /products/available-for-shopping/2024-01-15
# Returns products that were not deleted on 2024-01-15
```
### 4. View Historical Shopping Event
```bash
GET /shopping-events/123/products-as-purchased
# Returns products exactly as they were when purchased, regardless of current deletion status
```
## Benefits
1. **Data Preservation**: No data is ever lost
2. **Audit Compliance**: Complete audit trail of all changes
3. **Historical Accuracy**: Shopping events remain accurate over time
4. **User Experience**: Clean interface with optional deleted product visibility
5. **Flexibility**: Easy to "undelete" products if needed (future enhancement)
## Future Enhancements
1. **Undelete Functionality**: Add ability to restore deleted products
2. **Bulk Operations**: Delete/restore multiple products at once
3. **Deletion Reasons**: Add optional reason field for deletions
4. **Advanced Filtering**: Filter by deletion date, reason, etc.
5. **Reporting**: Generate reports on deleted products and their impact
## Migration Instructions
1. **Run Migration**: Execute `temporal_migration.sql` to add `deleted` column
2. **Deploy Backend**: Update backend with new API endpoints and logic
3. **Deploy Frontend**: Update frontend with new UI components and API calls
4. **Test**: Verify soft delete functionality and historical data integrity
## Testing Scenarios
1. **Basic Deletion**: Delete a product and verify it's hidden from default view
2. **Show Deleted Toggle**: Enable "Show deleted" and verify deleted products appear with proper styling
3. **Shopping Event Filtering**: Create shopping event and verify deleted products don't appear in product list
4. **Historical Accuracy**: Delete a product that was in a past shopping event, verify the shopping event still shows correct historical data
5. **Date-based Filtering**: Test `/products/available-for-shopping/{date}` with various dates before/after product deletions
## Database Initialization
### ⚠️ Important: Trigger Creation
The temporal triggers are essential for the soft delete functionality. They must be created in **all environments**:
### **Method 1: Automatic (Recommended)**
The triggers are now automatically created when using SQLAlchemy's `create_all()`:
```python
# This now creates both tables AND triggers
models.Base.metadata.create_all(bind=engine)
```
**How it works**:
- SQLAlchemy event listener detects when `ProductHistory` table is created
- Automatically executes trigger creation SQL
- Works for fresh dev, test, and production databases
### **Method 2: Manual Database Script**
For explicit control, use the initialization script:
```bash
# Run this for fresh database setup
python backend/database_init.py
```
**Features**:
- Creates all tables
- Creates all triggers
- Checks for existing triggers (safe to run multiple times)
- Provides detailed feedback
### **Method 3: Migration File**
For existing databases, run the migration:
```sql
-- Execute temporal_migration.sql
\i temporal_migration.sql
```
## Environment Setup Guide
### **Development (Fresh DB)**
```bash
# Option A: Automatic (when starting app)
python backend/main.py
# ✅ Tables + triggers created automatically
# Option B: Explicit setup
python backend/database_init.py
python backend/main.py
```
### **Production (Fresh DB)**
```bash
# Recommended: Explicit initialization
python backend/database_init.py
# Then start the application
```
### **Existing Database (Migration)**
```bash
# Apply migration to add soft delete functionality
docker-compose exec db psql -U postgres -d groceries -f /tmp/temporal_migration.sql
```

111
TEMPORAL_FEATURES.md Normal file
View File

@ -0,0 +1,111 @@
# Temporal Product Tracking Features
This document describes the new historical product tracking functionality that allows you to track product changes over time.
## Features
### 1. Historical Product Versioning
- Products now maintain a complete history of changes
- When product attributes (name, weight, category, etc.) are updated, the old version is automatically saved
- Each version has `valid_from` and `valid_to` dates indicating when it was active
### 2. Manual Effective Dates
- When creating or editing products, you can specify a custom "Effective From" date
- If not specified, the current date is used
- This allows you to retroactively record product changes or schedule future changes
### 3. Shopping Event Historical Accuracy
- Shopping events now show products exactly as they were when purchased
- Even if a product's weight or name has changed since purchase, the historical data is preserved
## Database Changes
### New Tables
- `products_history` - Stores old versions of products when they're updated
### New Columns
- `products.valid_from` (DATE) - When this product version became effective
- `products.valid_to` (DATE) - When this product version was superseded (9999-12-31 for current versions)
## API Endpoints
### New Endpoints
- `GET /current-date` - Get current date for form prefilling
- `GET /products/{id}/history` - Get all historical versions of a product
- `GET /products/{id}/at/{date}` - Get product as it existed on a specific date (YYYY-MM-DD)
- `GET /shopping-events/{id}/products-as-purchased` - Get products as they were when purchased
### Updated Endpoints
- `POST /products/` - Now accepts optional `valid_from` field
- `PUT /products/{id}` - Now accepts optional `valid_from` field for manual versioning
## Frontend Changes
### Product Forms
- Added "Effective From" date field to product create/edit forms
- Date field is pre-filled with current date
- Required field with helpful description
### API Integration
- ProductCreate interface now includes optional `valid_from` field
- New utilityApi for fetching current date
- Proper form validation and error handling
## Migration
Run `temporal_migration.sql` to:
1. Add temporal columns to existing products table
2. Create products_history table
3. Set up automatic versioning trigger
4. Initialize existing products with baseline date (2025-05-01)
## Usage Examples
### Creating a Product with Custom Date
```json
POST /products/
{
"name": "Organic Milk",
"category_id": 1,
"weight": 1000,
"weight_unit": "ml",
"valid_from": "2025-03-15"
}
```
### Updating a Product with Future Effective Date
```json
PUT /products/123
{
"weight": 1200,
"valid_from": "2025-04-01"
}
```
### Getting Historical Product Data
```bash
# Get product as it was on January 15, 2025
GET /products/123/at/2025-01-15
# Get all versions of a product
GET /products/123/history
# Get shopping event with historical product data
GET /shopping-events/456/products-as-purchased
```
## Benefits
1. **Complete Audit Trail** - Never lose track of how products have changed over time
2. **Accurate Historical Data** - Shopping events always show correct product information
3. **Flexible Dating** - Record changes retroactively or schedule future changes
4. **Automatic Versioning** - No manual effort required for basic updates
5. **Cross-Database Compatibility** - Uses standard DATE fields that work with PostgreSQL, SQLite, MySQL, etc.
## Technical Notes
- Versioning is handled automatically by database triggers
- Manual versioning is used when custom `valid_from` dates are specified
- Date format: YYYY-MM-DD (ISO 8601)
- Far future date (9999-12-31) represents current/active versions
- Temporal fields are not displayed in regular product lists (by design)

63
backend/database_init.py Normal file
View File

@ -0,0 +1,63 @@
#!/usr/bin/env python3
"""
Database initialization script that creates all tables and triggers.
Use this for setting up fresh development or production databases.
Usage:
python database_init.py
"""
import sys
import os
from sqlalchemy import create_engine, text
from sqlalchemy.orm import sessionmaker
# Add parent directory to path to import models
sys.path.append(os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
from models import Base
from database import get_database_url
def init_database():
"""Initialize database with all tables and triggers"""
database_url = get_database_url()
engine = create_engine(database_url)
print("🚀 Initializing database...")
print(f"📍 Database URL: {database_url}")
try:
# Create all tables
print("📊 Creating tables...")
Base.metadata.create_all(bind=engine)
print("✅ Tables created successfully")
# Create triggers (if not already created by event listener)
print("⚙️ Ensuring triggers are created...")
with engine.connect() as connection:
# Check if trigger exists
result = connection.execute(text("""
SELECT EXISTS (
SELECT 1 FROM information_schema.triggers
WHERE trigger_name = 'products_versioning_trigger'
);
""")).scalar()
if not result:
print("📝 Creating products versioning trigger...")
from models import PRODUCTS_VERSIONING_TRIGGER_SQL
connection.execute(text(PRODUCTS_VERSIONING_TRIGGER_SQL))
connection.commit()
print("✅ Trigger created successfully")
else:
print("✅ Trigger already exists")
print("🎉 Database initialization completed successfully!")
except Exception as e:
print(f"❌ Error initializing database: {e}")
sys.exit(1)
if __name__ == "__main__":
init_database()

View File

@ -1,7 +1,7 @@
from fastapi import FastAPI, Depends, HTTPException, status
from fastapi.middleware.cors import CORSMiddleware
from sqlalchemy.orm import Session
from sqlalchemy import text
from sqlalchemy import text, func
from typing import List
import models, schemas
from database import engine, get_db
@ -95,6 +95,34 @@ def build_shopping_event_response(event: models.ShoppingEvent, db: Session) -> s
def read_root():
return {"message": __app_name__, "version": __version__, "name": "Groceries Tracker Backend"}
# Utility endpoints
@app.get("/current-date")
def get_current_date():
"""Get current date for use as default in valid_from fields"""
from datetime import date
return {"current_date": date.today().isoformat()}
@app.get("/products/available-for-shopping/{shopping_date}", response_model=List[schemas.Product])
def get_products_available_for_shopping(shopping_date: str, db: Session = Depends(get_db)):
"""Get products that were available (not deleted) on a specific shopping date"""
from datetime import datetime
try:
# Parse the shopping date
target_date = datetime.strptime(shopping_date, '%Y-%m-%d').date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD format")
# Get products that were either:
# 1. Never deleted (deleted=False)
# 2. Deleted after the shopping date (valid_from > shopping_date for deleted=True products)
products = db.query(models.Product).filter(
(models.Product.deleted == False) |
((models.Product.deleted == True) & (models.Product.valid_from > target_date))
).all()
return products
# Product endpoints
@app.post("/products/", response_model=schemas.Product)
def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)):
@ -109,15 +137,31 @@ def create_product(product: schemas.ProductCreate, db: Session = Depends(get_db)
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
db_product = models.Product(**product.dict())
# Validate valid_from date if provided
if product.valid_from is not None:
from datetime import date
if product.valid_from > date.today():
raise HTTPException(status_code=400, detail="Valid from date cannot be in the future")
# Create product data
product_data = product.dict(exclude={'valid_from'})
db_product = models.Product(**product_data)
# Set valid_from if provided, otherwise let database default handle it
if product.valid_from is not None:
db_product.valid_from = product.valid_from
db.add(db_product)
db.commit()
db.refresh(db_product)
return db_product
@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()
def read_products(skip: int = 0, limit: int = 100, show_deleted: bool = False, db: Session = Depends(get_db)):
query = db.query(models.Product)
if not show_deleted:
query = query.filter(models.Product.deleted == False)
products = query.offset(skip).limit(limit).all()
return products
@app.get("/products/{product_id}", response_model=schemas.Product)
@ -127,13 +171,21 @@ def read_product(product_id: int, db: Session = Depends(get_db)):
raise HTTPException(status_code=404, detail="Product not found")
return product
@app.get("/products/{product_id}/valid-from")
def get_product_valid_from(product_id: int, db: Session = Depends(get_db)):
"""Get the current valid_from date for a product (used for validation when editing)"""
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 {"valid_from": product.valid_from.isoformat()}
@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 = product_update.dict(exclude_unset=True)
update_data = product_update.dict(exclude_unset=True, exclude={'valid_from'})
# Validate category exists if category_id is being updated
if 'category_id' in update_data:
@ -147,9 +199,32 @@ def update_product(product_id: int, product_update: schemas.ProductUpdate, db: S
if brand is None:
raise HTTPException(status_code=404, detail="Brand not found")
# Validate valid_from date if provided
if product_update.valid_from is not None:
from datetime import date
if product_update.valid_from > date.today():
raise HTTPException(status_code=400, detail="Valid from date cannot be in the future")
if product_update.valid_from <= product.valid_from:
raise HTTPException(
status_code=400,
detail=f"Valid from date must be after the current product's valid from date ({product.valid_from})"
)
# Check if any versioned fields are actually changing
versioned_fields = ['name', 'category_id', 'brand_id', 'organic', 'weight', 'weight_unit']
has_changes = any(
field in update_data and getattr(product, field) != update_data[field]
for field in versioned_fields
)
# Apply the updates - trigger will handle history creation automatically
for field, value in update_data.items():
setattr(product, field, value)
# Set valid_from if provided for manual versioning
if product_update.valid_from is not None:
product.valid_from = product_update.valid_from
db.commit()
db.refresh(product)
return product
@ -160,10 +235,163 @@ def delete_product(product_id: int, db: Session = Depends(get_db)):
if product is None:
raise HTTPException(status_code=404, detail="Product not found")
db.delete(product)
if product.deleted:
raise HTTPException(status_code=400, detail="Product is already deleted")
from datetime import date
# Simply mark as deleted and set valid_from to today
# The trigger will automatically create the history record
product.deleted = True
product.valid_from = date.today()
product.updated_at = func.now()
db.commit()
return {"message": "Product deleted successfully"}
# Historical Product endpoints
@app.get("/products/{product_id}/history", response_model=List[schemas.ProductHistory])
def get_product_history(product_id: int, db: Session = Depends(get_db)):
"""Get all historical versions of a product"""
# Check if product exists
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")
# Get history from history table
history = db.query(models.ProductHistory).filter(
models.ProductHistory.id == product_id
).order_by(models.ProductHistory.valid_from.desc()).all()
return history
@app.get("/products/{product_id}/at/{date}", response_model=schemas.ProductAtDate)
def get_product_at_date(product_id: int, date: str, db: Session = Depends(get_db)):
"""Get product as it existed at a specific date - CRUCIAL for shopping events"""
from datetime import datetime, date as date_type
try:
# Parse the date string (accept YYYY-MM-DD format)
target_date = datetime.strptime(date, '%Y-%m-%d').date()
except ValueError:
raise HTTPException(status_code=400, detail="Invalid date format. Use YYYY-MM-DD format")
# First try current products table
current_product = db.query(models.Product).filter(
models.Product.id == product_id,
models.Product.valid_from <= target_date,
models.Product.valid_to >= target_date
).first()
if current_product:
# Get related data
category = db.query(models.GroceryCategory).filter(
models.GroceryCategory.id == current_product.category_id
).first()
brand = None
if current_product.brand_id:
brand = db.query(models.Brand).filter(
models.Brand.id == current_product.brand_id
).first()
return schemas.ProductAtDate(
id=current_product.id,
name=current_product.name,
category_id=current_product.category_id,
category=category,
brand_id=current_product.brand_id,
brand=brand,
organic=current_product.organic,
weight=current_product.weight,
weight_unit=current_product.weight_unit,
valid_from=current_product.valid_from,
valid_to=current_product.valid_to,
deleted=current_product.deleted,
was_current=True
)
# Try history table
historical_product = db.query(models.ProductHistory).filter(
models.ProductHistory.id == product_id,
models.ProductHistory.valid_from <= target_date,
models.ProductHistory.valid_to >= target_date
).first()
if historical_product:
# Get related data (note: these might have changed too, but we'll use current versions)
category = db.query(models.GroceryCategory).filter(
models.GroceryCategory.id == historical_product.category_id
).first()
brand = None
if historical_product.brand_id:
brand = db.query(models.Brand).filter(
models.Brand.id == historical_product.brand_id
).first()
return schemas.ProductAtDate(
id=historical_product.id,
name=historical_product.name,
category_id=historical_product.category_id,
category=category,
brand_id=historical_product.brand_id,
brand=brand,
organic=historical_product.organic,
weight=historical_product.weight,
weight_unit=historical_product.weight_unit,
valid_from=historical_product.valid_from,
valid_to=historical_product.valid_to,
deleted=historical_product.deleted,
was_current=False
)
# Product didn't exist at that date
raise HTTPException(
status_code=404,
detail=f"Product {product_id} did not exist on {date}"
)
@app.get("/shopping-events/{event_id}/products-as-purchased", response_model=List[schemas.ProductAtPurchase])
def get_shopping_event_products_as_purchased(event_id: int, db: Session = Depends(get_db)):
"""Get products as they were when purchased - shows historical product data"""
# Get the shopping event
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")
# Get products from association table
products_data = db.execute(
text("""
SELECT sep.product_id, sep.amount, sep.price, sep.discount
FROM shopping_event_products sep
WHERE sep.shopping_event_id = :event_id
"""),
{"event_id": event_id}
).fetchall()
result = []
for product_data in products_data:
# Get product as it was at the time of purchase
try:
# Extract just the date from the shopping event datetime
purchase_date = event.date.date().strftime('%Y-%m-%d')
product_at_purchase = get_product_at_date(
product_data.product_id,
purchase_date,
db
)
result.append(schemas.ProductAtPurchase(
product=product_at_purchase,
amount=product_data.amount,
price=product_data.price,
discount=product_data.discount
))
except HTTPException:
# Product didn't exist at purchase time (shouldn't happen, but handle gracefully)
continue
return result
# Shop endpoints
@app.post("/shops/", response_model=schemas.Shop)
def create_shop(shop: schemas.ShopCreate, db: Session = Depends(get_db)):

View File

@ -1,11 +1,89 @@
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, ForeignKey, Table
from sqlalchemy import Column, Integer, String, Float, Boolean, DateTime, Date, ForeignKey, Table, event, DDL
from sqlalchemy.ext.declarative import declarative_base
from sqlalchemy.orm import relationship
from sqlalchemy.sql import func
from sqlalchemy.sql import func, text
from datetime import datetime
# Constants for temporal tables
FAR_FUTURE_DATE = "'9999-12-31'"
Base = declarative_base()
# Trigger creation SQL
PRODUCTS_VERSIONING_TRIGGER_SQL = """
CREATE OR REPLACE FUNCTION products_versioning_trigger()
RETURNS TRIGGER AS $$
BEGIN
-- Handle DELETE operations
IF TG_OP = 'DELETE' THEN
-- Create history record for the deleted product
INSERT INTO products_history (
id, name, category_id, brand_id, organic, weight, weight_unit,
created_at, updated_at, valid_from, valid_to, deleted, operation
) VALUES (
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
OLD.valid_from, CURRENT_DATE, OLD.deleted, 'D'
);
RETURN OLD;
END IF;
-- Handle UPDATE operations
IF TG_OP = 'UPDATE' THEN
-- Check if any versioned fields have changed
IF (OLD.name IS DISTINCT FROM NEW.name OR
OLD.category_id IS DISTINCT FROM NEW.category_id OR
OLD.brand_id IS DISTINCT FROM NEW.brand_id OR
OLD.organic IS DISTINCT FROM NEW.organic OR
OLD.weight IS DISTINCT FROM NEW.weight OR
OLD.weight_unit IS DISTINCT FROM NEW.weight_unit OR
OLD.deleted IS DISTINCT FROM NEW.deleted) THEN
-- Determine the valid_to date for the history record
DECLARE
history_valid_to DATE;
BEGIN
-- If valid_from was manually changed, use that as the cutoff
-- Otherwise, use current date for automatic versioning
IF OLD.valid_from IS DISTINCT FROM NEW.valid_from THEN
history_valid_to = NEW.valid_from;
ELSE
history_valid_to = CURRENT_DATE;
-- For automatic versioning, update the valid_from to today
NEW.valid_from = CURRENT_DATE;
END IF;
-- Create history record with the old data
INSERT INTO products_history (
id, name, category_id, brand_id, organic, weight, weight_unit,
created_at, updated_at, valid_from, valid_to, deleted, operation
) VALUES (
OLD.id, OLD.name, OLD.category_id, OLD.brand_id, OLD.organic,
OLD.weight, OLD.weight_unit, OLD.created_at, OLD.updated_at,
OLD.valid_from, history_valid_to, OLD.deleted, 'U'
);
END;
-- Always ensure valid_to is set to far future for current version
NEW.valid_to = '9999-12-31';
NEW.updated_at = NOW();
END IF;
RETURN NEW;
END IF;
RETURN NULL;
END;
$$ LANGUAGE plpgsql;
DROP TRIGGER IF EXISTS products_versioning_trigger ON products;
CREATE TRIGGER products_versioning_trigger
BEFORE UPDATE OR DELETE ON products
FOR EACH ROW
EXECUTE FUNCTION products_versioning_trigger();
"""
# Association table for many-to-many relationship between shopping events and products
shopping_event_products = Table(
'shopping_event_products',
@ -78,6 +156,11 @@ class Product(Base):
created_at = Column(DateTime(timezone=True), server_default=func.now())
updated_at = Column(DateTime(timezone=True), onupdate=func.now())
# Temporal columns for versioning
valid_from = Column(Date, server_default=func.current_date(), nullable=False)
valid_to = Column(Date, server_default=text(FAR_FUTURE_DATE), nullable=False)
deleted = Column(Boolean, default=False, nullable=False)
# Relationships
category = relationship("GroceryCategory", back_populates="products")
brand = relationship("Brand", back_populates="products")
@ -93,6 +176,41 @@ class Product(Base):
viewonly=True
)
class ProductHistory(Base):
__tablename__ = "products_history"
history_id = Column(Integer, primary_key=True, index=True)
id = Column(Integer, nullable=False, index=True) # Original product ID
name = Column(String, nullable=False)
category_id = Column(Integer, nullable=False)
brand_id = Column(Integer, nullable=True)
organic = Column(Boolean, default=False)
weight = Column(Float, nullable=True)
weight_unit = Column(String, default="piece")
created_at = Column(DateTime(timezone=True))
updated_at = Column(DateTime(timezone=True))
# Temporal columns
valid_from = Column(Date, nullable=False)
valid_to = Column(Date, nullable=False)
deleted = Column(Boolean, default=False, nullable=False)
# Audit columns
operation = Column(String(1), nullable=False) # 'U' for Update, 'D' for Delete
archived_at = Column(DateTime(timezone=True), server_default=func.now(), nullable=False)
# Create trigger after ProductHistory table is created
@event.listens_for(ProductHistory.__table__, 'after_create')
def create_products_versioning_trigger(target, connection, **kw):
"""Create the products versioning trigger after the history table is created"""
try:
connection.execute(text(PRODUCTS_VERSIONING_TRIGGER_SQL))
print("✅ Products versioning trigger created successfully")
except Exception as e:
print(f"⚠️ Warning: Could not create products versioning trigger: {e}")
# Don't fail the entire application startup if trigger creation fails
pass
class Shop(Base):
__tablename__ = "shops"

View File

@ -1,6 +1,6 @@
from pydantic import BaseModel, Field
from typing import Optional, List
from datetime import datetime
from datetime import datetime, date
# Brand schemas
class BrandBase(BaseModel):
@ -70,7 +70,7 @@ class ProductBase(BaseModel):
weight_unit: str = "g"
class ProductCreate(ProductBase):
pass
valid_from: Optional[date] = None # If not provided, will use current date
class ProductUpdate(BaseModel):
name: Optional[str] = None
@ -79,6 +79,7 @@ class ProductUpdate(BaseModel):
organic: Optional[bool] = None
weight: Optional[float] = None
weight_unit: Optional[str] = None
valid_from: Optional[date] = None # If not provided, will use current date
class Product(ProductBase):
id: int
@ -90,6 +91,54 @@ class Product(ProductBase):
class Config:
from_attributes = True
# Historical Product schemas
class ProductHistory(BaseModel):
history_id: int
id: int # Original product ID
name: str
category_id: int
brand_id: Optional[int] = None
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
created_at: Optional[datetime] = None
updated_at: Optional[datetime] = None
valid_from: date
valid_to: date
deleted: bool
operation: str # 'U' for Update, 'D' for Delete
archived_at: datetime
class Config:
from_attributes = True
class ProductAtDate(BaseModel):
id: int
name: str
category_id: int
category: GroceryCategory
brand_id: Optional[int] = None
brand: Optional[Brand] = None
organic: bool = False
weight: Optional[float] = None
weight_unit: str = "g"
valid_from: date
valid_to: date
deleted: bool
was_current: bool # True if from current table, False if from history
class Config:
from_attributes = True
class ProductAtPurchase(BaseModel):
product: ProductAtDate
amount: float
price: float
discount: bool
class Config:
from_attributes = True
# Shop schemas
class ShopBase(BaseModel):
name: str

View File

@ -3,6 +3,6 @@ Version configuration for Groceries Tracker Backend
Single source of truth for version information
"""
__version__ = "1.0.0"
__version__ = "1.1.0"
__app_name__ = "Groceries Tracker API"
__description__ = "API for tracking grocery shopping events, products, and expenses"
__description__ = "API for tracking grocery shopping events, products, and expenses with historical data support"

View File

@ -1,6 +1,6 @@
{
"name": "groceries-tracker-frontend",
"version": "1.0.1",
"version": "1.1.0",
"private": true,
"dependencies": {
"@types/node": "^20.10.5",

View File

@ -1,5 +1,5 @@
import React, { useState, useEffect, useCallback } from 'react';
import { productApi, brandApi, groceryCategoryApi } from '../services/api';
import { productApi, brandApi, groceryCategoryApi, utilityApi } from '../services/api';
import { Product, Brand, GroceryCategory } from '../types';
import { useBodyScrollLock } from '../hooks/useBodyScrollLock';
@ -18,6 +18,7 @@ interface ProductFormData {
organic: boolean;
weight?: number;
weight_unit: string;
valid_from: string; // ISO date string (YYYY-MM-DD)
}
const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onProductAdded, editProduct, duplicateProduct }) => {
@ -27,12 +28,15 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
weight_unit: 'piece',
valid_from: ''
});
const [brands, setBrands] = useState<Brand[]>([]);
const [categories, setCategories] = useState<GroceryCategory[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState('');
const [currentDate, setCurrentDate] = useState('');
const [minValidFromDate, setMinValidFromDate] = useState('');
const weightUnits = ['piece', 'g', 'kg', 'lb', 'oz', 'ml', 'l'];
@ -44,6 +48,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
if (isOpen) {
fetchBrands();
fetchCategories();
fetchCurrentDate();
}
}, [isOpen]);
@ -65,52 +70,113 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
}
};
const fetchCurrentDate = async () => {
try {
const response = await utilityApi.getCurrentDate();
// Only update if valid_from is not already set
setFormData(prev => ({
...prev,
valid_from: prev.valid_from || response.data.current_date
}));
setCurrentDate(response.data.current_date);
setMinValidFromDate(response.data.current_date);
} catch (err) {
console.error('Failed to fetch current date:', err);
// Fallback to current date if API fails
const today = new Date().toISOString().split('T')[0];
setFormData(prev => ({
...prev,
valid_from: prev.valid_from || today
}));
setCurrentDate(today);
setMinValidFromDate(today);
}
};
// Populate form when editing or duplicating
useEffect(() => {
if (editProduct) {
if (editProduct && isOpen) {
// For editing, fetch the current valid_from to set proper constraints
const fetchProductValidFrom = async () => {
try {
const response = await productApi.getValidFromDate(editProduct.id);
const currentValidFrom = response.data.valid_from;
setMinValidFromDate(currentValidFrom);
setFormData({
name: editProduct.name,
category_id: editProduct.category_id,
brand_id: editProduct.brand_id,
organic: editProduct.organic,
weight: editProduct.weight,
weight_unit: editProduct.weight_unit
weight_unit: editProduct.weight_unit,
valid_from: currentDate // Default to today for edits
});
} else if (duplicateProduct) {
} catch (err) {
console.error('Failed to fetch product valid_from:', err);
setError('Failed to load product data for editing');
}
};
if (currentDate) {
fetchProductValidFrom();
}
} else if (duplicateProduct && isOpen) {
// For duplicating, use today as default and allow any date <= today
setMinValidFromDate('1900-01-01'); // No restriction for new products
setFormData({
name: duplicateProduct.name,
name: `${duplicateProduct.name} (Copy)`,
category_id: duplicateProduct.category_id,
brand_id: duplicateProduct.brand_id,
organic: duplicateProduct.organic,
weight: duplicateProduct.weight,
weight_unit: duplicateProduct.weight_unit
weight_unit: duplicateProduct.weight_unit,
valid_from: currentDate
});
} else {
// Reset form for adding new product
} else if (isOpen && currentDate) {
// For new products, allow any date <= today
setMinValidFromDate('1900-01-01'); // No restriction for new products
setFormData({
name: '',
category_id: undefined,
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
weight_unit: 'piece',
valid_from: currentDate
});
}
setError('');
}, [editProduct, duplicateProduct, isOpen]);
}, [editProduct, duplicateProduct, isOpen, currentDate]);
const handleSubmit = useCallback(async (e: React.FormEvent) => {
e.preventDefault();
if (!formData.name.trim() || !formData.category_id) {
if (!formData.name.trim() || !formData.category_id || !formData.valid_from) {
setError('Please fill in all required fields with valid values');
return;
}
// Validate date constraints
const validFromDate = new Date(formData.valid_from);
const today = new Date(currentDate);
if (validFromDate > today) {
setError('Valid from date cannot be in the future');
return;
}
if (editProduct) {
const minDate = new Date(minValidFromDate);
if (validFromDate <= minDate) {
setError(`Valid from date must be after the current product's valid from date (${minValidFromDate})`);
return;
}
}
try {
setLoading(true);
setError('');
const productData = {
const productData: any = {
name: formData.name.trim(),
category_id: formData.category_id!,
brand_id: formData.brand_id || undefined,
@ -119,6 +185,11 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
weight_unit: formData.weight_unit
};
// Only include valid_from if it's provided
if (formData.valid_from) {
productData.valid_from = formData.valid_from;
}
if (editProduct) {
// Update existing product
await productApi.update(editProduct.id, productData);
@ -134,7 +205,8 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
brand_id: undefined,
organic: false,
weight: undefined,
weight_unit: 'piece'
weight_unit: 'piece',
valid_from: ''
});
onProductAdded();
@ -145,7 +217,7 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
} finally {
setLoading(false);
}
}, [formData, editProduct, onProductAdded, onClose]);
}, [formData, editProduct, onProductAdded, onClose, currentDate, minValidFromDate]);
useEffect(() => {
if (!isOpen) return;
@ -228,10 +300,37 @@ const AddProductModal: React.FC<AddProductModalProps> = ({ isOpen, onClose, onPr
onChange={handleChange}
required
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
placeholder="e.g., Whole Foods Organic Milk"
placeholder="Product name"
/>
</div>
<div>
<label htmlFor="valid_from" className="block text-sm font-medium text-gray-700">
Valid from *
</label>
<input
type="date"
id="valid_from"
name="valid_from"
value={formData.valid_from}
onChange={handleChange}
required
min={editProduct ? (() => {
const nextDay = new Date(minValidFromDate);
nextDay.setDate(nextDay.getDate() + 1);
return nextDay.toISOString().split('T')[0];
})() : undefined}
max={currentDate}
className="mt-1 block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm focus:outline-none focus:ring-blue-500 focus:border-blue-500"
/>
<p className="mt-1 text-xs text-gray-500">
{editProduct
? `Must be after ${minValidFromDate} and not in the future`
: 'The date when this product information becomes effective (cannot be in the future)'
}
</p>
</div>
<div>
<label htmlFor="category_id" className="block text-sm font-medium text-gray-700">
Category *

View File

@ -190,7 +190,11 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
const fetchProducts = async () => {
try {
const response = await productApi.getAll();
// If we have a shopping date, get products available for that date
// Otherwise, get all non-deleted products
const response = formData.date
? await productApi.getAvailableForShopping(formData.date)
: await productApi.getAll(false); // false = don't show deleted
setProducts(response.data);
} catch (error) {
console.error('Error fetching products:', error);
@ -223,6 +227,13 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
}
}, [formData.shop_id]);
// Effect to refetch products when shopping date changes
useEffect(() => {
if (isOpen && formData.date) {
fetchProducts();
}
}, [formData.date, isOpen]);
const addProductToEvent = () => {
if (newProductItem.product_id > 0 && newProductItem.amount > 0 && newProductItem.price >= 0) {
setSelectedProducts([...selectedProducts, { ...newProductItem }]);
@ -483,7 +494,7 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
))}
</select>
</div>
<div className="w-24">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Amount
</label>
@ -494,10 +505,10 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
placeholder="1"
value={newProductItem.amount}
onChange={(e) => setNewProductItem({...newProductItem, amount: parseFloat(e.target.value)})}
className="w-full h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="w-24">
<div>
<label className="block text-xs font-medium text-gray-700 mb-1">
Price ($)
</label>
@ -508,165 +519,133 @@ const AddShoppingEventModal: React.FC<AddShoppingEventModalProps> = ({
placeholder="0.00"
value={newProductItem.price}
onChange={(e) => setNewProductItem({...newProductItem, price: parseFloat(e.target.value)})}
className="w-full h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
className="w-24 h-10 border border-gray-300 rounded-md px-3 py-2 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div className="w-20">
<label className="block text-xs font-medium text-gray-700 mb-1 text-center">
Discount
</label>
<div className="h-10 flex items-center justify-center border border-gray-300 rounded-md bg-gray-50">
<div className="flex items-center">
<label className="flex items-center space-x-2">
<input
type="checkbox"
checked={newProductItem.discount}
onChange={(e) => setNewProductItem({...newProductItem, discount: e.target.checked})}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
className="h-5 w-5 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
</div>
</div>
<div className="w-16">
<label className="block text-xs font-medium text-gray-700 mb-1 opacity-0">
Action
<span className="text-xs font-medium text-gray-700">Discount</span>
</label>
</div>
<div className="flex items-end">
<button
type="button"
onClick={addProductToEvent}
className="w-full h-10 bg-green-500 hover:bg-green-700 text-white px-3 py-2 rounded-md font-medium"
className="px-6 py-2 bg-green-500 hover:bg-green-700 text-white rounded-md font-medium text-sm"
>
Add
Add Product
</button>
</div>
</div>
{formData.shop_id > 0 && (
<p className="text-xs text-gray-500 mb-4">
{shopBrands.length === 0
? `Showing all ${products.length} products (no brand restrictions for this shop)`
: `Showing ${getFilteredProducts().length} of ${products.length} products (filtered by shop's available brands)`
}
</p>
)}
{/* Selected Products List */}
{selectedProducts.length > 0 && (
<div className="bg-gray-50 rounded-md p-4 max-h-40 md:max-h-48 overflow-y-auto">
<h4 className="font-medium text-gray-700 mb-2">Selected Items:</h4>
{Object.entries(
selectedProducts.reduce((groups, item, index) => {
const product = products.find(p => p.id === item.product_id);
const category = product?.category.name || 'Unknown';
if (!groups[category]) {
groups[category] = [];
}
groups[category].push({ ...item, index });
return groups;
}, {} as Record<string, (ProductInEvent & { index: number })[]>)
)
.sort(([a], [b]) => a.localeCompare(b))
.map(([category, categoryItems]) => (
<div key={category} className="mb-3 last:mb-0">
<div className="text-xs font-semibold text-gray-600 uppercase tracking-wide mb-1 border-b border-gray-300 pb-1">
{category}
</div>
{categoryItems.map((item) => (
<div key={item.index} className="flex flex-col md:flex-row md:justify-between md:items-center py-2 pl-2 space-y-2 md:space-y-0">
<div className="flex-1 min-w-0">
<div className="text-sm text-gray-900">
{getProductName(item.product_id)}
</div>
<div className="text-xs text-gray-600">
{item.amount} × ${item.price.toFixed(2)} = ${(item.amount * item.price).toFixed(2)}
{item.discount && <span className="ml-2 text-green-600 font-medium">🏷</span>}
</div>
</div>
<div className="flex space-x-2 md:flex-shrink-0">
<div className="overflow-x-auto">
<table className="min-w-full divide-y divide-gray-200">
<thead className="bg-gray-50">
<tr>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Product
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Amount
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Price ($)
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Discount
</th>
<th scope="col" className="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase tracking-wider">
Total ($)
</th>
<th scope="col" className="relative px-6 py-3">
<span className="sr-only">Edit</span>
</th>
</tr>
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{selectedProducts.map((product, index) => (
<tr key={index}>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium text-gray-900">
{getProductName(product.product_id)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.amount}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.price.toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{product.discount ? 'Yes' : 'No'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-500">
{(product.amount * product.price).toFixed(2)}
</td>
<td className="px-6 py-4 whitespace-nowrap text-right text-sm font-medium">
<button
type="button"
onClick={() => editProductFromEvent(item.index)}
className="flex-1 md:flex-none px-3 py-1 text-blue-500 hover:text-blue-700 border border-blue-300 hover:bg-blue-50 rounded text-sm"
onClick={() => editProductFromEvent(index)}
className="text-indigo-600 hover:text-indigo-900 mr-2"
>
Edit
</button>
<button
type="button"
onClick={() => removeProductFromEvent(item.index)}
className="flex-1 md:flex-none px-3 py-1 text-red-500 hover:text-red-700 border border-red-300 hover:bg-red-50 rounded text-sm"
onClick={() => removeProductFromEvent(index)}
className="text-red-600 hover:text-red-900"
>
Remove
</button>
</div>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
))}
</div>
)}
</div>
{/* Total Amount */}
<div>
<div className="flex items-center space-x-2 mb-2">
<label className="block text-sm font-medium text-gray-700">
{/* Total Amount and Notes */}
<div className="flex flex-col md:flex-row md:space-x-4 space-y-4 md:space-y-0">
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Total Amount ($)
</label>
<label className="flex items-center space-x-1">
<input
type="checkbox"
checked={autoCalculate}
onChange={(e) => setAutoCalculate(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="text-xs text-gray-600">Auto-calculate</span>
</label>
</div>
<input
type="number"
step="0.01"
min="0"
placeholder="0.00"
value={formData.total_amount || ''}
onChange={(e) => setFormData({...formData, total_amount: e.target.value ? parseFloat(e.target.value) : undefined})}
disabled={autoCalculate}
className={`w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500 ${
autoCalculate ? 'bg-gray-100 cursor-not-allowed' : ''
}`}
value={formData.total_amount}
onChange={(e) => setFormData({...formData, total_amount: parseFloat(e.target.value)})}
className="w-full h-12 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
{autoCalculate && selectedProducts.length > 0 && (
<p className="text-xs text-gray-500 mt-1">
Automatically calculated from selected products: ${calculateTotal(selectedProducts).toFixed(2)}
</p>
)}
</div>
{/* Notes */}
<div>
<div className="flex-1">
<label className="block text-sm font-medium text-gray-700 mb-2">
Notes (Optional)
Notes
</label>
<textarea
value={formData.notes}
onChange={(e) => setFormData({...formData, notes: e.target.value})}
rows={3}
className="w-full border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
placeholder="Add any notes about this shopping event..."
className="w-full h-24 md:h-10 border border-gray-300 rounded-md px-3 py-2 text-base md:text-sm focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
</div>
{/* Submit Button */}
<div className="flex flex-col md:flex-row md:justify-end space-y-3 md:space-y-0 md:space-x-3 pt-4">
<button
type="button"
onClick={onClose}
className="w-full md:w-auto px-6 py-3 md:py-2 border border-gray-300 text-gray-700 rounded-md hover:bg-gray-50 font-medium text-base md:text-sm"
>
Cancel
</button>
{/* Save Button */}
<div className="flex justify-end">
<button
type="submit"
disabled={loading || formData.shop_id === 0 || selectedProducts.length === 0}
className="w-full md:w-auto px-6 py-3 md:py-2 bg-blue-500 hover:bg-blue-700 disabled:bg-gray-300 disabled:cursor-not-allowed text-white rounded-md font-medium text-base md:text-sm"
disabled={loading}
className="px-6 py-3 bg-blue-500 hover:bg-blue-700 text-white rounded-md font-medium text-base disabled:opacity-50"
>
{loading ? 'Saving...' : (isEditMode ? 'Update Event' : 'Create Event')}
{loading ? 'Saving...' : 'Save'}
</button>
</div>
</form>

View File

@ -17,6 +17,7 @@ const ProductList: React.FC = () => {
const [duplicatingProduct, setDuplicatingProduct] = useState<Product | null>(null);
const [sortField, setSortField] = useState<string>('name');
const [sortDirection, setSortDirection] = useState<'asc' | 'desc'>('asc');
const [showDeleted, setShowDeleted] = useState(false);
useEffect(() => {
fetchProducts();
@ -27,12 +28,12 @@ const ProductList: React.FC = () => {
// Remove the parameter from URL
setSearchParams({});
}
}, [searchParams, setSearchParams]);
}, [searchParams, setSearchParams, showDeleted]);
const fetchProducts = async () => {
try {
setLoading(true);
const response = await productApi.getAll();
const response = await productApi.getAll(showDeleted);
setProducts(response.data);
} catch (err) {
setError('Failed to fetch products');
@ -180,6 +181,16 @@ const ProductList: React.FC = () => {
<div className="space-y-6">
<div className="flex flex-col sm:flex-row sm:justify-between sm:items-center space-y-4 sm:space-y-0">
<h1 className="text-xl md:text-2xl font-bold text-gray-900">Products</h1>
<div className="flex flex-col sm:flex-row sm:items-center space-y-2 sm:space-y-0 sm:space-x-4">
<label className="flex items-center">
<input
type="checkbox"
checked={showDeleted}
onChange={(e) => setShowDeleted(e.target.checked)}
className="h-4 w-4 text-blue-600 focus:ring-blue-500 border-gray-300 rounded"
/>
<span className="ml-2 text-sm text-gray-700">Show deleted</span>
</label>
<button
onClick={() => {
setEditingProduct(null);
@ -191,6 +202,7 @@ const ProductList: React.FC = () => {
Add New Product
</button>
</div>
</div>
{error && (
<div className="bg-red-50 border border-red-200 text-red-700 px-4 py-3 rounded">
@ -257,22 +269,24 @@ const ProductList: React.FC = () => {
</thead>
<tbody className="bg-white divide-y divide-gray-200">
{sortedProducts.map((product) => (
<tr key={product.id} className="hover:bg-gray-50">
<tr key={product.id} className={`hover:bg-gray-50 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
<td className="px-6 py-4 whitespace-nowrap">
<div className="text-sm font-medium text-gray-900">
{product.name} {product.organic ? '🌱' : ''}
<div className={`text-sm font-medium ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
</div>
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.category.name}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.brand ? product.brand.name : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm text-gray-900">
<td className={`px-6 py-4 whitespace-nowrap text-sm ${product.deleted ? 'text-gray-500' : 'text-gray-900'}`}>
{product.weight ? `${product.weight}${product.weight_unit}` : '-'}
</td>
<td className="px-6 py-4 whitespace-nowrap text-sm font-medium">
{!product.deleted ? (
<>
<button
onClick={() => handleEdit(product)}
className="text-indigo-600 hover:text-indigo-900 mr-3"
@ -291,6 +305,10 @@ const ProductList: React.FC = () => {
>
Delete
</button>
</>
) : (
<span className="text-gray-400 text-sm">Deleted</span>
)}
</td>
</tr>
))}
@ -301,29 +319,30 @@ const ProductList: React.FC = () => {
{/* Mobile Card Layout */}
<div className="md:hidden">
{sortedProducts.map((product) => (
<div key={product.id} className="border-b border-gray-200 p-4 last:border-b-0">
<div key={product.id} className={`border-b border-gray-200 p-4 last:border-b-0 ${product.deleted ? 'bg-red-50 opacity-75' : ''}`}>
<div className="flex justify-between items-start mb-3">
<div className="flex-1 min-w-0">
<h3 className="font-medium text-gray-900 truncate">
{product.name} {product.organic ? '🌱' : ''}
<h3 className={`font-medium truncate ${product.deleted ? 'text-gray-500 line-through' : 'text-gray-900'}`}>
{product.name} {product.organic ? '🌱' : ''} {product.deleted ? '🗑️' : ''}
</h3>
<p className="text-sm text-gray-500">{product.category.name}</p>
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-500'}`}>{product.category.name}</p>
</div>
{product.weight && (
<div className="text-right flex-shrink-0 ml-4">
<p className="text-sm text-gray-600">{product.weight}{product.weight_unit}</p>
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>{product.weight}{product.weight_unit}</p>
</div>
)}
</div>
{product.brand && (
<div className="mb-3">
<p className="text-sm text-gray-600">
<p className={`text-sm ${product.deleted ? 'text-gray-400' : 'text-gray-600'}`}>
<span className="font-medium">Brand:</span> {product.brand.name}
</p>
</div>
)}
{!product.deleted ? (
<div className="flex space-x-4">
<button
onClick={() => handleEdit(product)}
@ -344,6 +363,11 @@ const ProductList: React.FC = () => {
Delete
</button>
</div>
) : (
<div className="text-center py-2">
<span className="text-gray-400 text-sm">Deleted</span>
</div>
)}
</div>
))}
</div>

View File

@ -26,8 +26,10 @@ const api = {
// Product API functions
export const productApi = {
getAll: () => api.get<Product[]>('/products/'),
getAll: (showDeleted: boolean = false) => api.get<Product[]>(`/products/?show_deleted=${showDeleted}`),
getById: (id: number) => api.get<Product>(`/products/${id}`),
getValidFromDate: (id: number) => api.get<{ valid_from: string }>(`/products/${id}/valid-from`),
getAvailableForShopping: (shoppingDate: string) => api.get<Product[]>(`/products/available-for-shopping/${shoppingDate}`),
create: (product: ProductCreate) => api.post<Product>('/products/', product),
update: (id: number, product: Partial<ProductCreate>) =>
api.put<Product>(`/products/${id}`, product),
@ -89,4 +91,9 @@ export const statsApi = {
getShops: () => api.get('/stats/shops'),
};
// Utility API functions
export const utilityApi = {
getCurrentDate: () => api.get<{ current_date: string }>('/current-date'),
};
export default api;

View File

@ -30,6 +30,7 @@ export interface Product {
organic: boolean;
weight?: number;
weight_unit: string;
deleted?: boolean;
created_at: string;
updated_at?: string;
}
@ -41,6 +42,7 @@ export interface ProductCreate {
organic: boolean;
weight?: number;
weight_unit: string;
valid_from?: string; // Optional: ISO date string (YYYY-MM-DD), defaults to current date if not provided
}
export interface Shop {

127
test_temporal_logic.md Normal file
View File

@ -0,0 +1,127 @@
# Temporal Logic Test Scenarios
## Test Case 1: Add New Product
### Frontend Behavior:
- **Default valid_from**: Today's date (e.g., 2025-01-15)
- **User constraint**: Can edit to any date <= today
- **Date picker**: max="2025-01-15", no min restriction
### API Call:
```json
POST /products/
{
"name": "New Milk",
"category_id": 1,
"weight": 1000,
"valid_from": "2025-01-10" // User chose 5 days ago
}
```
### Backend Validation:
- ✅ valid_from <= today (2025-01-10 <= 2025-01-15)
### Database Result:
```sql
products: id=1, name="New Milk", weight=1000, valid_from='2025-01-10', valid_to='9999-12-31'
```
---
## Test Case 2: Edit Existing Product
### Current State:
```sql
products: id=1, name="Milk", weight=1000, valid_from='2025-01-10', valid_to='9999-12-31'
```
### Frontend Behavior:
- **Fetch current valid_from**: API call to `/products/1/valid-from` → "2025-01-10"
- **Default valid_from**: Today's date (2025-01-15)
- **User constraint**: Can edit to any date > 2025-01-10 AND <= today
- **Date picker**: min="2025-01-10", max="2025-01-15"
### API Call:
```json
PUT /products/1
{
"weight": 1200,
"valid_from": "2025-01-12" // User chose 2 days after current valid_from
}
```
### Backend Validation:
- ✅ valid_from <= today (2025-01-12 <= 2025-01-15)
- ✅ valid_from > current_valid_from (2025-01-12 > 2025-01-10)
### Backend Processing:
1. **Manual versioning** (since valid_from was specified):
- Create history record with old data
- Set history.valid_to = new.valid_from
### Database Result:
```sql
-- History table gets the old version
products_history:
id=1, name="Milk", weight=1000,
valid_from='2025-01-10', valid_to='2025-01-12', operation='U'
-- Current table gets updated version
products:
id=1, name="Milk", weight=1200,
valid_from='2025-01-12', valid_to='9999-12-31'
```
---
## Test Case 3: Edit Without Custom Date (Automatic Versioning)
### Current State:
```sql
products: id=1, name="Milk", weight=1200, valid_from='2025-01-12', valid_to='9999-12-31'
```
### API Call (no valid_from specified):
```json
PUT /products/1
{
"name": "Organic Milk"
}
```
### Backend Processing:
1. **Automatic versioning** (trigger handles it):
- Trigger detects OLD.valid_from = NEW.valid_from
- Trigger creates history with valid_to = CURRENT_DATE
- Trigger sets NEW.valid_from = CURRENT_DATE
### Database Result:
```sql
-- History table gets the old version (trigger created)
products_history:
id=1, name="Milk", weight=1200,
valid_from='2025-01-12', valid_to='2025-01-15', operation='U'
-- Current table gets updated version (trigger set dates)
products:
id=1, name="Organic Milk", weight=1200,
valid_from='2025-01-15', valid_to='9999-12-31'
```
---
## Validation Rules Summary
### For New Products:
- ✅ Frontend: valid_from <= today
- ✅ Backend: valid_from <= today
### For Existing Products:
- ✅ Frontend: current_valid_from < valid_from <= today
- ✅ Backend: current_valid_from < valid_from <= today
- ✅ Manual versioning: history.valid_to = new.valid_from
- ✅ Automatic versioning: history.valid_to = CURRENT_DATE
### Database Trigger Logic:
- **Manual versioning**: When OLD.valid_from ≠ NEW.valid_from (app handles history)
- **Automatic versioning**: When OLD.valid_from = NEW.valid_from (trigger handles history)