Inventory / app.py
Lukeetah's picture
Update app.py
0b5603a verified
# -*- coding: utf-8 -*-
"""
Business Automation Suite - AI-Ready Core v3.0 (User Management Added)
Designed by Gemini Advanced Development Unit under impeccable direction.
Target Platform: Hugging Face Spaces (Free Tier)
Architecture: Single-File Ultra-Efficient Application with API Backend
"""
import sqlite3
import hashlib
import secrets
import datetime
import json
import os
from flask import Flask, request, jsonify, session, render_template_string, redirect, url_for, Response
from functools import wraps
from typing import Dict, List, Tuple, Optional, Any, Union
# --- Constants and Configuration ---
DB_NAME = "business_suite_v3.db" # Use a different DB name for v3 or keep v2 name if migrating data
SALT_LENGTH = 16
SECRET_KEY = secrets.token_hex(24) # Regenerate or get from environment in production
# --- Database Core: The Central Nervous System ---
def get_db_connection() -> sqlite3.Connection:
"""Establishes and returns a database connection. Enables Foreign Keys."""
conn = sqlite3.connect(DB_NAME, check_same_thread=False) # check_same_thread=False is needed for Flask/multi-threading
conn.row_factory = sqlite3.Row
conn.execute("PRAGMA foreign_keys = ON;")
return conn
def setup_database():
"""Initializes the database schema if it doesn't exist. Idempotent."""
required_tables = {"users", "business_config", "inventory_items", "suppliers", "purchase_suggestions", "event_log"}
conn_check = None
try:
db_exists = os.path.exists(DB_NAME)
if db_exists:
conn_check = get_db_connection()
cursor_check = conn_check.cursor()
cursor_check.execute("SELECT name FROM sqlite_master WHERE type='table';")
existing_tables = {row[0] for row in cursor_check.fetchall()}
conn_check.close()
else:
existing_tables = set()
if not required_tables.issubset(existing_tables):
print("INFO: Database schema not found or incomplete. Initializing...")
conn = get_db_connection()
cursor = conn.cursor()
# Users Table (Schema unchanged, but central to this update)
cursor.execute("""
CREATE TABLE IF NOT EXISTS users (
username TEXT PRIMARY KEY,
hashed_password TEXT NOT NULL,
salt TEXT NOT NULL,
is_admin INTEGER DEFAULT 0 NOT NULL,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Business Configuration
cursor.execute("""
CREATE TABLE IF NOT EXISTS business_config (
config_key TEXT PRIMARY KEY,
config_value TEXT,
description TEXT,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Inventory Items
cursor.execute("""
CREATE TABLE IF NOT EXISTS inventory_items (
item_id INTEGER PRIMARY KEY AUTOINCREMENT,
item_name TEXT UNIQUE NOT NULL,
description TEXT,
current_quantity REAL DEFAULT 0 NOT NULL,
unit TEXT DEFAULT 'units' NOT NULL,
reorder_level REAL,
preferred_supplier_id INTEGER,
last_updated TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
FOREIGN KEY (preferred_supplier_id) REFERENCES suppliers(supplier_id) ON DELETE SET NULL
);
""")
# Suppliers
cursor.execute("""
CREATE TABLE IF NOT EXISTS suppliers (
supplier_id INTEGER PRIMARY KEY AUTOINCREMENT,
supplier_name TEXT UNIQUE NOT NULL,
contact_info TEXT,
notes TEXT,
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Purchase Suggestions
cursor.execute("""
CREATE TABLE IF NOT EXISTS purchase_suggestions (
suggestion_id INTEGER PRIMARY KEY AUTOINCREMENT,
item_id INTEGER NOT NULL,
suggested_quantity REAL NOT NULL,
supplier_id INTEGER,
reason TEXT,
status TEXT DEFAULT 'pending' NOT NULL CHECK(status IN ('pending', 'ordered', 'received', 'cancelled')),
created_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP,
resolved_at TIMESTAMP,
FOREIGN KEY (item_id) REFERENCES inventory_items(item_id) ON DELETE CASCADE,
FOREIGN KEY (supplier_id) REFERENCES suppliers(supplier_id) ON DELETE SET NULL
);
""")
# Event Log
cursor.execute("""
CREATE TABLE IF NOT EXISTS event_log (
log_id INTEGER PRIMARY KEY AUTOINCREMENT,
event_type TEXT NOT NULL,
username TEXT,
details TEXT,
timestamp TIMESTAMP DEFAULT CURRENT_TIMESTAMP
);
""")
# Default Data (Only if creating tables)
# Create a default admin user if none exists
cursor.execute("SELECT COUNT(*) FROM users WHERE is_admin = 1;")
if cursor.fetchone()[0] == 0:
print("INFO: Creating default admin user 'admin' with password 'changeme'. PLEASE CHANGE THIS PASSWORD.")
default_username = "admin"
default_password = "changeme"
salt = secrets.token_hex(SALT_LENGTH)
hashed_password = hash_password(default_password, salt)
cursor.execute("""
INSERT INTO users (username, hashed_password, salt, is_admin)
VALUES (?, ?, ?, 1)
""", (default_username, hashed_password, salt))
# Manually log event here as log_event might not be fully ready if DB just created
cursor.execute("INSERT INTO event_log (username, event_type, details) VALUES (?, ?, ?)",
('System', 'DEFAULT_ADMIN_CREATED', json.dumps({'username': default_username})))
# Default business config
default_config = {
'business_name': ('My Automated Business', 'The name displayed in the UI'),
'low_stock_alert_threshold_pct': ('0.1', 'Alert if stock falls below 10% of reorder level (if set)'),
'default_currency': ('USD', 'Default currency symbol')
}
for key, (value, desc) in default_config.items():
cursor.execute("INSERT OR IGNORE INTO business_config (config_key, config_value, description) VALUES (?, ?, ?)", (key, value, desc))
conn.commit()
print("INFO: Database initialization complete.")
conn.close()
else:
print("INFO: Database schema already exists.")
except sqlite3.Error as e:
print(f"FATAL: Database setup/check failed: {e}")
if conn_check: conn_check.close()
raise
except Exception as e:
print(f"FATAL: An unexpected error occurred during database setup: {e}")
if conn_check: conn_check.close()
raise
# --- Security Module (Hashing unchanged, Auth adapted for Flask) ---
def hash_password(password: str, salt: str) -> str:
"""Hashes the password with the given salt using SHA-256."""
password_bytes = password.encode('utf-8')
salt_bytes = salt.encode('utf-8')
hashed = hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, 100000)
return hashed.hex()
def verify_password(stored_password_hash: str, salt: str, provided_password: str) -> bool:
"""Verifies a provided password against a stored hash and salt."""
provided_hash = hash_password(provided_password, salt)
return provided_hash == stored_password_hash
# --- Authentication/Authorization Decorators ---
def login_required(f):
"""Decorator to restrict access to logged-in users."""
@wraps(f)
def decorated_function(*args, **kwargs):
if 'username' not in session:
# For API requests, return 401 Unauthorized
if request.endpoint and request.endpoint.startswith('api_'):
return jsonify({"success": False, "message": "Authentication required"}), 401
# For page loads, redirect to login (index route handles this now)
return redirect(url_for('index')) # Redirect back to index which renders login if not auth
return f(*args, **kwargs)
return decorated_function
def admin_required(f):
"""Decorator to restrict access to admin users."""
@wraps(f)
@login_required # Ensure user is logged in first
def decorated_function(*args, **kwargs):
if not session.get('is_admin', False):
# For API requests, return 403 Forbidden
if request.endpoint and request.endpoint.startswith('api_'):
return jsonify({"success": False, "message": "Admin access required"}), 403
# For page loads (not currently used for admin-only pages, but good practice)
return jsonify({"success": False, "message": "Admin access required"}), 403 # Should ideally redirect or render error page
return f(*args, **kwargs)
return decorated_function
# --- Event Logging Module ---
def log_event(username: Optional[str], event_type: str, details: Dict[str, Any]):
"""Logs an event to the database."""
# Ensure details are serializable (handle common non-serializable types)
serializable_details = {}
for k, v in details.items():
if isinstance(v, (str, int, float, bool, list, dict, tuple)) or v is None:
serializable_details[k] = v
else:
try:
serializable_details[k] = json.dumps(v) # Try serializing complex objects
except TypeError:
serializable_details[k] = str(v) # Fallback to string conversion
try:
conn = get_db_connection()
cursor = conn.cursor()
# Add remote_addr to logs where available (especially for login)
event_details = serializable_details
if request and request.remote_addr:
event_details['ip_address'] = request.remote_addr
cursor.execute("""
INSERT INTO event_log (username, event_type, details)
VALUES (?, ?, ?)
""", (username, event_type, json.dumps(event_details)))
conn.commit()
conn.close()
except sqlite3.Error as e:
print(f"ERROR: Failed to log event {event_type} for user {username}: {e}")
except TypeError as te:
print(f"ERROR: Failed to serialize details for event {event_type}: {te} - Details: {serializable_details}")
except Exception as ex: # Catch unexpected errors during logging itself
print(f"ERROR: Unexpected error in log_event for {event_type}: {ex}")
# --- Business Logic Core (Inventory, Suppliers, Suggestions - Unchanged) ---
# (Omitted here for brevity as they are the same as v2.0 logic functions,
# assuming they are present above the API routes in the final app.py file)
# e.g., get_business_config_logic, update_business_config_logic,
# get_inventory_summary_logic, add_inventory_item_logic, update_inventory_quantity_logic,
# get_suppliers_logic, add_supplier_logic,
# check_and_suggest_reorders_logic, get_pending_suggestions_logic, resolve_suggestion_logic
# --- NEW: User Management Logic ---
def get_users_logic() -> List[Dict[str, Any]]:
"""Retrieves a list of all users (excluding sensitive info)."""
users = []
try:
conn = get_db_connection()
cursor = conn.cursor()
# Select only safe fields for UI
cursor.execute("SELECT username, is_admin, created_at FROM users ORDER BY username;")
users = [dict(row) for row in cursor.fetchall()] # Convert to dicts
conn.close()
except sqlite3.Error as e:
print(f"ERROR: Failed to retrieve users: {e}")
# Consider raising an exception or returning an error structure
return users
def add_user_logic(username: str, password: str, is_admin: bool, creating_user_username: str) -> Tuple[bool, str]:
"""Adds a new user to the database."""
if not username or not password:
return False, "Username and password are required."
try:
# Basic sanitization (more robust validation recommended)
username = username.strip()
if len(username) < 3: return False, "Username must be at least 3 characters long."
if len(password) < 6: return False, "Password must be at least 6 characters long." # Example policy
salt = secrets.token_hex(SALT_LENGTH)
hashed_password = hash_password(password, salt)
conn = get_db_connection()
cursor = conn.cursor()
# Check if username already exists
cursor.execute("SELECT COUNT(*) FROM users WHERE username = ?", (username,))
if cursor.fetchone()[0] > 0:
conn.close()
log_event(creating_user_username, 'ADD_USER_FAILURE', {'reason': 'Username exists', 'new_username': username})
return False, f"Username '{username}' already exists."
# Insert the new user
cursor.execute("""
INSERT INTO users (username, hashed_password, salt, is_admin)
VALUES (?, ?, ?, ?)
""", (username, hashed_password, salt, 1 if is_admin else 0))
conn.commit()
conn.close()
log_event(creating_user_username, 'USER_ADDED', {'new_username': username, 'is_admin': is_admin})
return True, f"User '{username}' added successfully."
except sqlite3.IntegrityError:
# Should be caught by the SELECT COUNT(*) but defense in depth
log_event(creating_user_username, 'ADD_USER_FAILURE', {'reason': 'Integrity error (duplicate)', 'new_username': username})
return False, f"Username '{username}' already exists (integrity error)."
except sqlite3.Error as e:
print(f"ERROR: Failed to add user {username}: {e}")
log_event(creating_user_username, 'ADD_USER_ERROR', {'new_username': username, 'error': str(e)})
return False, f"Database error adding user: {e}"
except Exception as e:
print(f"ERROR: Unexpected error in add_user_logic for {username}: {e}")
log_event(creating_user_username, 'ADD_USER_UNEXPECTED_ERROR', {'new_username': username, 'error': str(e)})
return False, f"An unexpected server error occurred: {e}"
# --- Flask Application Setup ---
app = Flask(__name__)
app.secret_key = SECRET_KEY # MUST be set for sessions to work
# --- HTML/CSS/JS Templates (Embedded in Python String) ---
# NOTE: This is the *only* place where the HTML structure changes.
# The JS part below will be updated to handle the new User section.
HTML_TEMPLATE = """
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Business Suite v3.0</title>
<style>
/* Ultra-minimalist CSS for efficiency - Inherited from v2 */
body { font-family: sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f4f4f4; color: #333; }
.container { max-width: 1200px; margin: 20px auto; padding: 20px; background-color: #fff; box-shadow: 0 0 10px rgba(0,0,0,0.1); border-radius: 8px; }
header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; }
header h1 { margin: 0; color: #555; font-size: 1.8em; }
#user-info { font-size: 0.9em; }
#logout-btn { background-color: #dc3545; color: white; border: none; padding: 8px 15px; border-radius: 4px; cursor: pointer; font-size: 0.9em; }
#logout-btn:hover { background-color: #bb2d3b; }
nav button { background-color: #007bff; color: white; border: none; padding: 10px 15px; margin-right: 10px; border-radius: 4px; cursor: pointer; font-size: 1em; }
nav button:hover { background-color: #0056b3; }
nav button.active { background-color: #0056b3; font-weight: bold; }
h2 { color: #007bff; border-bottom: 2px solid #007bff; padding-bottom: 5px; margin-top: 30px; }
section { display: none; margin-top: 20px; animation: fadeIn 0.5s; }
section.active { display: block; }
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
table { width: 100%; border-collapse: collapse; margin-top: 15px; font-size: 0.9em; }
th, td { border: 1px solid #ddd; padding: 10px; text-align: left; }
th { background-color: #f0f0f0; font-weight: bold; }
tr:nth-child(even) { background-color: #f9f9f9; }
form { margin-top: 20px; padding: 15px; background-color: #f9f9f9; border: 1px solid #eee; border-radius: 5px; }
form label { display: block; margin-bottom: 5px; font-weight: bold; }
form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea, form input[type="checkbox"] {
padding: 10px;
margin-bottom: 10px;
border: 1px solid #ccc;
border-radius: 4px;
box-sizing: border-box; /* Include padding and border */
display: inline-block; /* Align with labels */
vertical-align: middle; /* Align with labels */
}
form input[type="checkbox"] { width: auto; margin-right: 5px; }
form label[for] { /* Specific styling for labels linked to inputs */
display: inline-block; /* Allow label and input to be on the same line */
margin-bottom: 10px;
margin-right: 15px; /* Space between label/input pairs */
vertical-align: middle;
font-weight: normal; /* Reduce weight for inline labels */
}
form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea {
width: calc(100% - 22px); /* Default for full width */
display: block; /* Revert to block for full-width inputs */
margin-right: 0;
}
.form-group { margin-bottom: 0; }
.form-group label[for] { display: block; font-weight: bold; margin-bottom: 5px; } /* Revert labels in form-group */
form textarea { height: 60px; }
form button { background-color: #28a745; color: white; border: none; padding: 12px 20px; border-radius: 4px; cursor: pointer; font-size: 1em; }
form button:hover { background-color: #218838; }
.form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
.status-message { padding: 10px; margin-top: 15px; border-radius: 4px; font-weight: bold; text-align: center; }
.status-success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
.status-error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
.hidden { display: none !important; } /* Use important to override form-grid display */
.accordion { background-color: #eee; color: #444; cursor: pointer; padding: 12px; width: 100%; text-align: left; border: none; outline: none; transition: 0.4s; margin-top: 10px; border-radius: 4px; }
.accordion:hover, .accordion.active { background-color: #ccc; }
.panel { padding: 0 18px; background-color: white; display: none; overflow: hidden; border: 1px solid #eee; border-top: none; border-radius: 0 0 4px 4px; }
/* Login Form Specifics - Inherited from v2 */
#login-form { max-width: 400px; margin: 50px auto; padding: 30px; background-color: #fff; box-shadow: 0 0 15px rgba(0,0,0,0.2); border-radius: 8px; }
#login-form h2 { text-align: center; margin-bottom: 25px; color: #333; border: none;}
#login-form button { width: 100%; background-color: #007bff; }
#login-form button:hover { background-color: #0056b3; }
.spinner { border: 4px solid #f3f3f3; border-top: 4px solid #3498db; border-radius: 50%; width: 20px; height: 20px; animation: spin 1s linear infinite; display: inline-block; margin-left: 10px; vertical-align: middle; }
@keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } }
.loading { text-align: center; padding: 20px; font-style: italic; color: #777; }
button .spinner { display: none; } /* Hide spinner by default */
button.loading .spinner { display: inline-block; } /* Show spinner when button has loading class */
button.loading span { vertical-align: middle; } /* Align text with spinner */
/* Specific style for the checkbox label */
#add-user-form .form-group label[for="add-user-is-admin"] {
display: inline-block; /* Keep label and checkbox on the same line */
margin-right: 5px;
font-weight: normal;
vertical-align: middle;
width: auto; /* Allow label width to shrink */
}
#add-user-form .form-group input[type="checkbox"] {
width: auto; /* Ensure checkbox width is default */
margin-right: 15px;
vertical-align: middle;
}
</style>
</head>
<body>
<div id="login-section" class="container {{ 'hidden' if session.get('username') else '' }}">
<form id="login-form">
<h2>Business Suite Login</h2>
<div id="login-error" class="status-message status-error hidden"></div>
<div class="form-group">
<label for="username">Username:</label>
<input type="text" id="username" name="username" required>
</div>
<div class="form-group">
<label for="password">Password:</label>
<input type="password" id="password" name="password" required>
</div>
<button type="submit"><span>Login</span><div class="spinner"></div></button>
</form>
</div>
<div id="app-section" class="container {{ '' if session.get('username') else 'hidden' }}">
<header>
<h1>Business Suite</h1>
<div id="user-info">
Logged in as: <strong id="user-display">{{ session.get('username', '') }}</strong>
(<span id="admin-display">{{ 'Admin' if session.get('is_admin') else 'User' }}</span>)
<button id="logout-btn">Logout</button>
</div>
</header>
<nav>
<button data-section="inventory" class="nav-btn active">📦 Inventory</button>
<button data-section="suppliers" class="nav-btn">🤝 Suppliers</button>
<button data-section="suggestions" class="nav-btn">💡 Suggestions</button>
{# NEW: User Management Button - Only visible if is_admin is true #}
<button data-section="users" class="nav-btn {{ '' if session.get('is_admin') else 'hidden' }}">👥 Users</button>
</nav>
<div id="global-status" class="status-message hidden"></div>
<!-- Inventory Section - Inherited from v2 -->
<section id="inventory-section" class="active">
<h2>Inventory Management</h2>
<button class="accordion">Perform Inventory Actions ▼</button>
<div class="panel">
<form id="inventory-action-form" class="form-grid">
<div class="form-group">
<label for="inv-action-type">Action Type:</label>
<select id="inv-action-type" name="action_type">
<option value="sale">Record Sale (-)</option>
<option value="adjustment">Record Adjustment (+/-)</option>
<option value="delivery">Record Delivery (+)</option>
</select>
</div>
<div class="form-group">
<label for="inv-item-id">Item ID:</label>
<input type="number" id="inv-item-id" name="item_id" required min="1" step="1">
</div>
<div class="form-group">
<label for="inv-quantity-change">Quantity:</label>
<input type="number" id="inv-quantity-change" name="quantity_change" required step="any">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="inv-reason">Reason/Note:</label>
<input type="text" id="inv-reason" name="reason" placeholder="e.g., Customer order #123, Stocktake correction">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<button type="submit"><span>Submit Action</span><div class="spinner"></div></button>
</div>
</form>
</div>
<button class="accordion">Add New Inventory Item ▼</button>
<div class="panel">
<form id="add-item-form" class="form-grid">
<div class="form-group">
<label for="add-item-name">Name *:</label>
<input type="text" id="add-item-name" name="name" required>
</div>
<div class="form-group">
<label for="add-item-unit">Unit *:</label>
<input type="text" id="add-item-unit" name="unit" value="units" required>
</div>
<div class="form-group">
<label for="add-item-initial-qty">Initial Qty:</label>
<input type="number" id="add-item-initial-qty" name="initial_quantity" value="0" step="any">
</div>
<div class="form-group">
<label for="add-item-desc">Description:</label>
<input type="text" id="add-item-desc" name="description">
</div>
<div class="form-group">
<label for="add-item-reorder-lvl">Reorder Lvl:</label>
<input type="number" id="add-item-reorder-lvl" name="reorder_level" step="any" placeholder="Optional">
</div>
<div class="form-group">
<label for="add-item-supplier-id">Pref. Supplier ID:</label>
<input type="number" id="add-item-supplier-id" name="preferred_supplier_id" step="1" placeholder="Optional">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<button type="submit"><span>Add Item</span><div class="spinner"></div></button>
</div>
</form>
</div>
<h3>Current Inventory</h3>
<button id="refresh-inventory-btn"><span>Refresh List</span><div class="spinner"></div></button>
<div id="inventory-table-container"><div class="loading">Loading inventory...</div></div>
</section>
<!-- Suppliers Section - Inherited from v2 -->
<section id="suppliers-section">
<h2>Supplier Management</h2>
<button class="accordion">Add New Supplier ▼</button>
<div class="panel">
<form id="add-supplier-form" class="form-grid">
<div class="form-group">
<label for="add-supplier-name">Name *:</label>
<input type="text" id="add-supplier-name" name="name" required>
</div>
<div class="form-group">
<label for="add-supplier-contact">Contact Info:</label>
<input type="text" id="add-supplier-contact" name="contact_info">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<label for="add-supplier-notes">Notes:</label>
<textarea id="add-supplier-notes" name="notes"></textarea>
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<button type="submit"><span>Add Supplier</span><div class="spinner"></div></button>
</div>
</form>
</div>
<h3>Supplier List</h3>
<button id="refresh-suppliers-btn"><span>Refresh List</span><div class="spinner"></div></button>
<div id="suppliers-table-container"><div class="loading">Loading suppliers...</div></div>
</section>
<!-- Suggestions Section - Inherited from v2 -->
<section id="suggestions-section">
<h2>Purchase Suggestions</h2>
<button class="accordion">Resolve Pending Suggestion ▼</button>
<div class="panel">
<form id="resolve-suggestion-form" class="form-grid">
<div class="form-group">
<label for="resolve-suggestion-id">Suggestion ID *:</label>
<select id="resolve-suggestion-id" name="suggestion_id" required>
<option value="">-- Select Suggestion --</option>
</select>
</div>
<div class="form-group">
<label for="resolve-action">Action Taken *:</label>
<select id="resolve-action" name="action" required>
<option value="ordered">Ordered</option>
<option value="received">Received</option>
<option value="cancelled">Cancelled</option>
</select>
</div>
<div class="form-group" id="received-qty-group" class="hidden"> {/* Initially hidden */}
<label for="resolve-received-qty">Quantity Received *:</label>
<input type="number" id="resolve-received-qty" name="received_quantity" step="any" min="0.01">
</div>
<div class="form-group" style="grid-column: 1 / -1;">
<button type="submit"><span>Resolve Suggestion</span><div class="spinner"></div></button>
</div>
</form>
</div>
<h3>Pending Suggestions</h3>
<button id="refresh-suggestions-btn"><span>Refresh List</span><div class="spinner"></div></button>
<div id="suggestions-table-container"><div class="loading">Loading suggestions...</div></div>
</section>
{# NEW: User Management Section #}
<section id="users-section">
<h2>User Management</h2>
<p>Manage system users. Requires Admin privileges.</p>
<button class="accordion">Add New User ▼</button>
<div class="panel">
<form id="add-user-form"> {/* Not using form-grid here for simpler layout */}
<div class="form-group">
<label for="add-user-username">Username *:</label>
<input type="text" id="add-user-username" name="username" required minlength="3">
</div>
<div class="form-group">
<label for="add-user-password">Password *:</label>
<input type="password" id="add-user-password" name="password" required minlength="6">
</div>
<div class="form-group">
<label for="add-user-is-admin">Is Admin:</label>
<input type="checkbox" id="add-user-is-admin" name="is_admin" value="true">
</div>
<div class="form-group">
<button type="submit"><span>Add User</span><div class="spinner"></div></button>
</div>
</form>
</div>
<h3>System Users</h3>
<button id="refresh-users-btn"><span>Refresh List</span><div class="spinner"></div></button>
<div id="users-table-container"><div class="loading">Loading users...</div></div>
</section>
</div>
<script>
// Encapsulate JS in an IIFE
(function() {
// --- State & Configuration ---
let currentSection = 'inventory'; // Default section
let isLoggedIn = {{ 'true' if session.get('username') else 'false' }};
let isAdmin = {{ 'true' if session.get('is_admin') else 'false' }};
// --- DOM Element References ---
// (Keep all existing references from v3.0)
const loginSection = document.getElementById('login-section');
const appSection = document.getElementById('app-section');
const loginForm = document.getElementById('login-form');
const loginError = document.getElementById('login-error');
const userInfo = document.getElementById('user-info');
const userDisplay = document.getElementById('user-display');
const adminDisplay = document.getElementById('admin-display');
const logoutBtn = document.getElementById('logout-btn');
const navButtons = document.querySelectorAll('.nav-btn');
const sections = document.querySelectorAll('section');
const globalStatus = document.getElementById('global-status');
const inventoryTableContainer = document.getElementById('inventory-table-container');
const suppliersTableContainer = document.getElementById('suppliers-table-container');
const suggestionsTableContainer = document.getElementById('suggestions-table-container');
const usersTableContainer = document.getElementById('users-table-container');
const inventoryActionForm = document.getElementById('inventory-action-form');
const addItemForm = document.getElementById('add-item-form');
const addSupplierForm = document.getElementById('add-supplier-form');
const resolveSuggestionForm = document.getElementById('resolve-suggestion-form');
const addUserForm = document.getElementById('add-user-form');
const resolveSuggestionIdSelect = document.getElementById('resolve-suggestion-id');
const resolveActionSelect = document.getElementById('resolve-action');
const receivedQtyGroup = document.getElementById('received-qty-group');
const resolveReceivedQtyInput = document.getElementById('resolve-received-qty');
const refreshInventoryBtn = document.getElementById('refresh-inventory-btn');
const refreshSuppliersBtn = document.getElementById('refresh-suppliers-btn');
const refreshSuggestionsBtn = document.getElementById('refresh-suggestions-btn');
const refreshUsersBtn = document.getElementById('refresh-users-btn');
// --- Utility Functions ---
// *** UPDATED/IMPROVED apiRequest function ***
async function apiRequest(endpoint, method = 'GET', body = null, button = null) {
const options = {
method: method,
headers: {
// Set Content-Type only if body exists, avoids issues with GET/HEAD
...(body && {'Content-Type': 'application/json'}),
// Add other headers if needed, e.g., CSRF token
}
};
if (body) {
options.body = JSON.stringify(body);
}
// Show spinner on button if provided
if (button) {
button.classList.add('loading');
button.disabled = true;
}
try {
const response = await fetch(endpoint, options);
// --- Authentication/Authorization Checks FIRST ---
if (response.status === 401) {
showStatus("Session expired or not authorized. Reloading login...", true);
setTimeout(() => window.location.reload(), 2000); // Reload to show login
// Indicate failure to the caller implicitly by not returning data
// Or explicitly throw an error handled by the caller's catch block
throw new Error("Authentication required"); // Throw to stop execution
}
if (response.status === 403) {
let errorMsg = "Access forbidden.";
try { // Try to parse error message from JSON body for 403
const errorData = await response.json();
errorMsg = errorData.message || errorMsg;
} catch (parseError) { /* Body wasn't JSON, use default message */ }
throw new Error(errorMsg); // Throw specific forbidden error
}
// --- Check for *ANY* Non-OK Statuses (4xx, 5xx, excluding 401/403) ---
if (!response.ok) {
let errorMsg = `Request failed: ${response.status} ${response.statusText}`;
let errorDetails = '';
try {
// Attempt to get error details from JSON body first
const errorData = await response.json();
errorDetails = errorData.message || JSON.stringify(errorData);
} catch (e) {
// Body wasn't JSON, try reading as text
try {
errorDetails = await response.text();
// Avoid logging full HTML pages if that's what we received
if (errorDetails.trim().toLowerCase().startsWith('<!doctype html') || errorDetails.trim().startsWith('<html')) {
errorDetails = "(Received unexpected HTML response body)";
} else if (errorDetails.length > 150) { // Truncate long text errors
errorDetails = errorDetails.substring(0, 150) + "...";
}
} catch (textError) {
errorDetails = "(Could not read error response body)";
}
}
throw new Error(`${errorMsg}. ${errorDetails}`);
}
// --- If response.ok (2xx status), check Content-Type before parsing ---
const contentType = response.headers.get('content-type');
if (!contentType || !contentType.includes('application/json')) {
// We expected JSON, but didn't get it, even on success status!
let responseBodyPreview = '(Could not read response body)';
try {
responseBodyPreview = (await response.text()).substring(0, 100); // Get start of body
} catch(e) {/* ignore */}
console.warn(`Expected JSON response from ${endpoint}, but received ${contentType || 'unknown content type'}. Body starts: ${responseBodyPreview}`);
throw new Error(`Received unexpected content type from server: ${contentType || 'unknown'}. Check server logs.`);
}
// --- Only parse JSON if status is OK and Content-Type is correct ---
const data = await response.json();
return data; // Success case
} catch (error) {
console.error(`API Request Error during fetch to ${endpoint}:`, error);
// Rethrow the error (already constructed meaningfully above)
// The calling function's catch block should display it.
throw error;
} finally {
if (button) {
button.classList.remove('loading');
button.disabled = false;
}
}
}
// Display status messages (Unchanged from v3.0)
function showStatus(message, isError = false, element = globalStatus) {
if (!element) { console.error("Status element not found"); return; }
element.textContent = message;
element.className = 'status-message'; // Reset classes
element.classList.add(isError ? 'status-error' : 'status-success');
element.classList.remove('hidden');
setTimeout(() => { if(element) element.classList.add('hidden'); }, 5000);
}
// Create HTML Table from data array (Unchanged from v3.0)
function createTable(data, columns) {
if (!data || data.length === 0) {
return '<p>No data available.</p>';
}
let html = '<table><thead><tr>';
columns.forEach(col => {
const headerText = col.replace(/_/g, ' ').replace(/\b\w/g, l => l.toUpperCase());
html += `<th>${headerText}</th>`;
});
html += '</tr></thead><tbody>';
data.forEach(row => {
html += '<tr>';
columns.forEach(col => {
let cellValue = row[col];
if (typeof cellValue === 'boolean') {
cellValue = cellValue ? 'Yes' : 'No';
} else if (cellValue === null || cellValue === undefined) {
cellValue = '';
}
html += `<td>${cellValue}</td>`;
});
html += '</tr>';
});
html += '</tbody></table>';
return html;
}
// --- Data Loading Functions (Use the new apiRequest, error handling added) ---
async function loadInventory(button = null) {
inventoryTableContainer.innerHTML = '<div class="loading">Loading inventory...</div>';
try {
// Use the robust apiRequest
const data = await apiRequest('/api/inventory', 'GET', null, button);
// apiRequest now throws on error or returns valid data
if (data && data.success) {
const columns = ["item_id", "item_name", "description", "current_quantity", "unit", "reorder_level", "preferred_supplier_name", "last_updated_str"];
inventoryTableContainer.innerHTML = createTable(data.inventory, columns);
} else if (data) { // Should ideally not happen if apiRequest throws, but defense-in-depth
inventoryTableContainer.innerHTML = `<p class="status-error">Failed to load inventory: ${data.message || 'Unknown error'}</p>`;
}
// No else needed - error case is handled by the catch block now
} catch (error) {
// Catch errors thrown by apiRequest
console.error("Error in loadInventory:", error);
// Display the error message constructed by apiRequest
inventoryTableContainer.innerHTML = `<p class="status-error">Error loading inventory: ${error.message}</p>`;
}
}
async function loadSuppliers(button = null) {
suppliersTableContainer.innerHTML = '<div class="loading">Loading suppliers...</div>';
try {
const data = await apiRequest('/api/suppliers', 'GET', null, button);
if (data && data.success) {
const columns = ["supplier_id", "supplier_name", "contact_info", "notes"];
suppliersTableContainer.innerHTML = createTable(data.suppliers, columns);
} else if (data) {
suppliersTableContainer.innerHTML = `<p class="status-error">Failed to load suppliers: ${data.message || 'Unknown error'}</p>`;
}
} catch (error) {
console.error("Error in loadSuppliers:", error);
suppliersTableContainer.innerHTML = `<p class="status-error">Error loading suppliers: ${error.message}</p>`;
}
}
async function loadSuggestions(button = null) {
suggestionsTableContainer.innerHTML = '<div class="loading">Loading suggestions...</div>';
resolveSuggestionIdSelect.innerHTML = '<option value="">-- Loading... --</option>';
try {
const data = await apiRequest('/api/suggestions', 'GET', null, button);
if (data && data.success) {
const columns = ["suggestion_id", "item_name", "suggested_quantity", "unit", "supplier_name", "reason", "created_at_str"];
suggestionsTableContainer.innerHTML = createTable(data.suggestions, columns);
resolveSuggestionIdSelect.innerHTML = '<option value="">-- Select Suggestion --</option>';
if (data.suggestions.length > 0) {
data.suggestions.forEach(s => {
const option = document.createElement('option');
option.value = s.suggestion_id;
option.textContent = `ID: ${s.suggestion_id} - ${s.item_name} (Qty: ${s.suggested_quantity})`;
resolveSuggestionIdSelect.appendChild(option);
});
} else {
resolveSuggestionIdSelect.innerHTML = '<option value="">-- No pending suggestions --</option>';
}
} else if (data) {
suggestionsTableContainer.innerHTML = `<p class="status-error">Failed to load suggestions: ${data.message || 'Unknown error'}</p>`;
resolveSuggestionIdSelect.innerHTML = '<option value="">-- Error loading --</option>';
}
} catch (error) {
console.error("Error in loadSuggestions:", error);
suggestionsTableContainer.innerHTML = `<p class="status-error">Error loading suggestions: ${error.message}</p>`;
resolveSuggestionIdSelect.innerHTML = '<option value="">-- Error loading --</option>';
}
}
async function loadUsers(button = null) {
usersTableContainer.innerHTML = '<div class="loading">Loading users...</div>';
if (!isAdmin) {
usersTableContainer.innerHTML = '<p class="status-error">Admin access required to view users.</p>';
return;
}
try {
const data = await apiRequest('/api/users', 'GET', null, button);
if (data && data.success) {
const columns = ["username", "is_admin", "created_at"];
usersTableContainer.innerHTML = createTable(data.users, columns);
} else if (data) {
usersTableContainer.innerHTML = `<p class="status-error">Failed to load users: ${data.message || 'Unknown error'}</p>`;
}
} catch (error) {
console.error("Error in loadUsers:", error);
// Check if it was specifically a Forbidden error caught by apiRequest
if (error.message.includes("forbidden") || error.message.includes("Access required")) {
usersTableContainer.innerHTML = '<p class="status-error">Admin access required.</p>';
} else {
usersTableContainer.innerHTML = `<p class="status-error">Error loading users: ${error.message}</p>`;
}
}
}
// --- Event Handlers ---
// (Login, Logout, Navigation, Inventory Action, Add Item, Add Supplier, Resolve Suggestion, Add User)
// These handlers remain largely the same as in v3.0, but now their calls to apiRequest
// benefit from the improved error handling within apiRequest itself.
// The .catch(error => showStatus(error.message, true)); blocks within each handler
// will now display more specific error messages originating from apiRequest.
// Example: Login Handler (No change needed, catch block handles errors from apiRequest)
loginForm.addEventListener('submit', async (e) => {
e.preventDefault();
loginError.classList.add('hidden');
const username = loginForm.username.value;
const password = loginForm.password.value;
const button = loginForm.querySelector('button[type="submit"]');
try {
const data = await apiRequest('/api/login', 'POST', { username, password }, button);
if (data && data.success) { // Check for data existence after await
isLoggedIn = true;
isAdmin = data.user.is_admin;
loginSection.classList.add('hidden');
appSection.classList.remove('hidden');
userDisplay.textContent = data.user.username;
adminDisplay.textContent = isAdmin ? 'Admin' : 'User';
document.querySelector('.nav-btn[data-section="users"]').classList.toggle('hidden', !isAdmin);
navigateTo(currentSection);
}
// No explicit else needed, apiRequest throws on failure
} catch (error) {
// Handle errors thrown by apiRequest (e.g., auth failed, network error)
showStatus(error.message, true, loginError); // Display error in login form's status area
// Reset state just in case
isLoggedIn = false;
isAdmin = false;
}
});
// Example: Logout Handler (No change needed)
logoutBtn.addEventListener('click', async () => {
try {
const data = await apiRequest('/api/logout', 'POST');
if (data && data.success) { // Check data exists
isLoggedIn = false;
isAdmin = false;
loginSection.classList.remove('hidden');
appSection.classList.add('hidden');
loginForm.reset();
loginError.classList.add('hidden');
document.querySelector('.nav-btn[data-section="users"]').classList.add('hidden');
inventoryTableContainer.innerHTML = '';
suppliersTableContainer.innerHTML = '';
suggestionsTableContainer.innerHTML = '';
usersTableContainer.innerHTML = '';
navigateTo('inventory');
}
} catch (error) {
// Handle potential errors during logout API call
showStatus(error.message, true);
}
});
// ... (Keep other event handlers: navButtons, inventoryActionForm, addItemForm, etc. as they were in v3.0) ...
// Ensure all handlers use `try...catch` around `apiRequest` calls and use `showStatus(error.message, true)` in the catch block.
// --- NEW versions of handlers with robust catch blocks ---
// Navigation Handler
navButtons.forEach(button => {
button.addEventListener('click', () => {
const sectionId = button.getAttribute('data-section');
if (!isLoggedIn) { showStatus("Please login.", true); return; }
if (sectionId === 'users' && !isAdmin) { showStatus("Admin access required.", true); return; }
navigateTo(sectionId);
});
});
// Inventory Action Handler
inventoryActionForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(inventoryActionForm);
const action_type = formData.get('action_type');
const item_id = formData.get('item_id');
let quantity_change = parseFloat(formData.get('quantity_change'));
const reason = formData.get('reason') || `Action: ${action_type}`;
const button = inventoryActionForm.querySelector('button[type="submit"]');
if (isNaN(quantity_change)) { showStatus("Quantity must be a number.", true); return; }
// ... (quantity adjustment logic) ...
if (action_type === 'sale') { if (quantity_change > 0) quantity_change = -quantity_change; else { showStatus("Sale quantity must be positive.", true); return; } }
else if (action_type === 'delivery') { if (quantity_change < 0) { showStatus("Delivery quantity must be positive.", true); return; } }
const payload = { quantity_change, reason };
try {
const data = await apiRequest(`/api/inventory/${item_id}/quantity`, 'POST', payload, button);
if (data && data.success) {
showStatus(data.message || "Inventory updated.", false);
inventoryActionForm.reset();
loadInventory(); loadSuggestions();
}
} catch (error) { showStatus(error.message, true); }
});
// Add Item Handler
addItemForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(addItemForm);
const payload = Object.fromEntries(formData.entries());
// ... (payload parsing logic) ...
payload.initial_quantity = parseFloat(payload.initial_quantity || 0);
payload.reorder_level = payload.reorder_level ? parseFloat(payload.reorder_level) : null;
payload.preferred_supplier_id = payload.preferred_supplier_id ? parseInt(payload.preferred_supplier_id) : null;
if (isNaN(payload.initial_quantity) || (payload.reorder_level !== null && isNaN(payload.reorder_level)) || (payload.preferred_supplier_id !== null && isNaN(payload.preferred_supplier_id))) { showStatus("Invalid number input.", true); return; }
const button = addItemForm.querySelector('button[type="submit"]');
try {
const data = await apiRequest('/api/inventory', 'POST', payload, button);
if (data && data.success) {
showStatus(data.message || "Item added.", false);
addItemForm.reset(); loadInventory();
}
} catch (error) { showStatus(error.message, true); }
});
// Add Supplier Handler
addSupplierForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(addSupplierForm);
const payload = Object.fromEntries(formData.entries());
const button = addSupplierForm.querySelector('button[type="submit"]');
try {
const data = await apiRequest('/api/suppliers', 'POST', payload, button);
if (data && data.success) {
showStatus(data.message || "Supplier added.", false);
addSupplierForm.reset(); loadSuppliers();
}
} catch (error) { showStatus(error.message, true); }
});
// Resolve Suggestion Handler
resolveSuggestionForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(resolveSuggestionForm);
const suggestion_id = formData.get('suggestion_id');
const action = formData.get('action');
let received_quantity = formData.get('received_quantity');
const button = resolveSuggestionForm.querySelector('button[type="submit"]');
if (!suggestion_id) { showStatus("Select suggestion.", true); return; }
// ... (payload preparation logic) ...
const payload = { action };
if (action === 'received') { if (!received_quantity || isNaN(parseFloat(received_quantity)) || parseFloat(received_quantity) <= 0) { showStatus("Valid positive quantity required.", true); return; } payload.received_quantity = parseFloat(received_quantity); } else { payload.received_quantity = null; }
try {
const data = await apiRequest(`/api/suggestions/${suggestion_id}/resolve`, 'POST', payload, button);
if (data && data.success) {
showStatus(data.message || "Suggestion resolved.", false);
resolveSuggestionForm.reset(); receivedQtyGroup.classList.add('hidden');
loadSuggestions(); if (action === 'received') { loadInventory(); }
}
} catch (error) { showStatus(error.message, true); }
});
// Add User Handler
addUserForm.addEventListener('submit', async (e) => {
e.preventDefault();
const formData = new FormData(addUserForm);
const username = formData.get('username');
const password = formData.get('password');
const is_admin = formData.get('is_admin') === 'true';
const button = addUserForm.querySelector('button[type="submit"]');
// ... (basic validation) ...
if (!username || !password) { showStatus("Username and password required.", true); return; } if (username.length < 3) { showStatus("Username too short.", true); return; } if (password.length < 6) { showStatus("Password too short.", true); return; }
const payload = { username, password, is_admin };
try {
const data = await apiRequest('/api/users', 'POST', payload, button);
if (data && data.success) {
showStatus(data.message || "User added.", false);
addUserForm.reset(); loadUsers();
}
} catch (error) { showStatus(error.message, true); }
});
// --- End Event Handlers ---
// Refresh Button Handlers (Unchanged)
refreshInventoryBtn.addEventListener('click', () => loadInventory(refreshInventoryBtn));
refreshSuppliersBtn.addEventListener('click', () => loadSuppliers(refreshSuppliersBtn));
refreshSuggestionsBtn.addEventListener('click', () => loadSuggestions(refreshSuggestionsBtn));
refreshUsersBtn.addEventListener('click', () => loadUsers(refreshUsersBtn));
// Accordion Handler (Unchanged)
document.querySelectorAll('.accordion').forEach(accordion => {
accordion.addEventListener('click', function() { this.classList.toggle('active'); const panel = this.nextElementSibling; if (panel.style.display === "block") { panel.style.display = "none"; this.innerHTML = this.innerHTML.replace('▲', '▼'); } else { panel.style.display = "block"; this.innerHTML = this.innerHTML.replace('▼', '▲'); } });
});
// --- Initial Load / State Check (Unchanged) ---
function initialize() {
if (isLoggedIn) {
loginSection.classList.add('hidden');
appSection.classList.remove('hidden');
document.querySelector('.nav-btn[data-section="users"]').classList.toggle('hidden', !isAdmin);
setTimeout(() => navigateTo(currentSection), 50);
} else {
loginSection.classList.remove('hidden');
appSection.classList.add('hidden');
}
document.querySelectorAll('.panel').forEach(panel => panel.style.display = 'none');
document.querySelectorAll('.accordion').forEach(acc => acc.innerHTML = acc.innerHTML.replace('▲', '▼'));
}
document.addEventListener('DOMContentLoaded', initialize);
// Expose navigateTo globally for debugging if needed
// window.navigateTo = navigateTo;
})(); // End IIFE
</script>
</body>
</html>
"""
# --- Flask Application Setup ---
app = Flask(__name__)
app.secret_key = SECRET_KEY
# --- Flask Routes ---
@app.route('/')
def index():
"""Serves the main application page or login page based on session."""
# render_template_string passes Flask session variables to the Jinja2 template
# The HTML template uses these to initially set element visibility
return render_template_string(HTML_TEMPLATE, session=session)
@app.route('/api/login', methods=['POST'])
def api_login():
"""Handles user login attempt."""
data = request.get_json()
username = data.get('username')
password = data.get('password')
if not username or not password:
# Log missing credentials without associating with a specific user (attacker might be guessing)
log_event(None, 'LOGIN_FAILURE', {'reason': 'Missing credentials', 'username_attempt': username, 'ip_address': request.remote_addr})
return jsonify({"success": False, "message": "Username and password are required"}), 400
conn = None
try:
conn = get_db_connection()
cursor = conn.cursor()
cursor.execute("SELECT username, hashed_password, salt, is_admin FROM users WHERE username = ?", (username,))
user_row = cursor.fetchone()
conn.close() # Close connection as early as possible
if user_row:
stored_hash = user_row['hashed_password']
salt = user_row['salt']
if verify_password(stored_hash, salt, password):
# Login successful - store user info in session
session['username'] = user_row['username']
session['is_admin'] = bool(user_row['is_admin']) # Store as Python boolean
session.permanent = True # Optional: make session permanent
log_event(username, 'LOGIN_SUCCESS', {'ip_address': request.remote_addr})
# Return user info including is_admin status to the frontend
user_info = {"username": user_row['username'], "is_admin": bool(user_row['is_admin'])}
return jsonify({"success": True, "message": "Login successful", "user": user_info})
else:
log_event(username, 'LOGIN_FAILURE', {'reason': 'Invalid password', 'ip_address': request.remote_addr})
return jsonify({"success": False, "message": "Invalid username or password"}), 401
else:
log_event(username, 'LOGIN_FAILURE', {'reason': 'User not found', 'username_attempt': username, 'ip_address': request.remote_addr})
return jsonify({"success": False, "message": "Invalid username or password"}), 401
except sqlite3.Error as e:
print(f"ERROR: Database error during login for user {username}: {e}")
log_event(username, 'LOGIN_ERROR', {'error': str(e), 'ip_address': request.remote_addr})
if conn: conn.close()
return jsonify({"success": False, "message": "Server error during login"}), 500
except Exception as e:
print(f"ERROR: Unexpected error during login for user {username}: {e}")
# Log as system event or None user if username couldn't be retrieved
log_event(username or 'Unknown', 'LOGIN_UNEXPECTED_ERROR', {'error': str(e), 'ip_address': request.remote_addr})
if conn: conn.close()
return jsonify({"success": False, "message": "An unexpected server error occurred"}), 500
@app.route('/api/logout', methods=['POST'])
@login_required # Ensure user is logged in to log out
def api_logout():
"""Logs the user out by clearing the session."""
username = session.get('username', 'Unknown')
session.pop('username', None)
session.pop('is_admin', None)
# Explicitly clear session data from Flask's storage
session.clear()
log_event(username, 'LOGOUT', {'ip_address': request.remote_addr})
return jsonify({"success": True, "message": "Logged out successfully"})
@app.route('/api/status')
def api_status():
"""Checks if the user is currently logged in."""
# This endpoint is useful for frontend validation if needed, but index route handles initial state
if 'username' in session:
user_info = {"username": session['username'], "is_admin": session.get('is_admin', False)}
return jsonify({"logged_in": True, "user": user_info})
else:
return jsonify({"logged_in": False})
# --- API Routes for Business Logic (Placeholder - Assume logic functions are defined above) ---
# Example:
# @app.route('/api/inventory', methods=['GET'])
# @login_required
# def api_get_inventory():
# try:
# items = get_inventory_summary_logic()
# return jsonify({"success": True, "inventory": items})
# except Exception as e:
# return jsonify({"success": False, "message": f"Failed: {e}"}), 500
# ... (other API routes for inventory, suppliers, suggestions from v2)
# --- NEW: API Routes for User Management ---
@app.route('/api/users', methods=['GET'])
@admin_required # Only admins can list users
def api_get_users():
"""API endpoint to get list of users."""
try:
users = get_users_logic()
# Exclude password/salt from API response for security
safe_users = [{"username": u['username'], "is_admin": bool(u['is_admin']), "created_at": u['created_at']} for u in users]
return jsonify({"success": True, "users": safe_users})
except Exception as e:
print(f"API ERROR: /api/users GET: {e}")
# Log admin-only error under the admin's username
log_event(session.get('username', 'Unknown Admin Attempt'), 'GET_USERS_API_ERROR', {'error': str(e)})
return jsonify({"success": False, "message": f"Failed to retrieve users: {e}"}), 500
@app.route('/api/users', methods=['POST'])
@admin_required # Only admins can add users
def api_add_user():
"""API endpoint to add a new user."""
data = request.get_json()
if not data:
log_event(session.get('username', 'Unknown Admin Attempt'), 'ADD_USER_API_FAILURE', {'reason': 'No data'})
return jsonify({"success": False, "message": "Invalid request body"}), 400
username = data.get('username')
password = data.get('password')
is_admin = data.get('is_admin', False) # Default to False if not provided
creating_user_username = session.get('username', 'Unknown Admin') # The admin performing the action
# Pass validation responsibility to the logic function
success, message = add_user_logic(username, password, bool(is_admin), creating_user_username)
if success:
return jsonify({"success": True, "message": message})
else:
# Determine status code: 409 Conflict for duplicate, 400 for bad input
status_code = 409 if 'already exists' in message else 400
return jsonify({"success": False, "message": message}), status_code
# Any unhandled exceptions would be caught by Flask's default error handler or gunicorn/web server
# For impeccable error handling, add a broad try...except around the logic call here too
# --- Application Entry Point ---
if __name__ == "__main__":
print("INFO: Starting Business Automation Suite v3.0 (Flask)...")
print(f"INFO: Using database file: {os.path.abspath(DB_NAME)}")
# Ensure database exists and schema is set up before launching Flask
try:
setup_database()
except Exception as e:
print(f"FATAL: Could not initialize database. Exiting. Error: {e}")
exit(1)
print("INFO: Launching Flask application...")
# host='0.0.0.0' is essential for accessibility within Docker/HF Space
# debug=False is important for production environments
# Use gunicorn or similar WSGI server for production instead of app.run()
# For HF Spaces, `app.run()` with server_name/port usually works via their infrastructure
app.run(host='0.0.0.0', port=7860, debug=False)
print("INFO: Application stopped.")