import sqlite3 import os import shutil import logging from datetime import datetime, date from typing import Tuple, Optional, List, Dict """ database.py - Manages database operations for ShopEase, a grocery inventory management app. Uses SQLite for storage with dynamic path handling for local and Hugging Face Spaces deployment. Supports product names and units in English and Bengali (e.g., "Sugar" or "চিনি"). Includes indexing, pruning, and backup features for scalability and reliability. """ # Configure logging with file output for persistence logging.basicConfig( level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s', handlers=[ logging.FileHandler('shopease.log'), # Save logs to a file logging.StreamHandler() ] ) logger = logging.getLogger(__name__) # Determine database path dynamically BASE_DIR = os.path.dirname(os.path.abspath(__file__)) DATA_DIR = os.path.join(BASE_DIR, 'data') DB_PATH = os.path.join(DATA_DIR, 'inventory.db') # Create data directory if it doesn’t exist (local use) if not os.path.exists(DATA_DIR): os.makedirs(DATA_DIR) logger.info(f"Created data directory at {DATA_DIR}") # Use Hugging Face Spaces path if available if os.path.exists('/data'): DB_PATH = '/data/inventory.db' logger.info("Using Hugging Face Spaces database path: /data/inventory.db") else: logger.info(f"Using local database path: {DB_PATH}") # Log database file status if os.path.exists(DB_PATH): logger.info(f"Database file exists at {DB_PATH}, size: {os.path.getsize(DB_PATH)} bytes, last modified: {datetime.fromtimestamp(os.path.getmtime(DB_PATH))}") else: logger.warning(f"Database file does not exist at {DB_PATH}, will create new database") # Connect to SQLite database with WAL mode for better concurrency try: conn = sqlite3.connect(DB_PATH, check_same_thread=False) conn.execute("PRAGMA journal_mode=WAL;") # Enable Write-Ahead Logging c = conn.cursor() logger.info("Successfully connected to the database with WAL mode") except sqlite3.Error as e: logger.error(f"Database connection failed: {e}") raise # Create tables and indexes try: c.execute('''CREATE TABLE IF NOT EXISTS products (id INTEGER PRIMARY KEY AUTOINCREMENT, name TEXT NOT NULL, unit TEXT NOT NULL)''') c.execute('''CREATE TABLE IF NOT EXISTS transactions (id INTEGER PRIMARY KEY AUTOINCREMENT, product_id INTEGER NOT NULL, quantity REAL NOT NULL, price REAL, date TEXT NOT NULL, FOREIGN KEY(product_id) REFERENCES products(id))''') c.execute('''CREATE TABLE IF NOT EXISTS daily_summaries (date TEXT PRIMARY KEY, cash_in REAL NOT NULL, cash_out REAL NOT NULL, purchase_costs REAL NOT NULL, profit_loss REAL NOT NULL)''') c.execute("CREATE INDEX IF NOT EXISTS idx_transactions_product_date ON transactions(product_id, date)") c.execute("CREATE INDEX IF NOT EXISTS idx_daily_summaries_date ON daily_summaries(date)") conn.commit() logger.info("Tables and indexes created/verified successfully") except sqlite3.Error as e: logger.error(f"Error creating tables or indexes: {e}") raise # Database Functions def add_product(name: str, unit: str) -> None: """Add a new product to the products table.""" try: c.execute("INSERT INTO products (name, unit) VALUES (?, ?)", (name.strip(), unit.strip())) conn.commit() logger.info(f"Added product: {name} with unit {unit}") except sqlite3.Error as e: logger.error(f"Error adding product {name}: {e}") raise def add_transaction(product_id: int, quantity: float, price: Optional[float] = None) -> None: """Add a transaction (purchase or sale) to the transactions table.""" try: date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") c.execute("INSERT INTO transactions (product_id, quantity, price, date) VALUES (?, ?, ?, ?)", (product_id, quantity, price, date)) conn.commit() logger.info(f"Added transaction for product_id {product_id}: {quantity} at total cost {price} INR on {date}") except sqlite3.Error as e: logger.error(f"Error adding transaction for product_id {product_id}: {e}") raise def get_current_quantity(product_id: int) -> float: """Calculate the current quantity of a product.""" try: c.execute("SELECT SUM(quantity) FROM transactions WHERE product_id = ?", (product_id,)) result = c.fetchone()[0] return result if result is not None else 0.0 except sqlite3.Error as e: logger.error(f"Error getting current quantity for product_id {product_id}: {e}") raise def get_last_price_before_date(product_id: int, target_date: Optional[str] = None) -> Optional[float]: """Get the last total purchase cost before a specific date.""" try: if target_date is None: target_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") c.execute("SELECT price FROM transactions WHERE product_id = ? AND quantity > 0 AND price IS NOT NULL AND date <= ? ORDER BY date DESC LIMIT 1", (product_id, target_date)) result = c.fetchone() return result[0] if result else None except sqlite3.Error as e: logger.error(f"Error getting last price for product_id {product_id} on {target_date}: {e}") raise def get_last_purchase_date(product_id: int, target_date: Optional[str] = None) -> Optional[str]: """Get the date of the last purchase before a specific date.""" try: if target_date is None: target_date = datetime.now().strftime("%Y-%m-%d %H:%M:%S") c.execute("SELECT date FROM transactions WHERE product_id = ? AND quantity > 0 AND date <= ? ORDER BY date DESC LIMIT 1", (product_id, target_date)) result = c.fetchone() return result[0] if result else None except sqlite3.Error as e: logger.error(f"Error getting last purchase date for product_id {product_id} on {target_date}: {e}") raise def get_purchase_history(product_id: int, start_date: Optional[date] = None, end_date: Optional[date] = None) -> List[Tuple[str, float, float]]: """Retrieve purchase history for a product within a date range.""" try: query = "SELECT date, quantity, price FROM transactions WHERE product_id = ? AND quantity > 0" params = [product_id] if start_date: query += " AND date >= ?" params.append(start_date.strftime("%Y-%m-%d 00:00:00")) if end_date: query += " AND date <= ?" params.append(end_date.strftime("%Y-%m-%d 23:59:59")) query += " ORDER BY date ASC" c.execute(query, params) return c.fetchall() except sqlite3.Error as e: logger.error(f"Error retrieving purchase history for product_id {product_id}: {e}") raise def get_all_products() -> List[Tuple[int, str, str]]: """Retrieve all products.""" try: c.execute("SELECT id, name, unit FROM products") return c.fetchall() except sqlite3.Error as e: logger.error(f"Error retrieving all products: {e}") raise def get_product_id(name: str) -> Optional[int]: """Get the ID of a product by its name.""" try: c.execute("SELECT id FROM products WHERE name = ?", (name.strip(),)) result = c.fetchone() return result[0] if result else None except sqlite3.Error as e: logger.error(f"Error getting ID for product {name}: {e}") raise def get_transactions_by_date(selected_date: date) -> List[Tuple[int, str, float, float, str, str]]: """Retrieve all purchase transactions for a specific date.""" try: start_date = selected_date.strftime("%Y-%m-%d 00:00:00") end_date = selected_date.strftime("%Y-%m-%d 23:59:59") c.execute("SELECT t.id, p.name, t.quantity, t.price, p.unit, t.date FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.quantity > 0 AND t.date BETWEEN ? AND ?", (start_date, end_date)) return c.fetchall() except sqlite3.Error as e: logger.error(f"Error retrieving transactions for {selected_date}: {e}") raise def delete_transaction(transaction_id: int) -> Tuple[bool, str]: """Delete a specific transaction and clean up products if no transactions remain.""" try: c.execute("SELECT product_id, quantity FROM transactions WHERE id = ?", (transaction_id,)) trans = c.fetchone() if trans: product_id, quantity = trans current_qty = get_current_quantity(product_id) if current_qty - quantity < 0: return False, "Cannot delete: would result in negative stock." c.execute("DELETE FROM transactions WHERE id = ?", (transaction_id,)) conn.commit() logger.info(f"Deleted transaction {transaction_id} for product_id {product_id}") c.execute("SELECT COUNT(*) FROM transactions WHERE product_id = ?", (product_id,)) if c.fetchone()[0] == 0: c.execute("DELETE FROM products WHERE id = ?", (product_id,)) conn.commit() logger.info(f"Cleaned up product_id {product_id} with no remaining transactions") return True, "Transaction deleted successfully." return False, "Transaction not found." except sqlite3.Error as e: logger.error(f"Error deleting transaction {transaction_id}: {e}") raise def get_daily_transactions(selected_date: date) -> List[Tuple[str, float, float, str]]: """Retrieve all transactions (purchases and sales) for a specific date.""" try: start_date = selected_date.strftime("%Y-%m-%d 00:00:00") end_date = selected_date.strftime("%Y-%m-%d 23:59:59") c.execute(""" SELECT p.name, t.quantity, t.price, CASE WHEN t.quantity > 0 THEN 'purchase' ELSE 'sale' END as type FROM transactions t JOIN products p ON t.product_id = p.id WHERE t.date BETWEEN ? AND ? """, (start_date, end_date)) return c.fetchall() except sqlite3.Error as e: logger.error(f"Error retrieving daily transactions for {selected_date}: {e}") raise def calculate_daily_earnings(selected_date: date) -> float: """Calculate daily earnings (sales revenue - purchase costs).""" try: transactions = get_daily_transactions(selected_date) purchase_cost = sum(price for _, quantity, price, trans_type in transactions if trans_type == 'purchase' and price is not None) sale_revenue = sum(price * abs(quantity) for _, quantity, price, trans_type in transactions if trans_type == 'sale' and price is not None) net_earnings = sale_revenue - purchase_cost logger.info(f"Calculated daily earnings for {selected_date}: Sales Revenue={sale_revenue}, Purchase Cost={purchase_cost}, Net={net_earnings}") return net_earnings except Exception as e: logger.error(f"Error calculating daily earnings for {selected_date}: {e}") raise def estimate_daily_needs(selected_date: date) -> Dict[str, float]: """Estimate daily product needs based on sales and inventory.""" try: transactions = get_daily_transactions(selected_date) needs = {} for product_name, quantity, _, trans_type in transactions: if trans_type == 'sale': needs[product_name] = needs.get(product_name, 0) + abs(quantity) for product_id, name, unit in get_all_products(): current_qty = get_current_quantity(product_id) if name in needs and current_qty < needs[name]: needs[name] = max(0, needs[name] - current_qty) elif name not in needs and current_qty == 0: needs[name] = 0.0 return needs except Exception as e: logger.error(f"Error estimating daily needs for {selected_date}: {e}") raise def save_daily_summary(selected_date: date, cash_in: float, cash_out: float, purchase_costs: float, profit_loss: float) -> None: """Save or update a daily cash flow summary.""" try: date_str = selected_date.isoformat() cash_in = float(cash_in) if cash_in is not None else 0.0 cash_out = float(cash_out) if cash_out is not None else 0.0 purchase_costs = float(purchase_costs) if purchase_costs is not None else 0.0 profit_loss = float(profit_loss) if profit_loss is not None else 0.0 c.execute(""" INSERT OR REPLACE INTO daily_summaries (date, cash_in, cash_out, purchase_costs, profit_loss) VALUES (?, ?, ?, ?, ?) """, (date_str, cash_in, cash_out, purchase_costs, profit_loss)) conn.commit() logger.info(f"Saved daily summary for {date_str}: Cash In={cash_in}, Cash Out={cash_out}, Purchase Costs={purchase_costs}, Profit/Loss={profit_loss}") except (sqlite3.Error, ValueError) as e: logger.error(f"Error saving daily summary for {selected_date}: {e}") raise def get_daily_summary(selected_date: date) -> Optional[Tuple[float, float, float, float]]: """Retrieve the daily cash flow summary for a specific date.""" try: date_str = selected_date.isoformat() logger.info(f"Querying daily summary for date: {date_str}") c.execute("SELECT cash_in, cash_out, purchase_costs, profit_loss FROM daily_summaries WHERE date = ?", (date_str,)) result = c.fetchone() if result: logger.info(f"Found summary for {date_str}: {result}") return (float(result[0]), float(result[1]), float(result[2]), float(result[3])) logger.info(f"No summary found for {date_str}") return None except sqlite3.Error as e: logger.error(f"Error retrieving daily summary for {selected_date}: {e}") raise def delete_daily_summary(selected_date: date) -> Tuple[bool, str]: """Delete a daily summary and associated purchase transactions with confirmation.""" try: date_str = selected_date.isoformat() start_date = selected_date.strftime("%Y-%m-%d 00:00:00") end_date = selected_date.strftime("%Y-%m-%d 23:59:59") # Check if summary exists c.execute("SELECT date FROM daily_summaries WHERE date = ?", (date_str,)) summary_exists = c.fetchone() is not None logger.info(f"Before deletion, summary exists for {date_str}: {summary_exists}") # Count transactions to delete c.execute("SELECT COUNT(*) FROM transactions WHERE date BETWEEN ? AND ? AND quantity > 0", (start_date, end_date)) trans_count = c.fetchone()[0] logger.info(f"Found {trans_count} purchase transactions to delete for {date_str}") # Delete transactions c.execute("DELETE FROM transactions WHERE date BETWEEN ? AND ? AND quantity > 0", (start_date, end_date)) trans_deleted = c.rowcount logger.info(f"Deleted {trans_deleted} transactions for {date_str}") # Delete summary c.execute("DELETE FROM daily_summaries WHERE date = ?", (date_str,)) summary_deleted = c.rowcount logger.info(f"Deleted {summary_deleted} summary rows for {date_str}") conn.commit() # Verify deletion c.execute("SELECT date FROM daily_summaries WHERE date = ?", (date_str,)) post_check = c.fetchone() if post_check is not None: logger.error(f"Deletion failed: Summary still exists for {date_str} after delete: {post_check}") raise sqlite3.Error(f"Failed to delete summary for {date_str}") if summary_exists or trans_deleted > 0: message = f"Daily summary and {trans_deleted} transaction(s) for {date_str} deleted successfully." logger.info(message) return True, message else: message = f"No daily summary or transactions found for {date_str}." logger.info(message) return False, message except sqlite3.Error as e: logger.error(f"Error deleting daily summary and transactions for {selected_date}: {e}") conn.rollback() raise def get_summaries_by_range(start_date: date, end_date: date) -> List[Tuple[date, float, float, float, float]]: """Retrieve all daily summaries within a date range.""" try: start_str = start_date.isoformat() end_str = end_date.isoformat() logger.info(f"Querying summaries for range {start_str} to {end_str}") c.execute("SELECT date, cash_in, cash_out, purchase_costs, profit_loss FROM daily_summaries WHERE date BETWEEN ? AND ? ORDER BY date", (start_str, end_str)) results = c.fetchall() summaries = [(datetime.strptime(r[0], "%Y-%m-%d").date(), float(r[1]), float(r[2]), float(r[3]), float(r[4])) for r in results] logger.info(f"Retrieved {len(summaries)} summaries for range {start_str} to {end_str}") return summaries except sqlite3.Error as e: logger.error(f"Error retrieving summaries for range {start_date} to {end_date}: {e}") raise def prune_old_transactions(keep_years: int = 2) -> None: """Remove transactions older than a specified number of years with safeguards.""" try: if keep_years < 1: logger.error("Pruning with keep_years < 1 is not allowed to prevent accidental data loss") raise ValueError("keep_years must be at least 1") cutoff_date = datetime.now().replace(year=datetime.now().year - keep_years).strftime("%Y-%m-%d %H:%M:%S") c.execute("SELECT COUNT(*) FROM transactions WHERE date < ?", (cutoff_date,)) count = c.fetchone()[0] logger.info(f"Found {count} transactions to prune before {cutoff_date}") c.execute("DELETE FROM transactions WHERE date < ?", (cutoff_date,)) deleted_count = c.rowcount conn.commit() logger.info(f"Pruned {deleted_count} transactions older than {keep_years} years (cutoff: {cutoff_date})") except (sqlite3.Error, ValueError) as e: logger.error(f"Error pruning old transactions: {e}") raise def backup_database(backup_path: str = None) -> None: """Create a backup of the current database with timestamp.""" try: if backup_path is None: timestamp = datetime.now().strftime("%Y%m%d_%H%M%S") backup_path = os.path.join(DATA_DIR, f"backup_inventory_{timestamp}.db") shutil.copy2(DB_PATH, backup_path) logger.info(f"Backed up database to {backup_path}") except OSError as e: logger.error(f"Error backing up database: {e}") raise # Periodic backup on startup try: backup_database() except Exception as e: logger.error(f"Failed to create initial backup: {e}") # Note: Connection is left open for Streamlit; close manually if needed in other contexts # conn.close()