Lukeetah commited on
Commit
4c026c5
·
verified ·
1 Parent(s): d32dd76

Update app.py

Browse files
Files changed (1) hide show
  1. app.py +489 -623
app.py CHANGED
@@ -1,6 +1,6 @@
1
  # -*- coding: utf-8 -*-
2
  """
3
- Business Automation Suite - AI-Ready Core v2.0 (Flask + HTML/JS Frontend)
4
  Designed by Gemini Advanced Development Unit under impeccable direction.
5
  Target Platform: Hugging Face Spaces (Free Tier)
6
  Architecture: Single-File Ultra-Efficient Application with API Backend
@@ -18,11 +18,11 @@ from typing import Dict, List, Tuple, Optional, Any, Union
18
 
19
  # --- Constants and Configuration ---
20
 
21
- DB_NAME = "business_suite_v2.db" # Use a different DB name for v2
22
  SALT_LENGTH = 16
23
- SECRET_KEY = secrets.token_hex(24) # Secret key for Flask sessions
24
 
25
- # --- Database Core: The Central Nervous System (Largely unchanged logic) ---
26
 
27
  def get_db_connection() -> sqlite3.Connection:
28
  """Establishes and returns a database connection. Enables Foreign Keys."""
@@ -36,7 +36,6 @@ def setup_database():
36
  required_tables = {"users", "business_config", "inventory_items", "suppliers", "purchase_suggestions", "event_log"}
37
  conn_check = None
38
  try:
39
- # Check if DB file exists and has tables *before* potentially creating it
40
  db_exists = os.path.exists(DB_NAME)
41
  if db_exists:
42
  conn_check = get_db_connection()
@@ -52,7 +51,7 @@ def setup_database():
52
  conn = get_db_connection()
53
  cursor = conn.cursor()
54
 
55
- # Users Table
56
  cursor.execute("""
57
  CREATE TABLE IF NOT EXISTS users (
58
  username TEXT PRIMARY KEY,
@@ -161,9 +160,9 @@ def setup_database():
161
 
162
  except sqlite3.Error as e:
163
  print(f"FATAL: Database setup/check failed: {e}")
164
- if conn_check: conn_check.close() # Ensure connection is closed on error
165
  raise
166
- except Exception as e: # Catch other potential errors like file system issues
167
  print(f"FATAL: An unexpected error occurred during database setup: {e}")
168
  if conn_check: conn_check.close()
169
  raise
@@ -174,7 +173,7 @@ def hash_password(password: str, salt: str) -> str:
174
  """Hashes the password with the given salt using SHA-256."""
175
  password_bytes = password.encode('utf-8')
176
  salt_bytes = salt.encode('utf-8')
177
- hashed = hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, 100000) # PBKDF2 for more security
178
  return hashed.hex()
179
 
180
  def verify_password(stored_password_hash: str, salt: str, provided_password: str) -> bool:
@@ -182,412 +181,167 @@ def verify_password(stored_password_hash: str, salt: str, provided_password: str
182
  provided_hash = hash_password(provided_password, salt)
183
  return provided_hash == stored_password_hash
184
 
185
- # --- Event Logging Module (Unchanged logic) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
186
 
187
  def log_event(username: Optional[str], event_type: str, details: Dict[str, Any]):
188
  """Logs an event to the database."""
189
- # Ensure details are serializable
190
  serializable_details = {}
191
  for k, v in details.items():
192
  if isinstance(v, (str, int, float, bool, list, dict, tuple)) or v is None:
193
  serializable_details[k] = v
194
  else:
195
- serializable_details[k] = str(v) # Convert non-standard types to string
 
 
 
 
196
 
197
  try:
198
  conn = get_db_connection()
199
  cursor = conn.cursor()
 
 
 
 
 
 
200
  cursor.execute("""
201
  INSERT INTO event_log (username, event_type, details)
202
  VALUES (?, ?, ?)
203
- """, (username, event_type, json.dumps(serializable_details)))
204
  conn.commit()
205
  conn.close()
206
  except sqlite3.Error as e:
207
  print(f"ERROR: Failed to log event {event_type} for user {username}: {e}")
208
  except TypeError as te:
209
  print(f"ERROR: Failed to serialize details for event {event_type}: {te} - Details: {serializable_details}")
 
 
210
 
211
 
212
- # --- Business Logic Core (Adapted to return data/status, not interact with UI directly) ---
213
-
214
- def get_business_config_logic() -> Dict[str, str]:
215
- """Retrieves all business configuration settings."""
216
- config = {}
217
- try:
218
- conn = get_db_connection()
219
- cursor = conn.cursor()
220
- cursor.execute("SELECT config_key, config_value FROM business_config")
221
- for row in cursor.fetchall():
222
- config[row['config_key']] = row['config_value']
223
- conn.close()
224
- except sqlite3.Error as e:
225
- print(f"ERROR: Failed to retrieve business configuration: {e}")
226
- # In an API context, maybe raise an exception or return an error indicator
227
- return config
228
 
229
- def update_business_config_logic(config_key: str, config_value: str, username: str) -> Tuple[bool, str]:
230
- """Updates a specific configuration key."""
231
- try:
232
- conn = get_db_connection()
233
- cursor = conn.cursor()
234
- cursor.execute("""
235
- UPDATE business_config
236
- SET config_value = ?, last_updated = CURRENT_TIMESTAMP
237
- WHERE config_key = ?
238
- """, (config_value, config_key))
239
- updated_rows = cursor.rowcount
240
- conn.commit()
241
- conn.close()
242
- if updated_rows > 0:
243
- log_event(username, 'CONFIG_UPDATE', {'key': config_key, 'new_value': config_value})
244
- return True, "Configuration updated successfully."
245
- else:
246
- print(f"WARN: Attempted to update non-existent config key: {config_key}")
247
- return False, f"Configuration key '{config_key}' not found."
248
- except sqlite3.Error as e:
249
- print(f"ERROR: Failed to update config key {config_key}: {e}")
250
- log_event(username, 'CONFIG_UPDATE_ERROR', {'key': config_key, 'error': str(e)})
251
- return False, f"Database error updating configuration: {e}"
252
 
253
- def get_inventory_summary_logic() -> List[Dict[str, Any]]:
254
- """Retrieves a summary of all inventory items."""
255
- items = []
256
  try:
257
  conn = get_db_connection()
258
  cursor = conn.cursor()
259
- cursor.execute("""
260
- SELECT
261
- i.item_id, i.item_name, i.description, i.current_quantity, i.unit,
262
- i.reorder_level, i.preferred_supplier_id, s.supplier_name as preferred_supplier_name,
263
- strftime('%Y-%m-%d %H:%M:%S', i.last_updated) as last_updated_str -- Format date for JSON
264
- FROM inventory_items i
265
- LEFT JOIN suppliers s ON i.preferred_supplier_id = s.supplier_id
266
- ORDER BY i.item_name;
267
- """)
268
- # Convert Row objects to dictionaries for JSON serialization
269
- items = [dict(row) for row in cursor.fetchall()]
270
  conn.close()
271
  except sqlite3.Error as e:
272
- print(f"ERROR: Failed to retrieve inventory summary: {e}")
273
  # Consider raising an exception or returning an error structure
274
- return items
275
 
276
- def add_inventory_item_logic(name: str, description: str, unit: str, reorder_level: Optional[float], preferred_supplier_id: Optional[int], initial_quantity: float, username: str) -> Tuple[bool, str, Optional[int]]:
277
- """Adds a new item to the inventory. Returns success, message, and new item ID."""
278
- if not name or not unit:
279
- return False, "Item Name and Unit are required.", None
280
- try:
281
- # Basic type validation (more robust validation recommended)
282
- initial_quantity = float(initial_quantity)
283
- if reorder_level is not None: reorder_level = float(reorder_level)
284
- if preferred_supplier_id is not None: preferred_supplier_id = int(preferred_supplier_id)
285
 
286
- conn = get_db_connection()
287
- cursor = conn.cursor()
288
- cursor.execute("""
289
- INSERT INTO inventory_items (item_name, description, current_quantity, unit, reorder_level, preferred_supplier_id)
290
- VALUES (?, ?, ?, ?, ?, ?)
291
- """, (name, description, initial_quantity, unit, reorder_level, preferred_supplier_id))
292
- item_id = cursor.lastrowid
293
- conn.commit()
294
- conn.close()
295
- log_event(username, 'INVENTORY_ADD', {'item_id': item_id, 'name': name, 'initial_quantity': initial_quantity})
296
- return True, f"Item '{name}' added successfully.", item_id
297
- except sqlite3.IntegrityError:
298
- # This usually means unique constraint failed (item_name)
299
- return False, f"Item with name '{name}' already exists.", None
300
- except (ValueError, TypeError) as ve:
301
- return False, f"Invalid input data type: {ve}", None
302
- except sqlite3.Error as e:
303
- print(f"ERROR: Failed to add inventory item {name}: {e}")
304
- log_event(username, 'INVENTORY_ADD_ERROR', {'name': name, 'error': str(e)})
305
- return False, f"Database error adding item: {e}", None
306
-
307
-
308
- def update_inventory_quantity_logic(item_id: int, quantity_change: float, reason: str, username: str) -> Tuple[bool, str, Optional[float]]:
309
- """Updates inventory quantity. Returns success, message, and new quantity."""
310
- if quantity_change == 0:
311
- return True, "No change in quantity specified.", None # Indicate no change happened
312
 
313
- conn = None
314
  try:
315
- item_id = int(item_id)
316
- quantity_change = float(quantity_change)
 
 
 
 
 
317
 
318
  conn = get_db_connection()
319
  cursor = conn.cursor()
320
 
321
- # Get current quantity and name
322
- cursor.execute("SELECT item_name, current_quantity FROM inventory_items WHERE item_id = ?", (item_id,))
323
- item_row = cursor.fetchone()
324
- if not item_row:
325
  conn.close()
326
- return False, f"Item with ID {item_id} not found.", None
327
-
328
- current_quantity = item_row['current_quantity']
329
- new_quantity = current_quantity + quantity_change
330
-
331
- # Optional: Prevent negative stock
332
- # if new_quantity < 0:
333
- # conn.close()
334
- # return False, f"Operation failed: Cannot reduce quantity of '{item_row['item_name']}' below zero.", None
335
 
 
336
  cursor.execute("""
337
- UPDATE inventory_items
338
- SET current_quantity = ?, last_updated = CURRENT_TIMESTAMP
339
- WHERE item_id = ?
340
- """, (new_quantity, item_id))
341
 
342
  conn.commit()
343
  conn.close()
 
 
344
 
345
- log_event(username, 'INVENTORY_UPDATE', {
346
- 'item_id': item_id, 'item_name': item_row['item_name'],
347
- 'quantity_change': quantity_change, 'new_quantity': new_quantity,
348
- 'reason': reason
349
- })
350
-
351
- # Trigger reorder check *after* successful update
352
- check_and_suggest_reorders_logic(username="System") # Run check after inventory changes
353
-
354
- return True, f"Quantity for item '{item_row['item_name']}' updated.", new_quantity
355
-
356
- except (ValueError, TypeError) as ve:
357
- return False, f"Invalid input data type: {ve}", None
358
- except sqlite3.Error as e:
359
- if conn: conn.close()
360
- print(f"ERROR: Failed to update inventory quantity for item ID {item_id}: {e}")
361
- log_event(username, 'INVENTORY_UPDATE_ERROR', {'item_id': item_id, 'change': quantity_change, 'error': str(e)})
362
- return False, f"Database error updating quantity: {e}", None
363
-
364
- def get_suppliers_logic() -> List[Dict[str, Any]]:
365
- """Retrieves a list of all suppliers."""
366
- suppliers = []
367
- try:
368
- conn = get_db_connection()
369
- cursor = conn.cursor()
370
- cursor.execute("SELECT supplier_id, supplier_name, contact_info, notes FROM suppliers ORDER BY supplier_name;")
371
- suppliers = [dict(row) for row in cursor.fetchall()] # Convert to dicts
372
- conn.close()
373
- except sqlite3.Error as e:
374
- print(f"ERROR: Failed to retrieve suppliers: {e}")
375
- return suppliers
376
-
377
- def add_supplier_logic(name: str, contact_info: str, notes: str, username: str) -> Tuple[bool, str, Optional[int]]:
378
- """Adds a new supplier. Returns success, message, new supplier ID."""
379
- if not name:
380
- return False, "Supplier Name is required.", None
381
- try:
382
- conn = get_db_connection()
383
- cursor = conn.cursor()
384
- cursor.execute("""
385
- INSERT INTO suppliers (supplier_name, contact_info, notes)
386
- VALUES (?, ?, ?)
387
- """, (name, contact_info, notes))
388
- supplier_id = cursor.lastrowid
389
- conn.commit()
390
- conn.close()
391
- log_event(username, 'SUPPLIER_ADD', {'supplier_id': supplier_id, 'name': name})
392
- return True, f"Supplier '{name}' added successfully.", supplier_id
393
  except sqlite3.IntegrityError:
394
- return False, f"Supplier with name '{name}' already exists.", None
 
 
395
  except sqlite3.Error as e:
396
- print(f"ERROR: Failed to add supplier {name}: {e}")
397
- log_event(username, 'SUPPLIER_ADD_ERROR', {'name': name, 'error': str(e)})
398
- return False, f"Database error adding supplier: {e}", None
399
-
400
-
401
- def check_and_suggest_reorders_logic(username: Optional[str] = "System") -> int:
402
- """Checks inventory and creates suggestions. Returns number of suggestions created."""
403
- suggestions_created_count = 0
404
- conn = None
405
- try:
406
- conn = get_db_connection()
407
- cursor = conn.cursor()
408
-
409
- cursor.execute("""
410
- SELECT
411
- i.item_id, i.item_name, i.current_quantity, i.reorder_level,
412
- i.preferred_supplier_id, s.supplier_name
413
- FROM inventory_items i
414
- LEFT JOIN suppliers s ON i.preferred_supplier_id = s.supplier_id
415
- WHERE i.reorder_level IS NOT NULL
416
- AND i.current_quantity < i.reorder_level
417
- AND NOT EXISTS (
418
- SELECT 1 FROM purchase_suggestions ps
419
- WHERE ps.item_id = i.item_id AND ps.status = 'pending'
420
- );
421
- """)
422
- items_to_reorder = cursor.fetchall()
423
-
424
- if not items_to_reorder:
425
- conn.close()
426
- return 0
427
-
428
- for item in items_to_reorder:
429
- item_id = item['item_id']
430
- item_name = item['item_name']
431
- current_qty = item['current_quantity']
432
- reorder_lvl = item['reorder_level']
433
- supplier_id = item['preferred_supplier_id']
434
-
435
- suggested_quantity = max(1, round(reorder_lvl - current_qty + (reorder_lvl * 0.2), 2))
436
- reason = f"Stock ({current_qty}) below reorder level ({reorder_lvl})"
437
-
438
- try:
439
- cursor.execute("""
440
- INSERT INTO purchase_suggestions (item_id, suggested_quantity, supplier_id, reason, status)
441
- VALUES (?, ?, ?, ?, 'pending')
442
- """, (item_id, suggested_quantity, supplier_id, reason))
443
- suggestion_id = cursor.lastrowid
444
- log_event(username, 'REORDER_SUGGESTION_CREATED', {
445
- 'suggestion_id': suggestion_id, 'item_id': item_id, 'item_name': item_name,
446
- 'suggested_quantity': suggested_quantity, 'supplier_id': supplier_id, 'reason': reason
447
- })
448
- suggestions_created_count += 1
449
-
450
- except sqlite3.Error as insert_e:
451
- print(f"ERROR: Failed to insert purchase suggestion for item ID {item_id}: {insert_e}")
452
- log_event(username, 'REORDER_SUGGESTION_ERROR', {'item_id': item_id, 'error': str(insert_e)})
453
- # Continue to next item
454
-
455
- conn.commit() # Commit all inserts at once
456
- conn.close()
457
- if suggestions_created_count > 0:
458
- print(f"INFO: Created {suggestions_created_count} new purchase suggestions by {username}.")
459
- return suggestions_created_count
460
-
461
- except sqlite3.Error as e:
462
- if conn: conn.close()
463
- print(f"ERROR: Failed during reorder check: {e}")
464
- log_event(username, 'REORDER_CHECK_ERROR', {'error': str(e)})
465
- return 0
466
-
467
- def get_pending_suggestions_logic() -> List[Dict[str, Any]]:
468
- """Retrieves all pending purchase suggestions."""
469
- suggestions = []
470
- try:
471
- conn = get_db_connection()
472
- cursor = conn.cursor()
473
- cursor.execute("""
474
- SELECT
475
- ps.suggestion_id, ps.item_id, i.item_name, ps.suggested_quantity, i.unit,
476
- ps.supplier_id, s.supplier_name, ps.reason,
477
- strftime('%Y-%m-%d %H:%M:%S', ps.created_at) as created_at_str
478
- FROM purchase_suggestions ps
479
- JOIN inventory_items i ON ps.item_id = i.item_id
480
- LEFT JOIN suppliers s ON ps.supplier_id = s.supplier_id
481
- WHERE ps.status = 'pending'
482
- ORDER BY ps.created_at DESC;
483
- """)
484
- suggestions = [dict(row) for row in cursor.fetchall()] # Convert to dicts
485
- conn.close()
486
- except sqlite3.Error as e:
487
- print(f"ERROR: Failed to retrieve pending purchase suggestions: {e}")
488
- return suggestions
489
-
490
- def resolve_suggestion_logic(suggestion_id: int, action: str, received_quantity: Optional[float], username: str) -> Tuple[bool, str]:
491
- """Resolves a suggestion. Returns success, message."""
492
- if action not in ['ordered', 'received', 'cancelled']:
493
- return False, "Invalid action. Must be 'ordered', 'received', or 'cancelled'."
494
- if action == 'received' and (received_quantity is None or received_quantity <= 0):
495
- return False, "Received quantity must be provided and positive for 'received' action."
496
-
497
- conn = None
498
- try:
499
- suggestion_id = int(suggestion_id)
500
- if received_quantity is not None: received_quantity = float(received_quantity)
501
-
502
- conn = get_db_connection()
503
- conn.execute("BEGIN TRANSACTION;") # Use transaction for atomicity
504
- cursor = conn.cursor()
505
-
506
- # Check suggestion exists and is pending
507
- cursor.execute("SELECT item_id, status FROM purchase_suggestions WHERE suggestion_id = ?", (suggestion_id,))
508
- suggestion_row = cursor.fetchone()
509
-
510
- if not suggestion_row:
511
- conn.rollback()
512
- conn.close()
513
- return False, f"Suggestion with ID {suggestion_id} not found."
514
- if suggestion_row['status'] != 'pending':
515
- conn.rollback()
516
- conn.close()
517
- return False, f"Suggestion {suggestion_id} is already resolved with status: {suggestion_row['status']}."
518
-
519
- item_id = suggestion_row['item_id']
520
-
521
- # Update suggestion status
522
- cursor.execute("""
523
- UPDATE purchase_suggestions
524
- SET status = ?, resolved_at = CURRENT_TIMESTAMP
525
- WHERE suggestion_id = ?
526
- """, (action, suggestion_id))
527
-
528
- inventory_update_msg = ""
529
- if action == 'received':
530
- reason = f"Received based on suggestion ID {suggestion_id}"
531
- # Call the logic function which handles its own commit/logging
532
- success, msg, _ = update_inventory_quantity_logic(item_id, received_quantity, reason, username)
533
- if not success:
534
- # IMPORTANT: Rollback the transaction if inventory update fails
535
- conn.rollback()
536
- conn.close()
537
- log_event(username, 'SUGGESTION_RESOLVE_ERROR', {'suggestion_id': suggestion_id, 'action': action, 'error': f'Inventory update failed: {msg}'})
538
- return False, f"Failed to resolve suggestion: Inventory update failed ({msg})"
539
- inventory_update_msg = msg # Store success message if needed
540
-
541
- # If we reached here, all parts succeeded or were not needed (e.g., 'ordered', 'cancelled')
542
- conn.commit()
543
- conn.close()
544
-
545
- log_event(username, 'SUGGESTION_RESOLVED', {'suggestion_id': suggestion_id, 'action': action, 'received_quantity': received_quantity if action == 'received' else None})
546
- final_msg = f"Suggestion ID {suggestion_id} resolved as '{action}'."
547
- if action == 'received':
548
- final_msg += f" Inventory updated: {inventory_update_msg}" # Append inventory update result
549
- return True, final_msg
550
 
551
- except (ValueError, TypeError) as ve:
552
- if conn: conn.rollback()
553
- if conn: conn.close()
554
- return False, f"Invalid input data type: {ve}"
555
- except sqlite3.Error as e:
556
- if conn: conn.rollback() # Rollback on any SQL error
557
- if conn: conn.close()
558
- print(f"ERROR: Failed to resolve suggestion {suggestion_id}: {e}")
559
- log_event(username, 'SUGGESTION_RESOLVE_ERROR', {'suggestion_id': suggestion_id, 'action': action, 'error': str(e)})
560
- return False, f"Database error resolving suggestion: {e}"
561
 
562
  # --- Flask Application Setup ---
563
  app = Flask(__name__)
564
- app.secret_key = SECRET_KEY
565
-
566
- # --- Authentication Decorator ---
567
- def login_required(f):
568
- @wraps(f)
569
- def decorated_function(*args, **kwargs):
570
- if 'username' not in session:
571
- # For API requests, return 401 Unauthorized
572
- if request.endpoint and request.endpoint.startswith('api_'):
573
- return jsonify({"success": False, "message": "Authentication required"}), 401
574
- # For page loads, redirect to login
575
- return redirect(url_for('login'))
576
- return f(*args, **kwargs)
577
- return decorated_function
578
 
579
  # --- HTML/CSS/JS Templates (Embedded in Python String) ---
580
 
581
- # NOTE: This makes the file very long. In a real project, use Flask's template rendering with separate files.
 
582
  HTML_TEMPLATE = """
583
  <!DOCTYPE html>
584
  <html lang="en">
585
  <head>
586
  <meta charset="UTF-8">
587
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
588
- <title>Business Suite v2.0</title>
589
  <style>
590
- /* Ultra-minimalist CSS for efficiency */
591
  body { font-family: sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f4f4f4; color: #333; }
592
  .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; }
593
  header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; }
@@ -608,27 +362,43 @@ HTML_TEMPLATE = """
608
  tr:nth-child(even) { background-color: #f9f9f9; }
609
  form { margin-top: 20px; padding: 15px; background-color: #f9f9f9; border: 1px solid #eee; border-radius: 5px; }
610
  form label { display: block; margin-bottom: 5px; font-weight: bold; }
611
- form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea {
612
- width: calc(100% - 22px); /* Adjust for padding/border */
613
  padding: 10px;
614
  margin-bottom: 10px;
615
  border: 1px solid #ccc;
616
  border-radius: 4px;
617
- box-sizing: border-box; /* Include padding and border in the element's total width and height */
 
 
618
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
619
  form textarea { height: 60px; }
620
  form button { background-color: #28a745; color: white; border: none; padding: 12px 20px; border-radius: 4px; cursor: pointer; font-size: 1em; }
621
  form button:hover { background-color: #218838; }
622
  .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
623
- .form-group { margin-bottom: 0; } /* Remove default bottom margin when in grid */
624
  .status-message { padding: 10px; margin-top: 15px; border-radius: 4px; font-weight: bold; text-align: center; }
625
  .status-success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
626
  .status-error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
627
- .hidden { display: none; }
628
  .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; }
629
  .accordion:hover, .accordion.active { background-color: #ccc; }
630
  .panel { padding: 0 18px; background-color: white; display: none; overflow: hidden; border: 1px solid #eee; border-top: none; border-radius: 0 0 4px 4px; }
631
- /* Login Form Specifics */
632
  #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; }
633
  #login-form h2 { text-align: center; margin-bottom: 25px; color: #333; border: none;}
634
  #login-form button { width: 100%; background-color: #007bff; }
@@ -639,6 +409,21 @@ HTML_TEMPLATE = """
639
  button .spinner { display: none; } /* Hide spinner by default */
640
  button.loading .spinner { display: inline-block; } /* Show spinner when button has loading class */
641
  button.loading span { vertical-align: middle; } /* Align text with spinner */
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
642
  </style>
643
  </head>
644
  <body>
@@ -673,12 +458,13 @@ HTML_TEMPLATE = """
673
  <button data-section="inventory" class="nav-btn active">📦 Inventory</button>
674
  <button data-section="suppliers" class="nav-btn">🤝 Suppliers</button>
675
  <button data-section="suggestions" class="nav-btn">💡 Suggestions</button>
676
- <!-- Add more buttons for other sections like config/admin later -->
 
677
  </nav>
678
 
679
  <div id="global-status" class="status-message hidden"></div>
680
 
681
- <!-- Inventory Section -->
682
  <section id="inventory-section" class="active">
683
  <h2>Inventory Management</h2>
684
  <button class="accordion">Perform Inventory Actions ▼</button>
@@ -700,7 +486,7 @@ HTML_TEMPLATE = """
700
  <label for="inv-quantity-change">Quantity:</label>
701
  <input type="number" id="inv-quantity-change" name="quantity_change" required step="any">
702
  </div>
703
- <div class="form-group" style="grid-column: 1 / -1;"> {/* Span full width */}
704
  <label for="inv-reason">Reason/Note:</label>
705
  <input type="text" id="inv-reason" name="reason" placeholder="e.g., Customer order #123, Stocktake correction">
706
  </div>
@@ -748,7 +534,7 @@ HTML_TEMPLATE = """
748
  <div id="inventory-table-container"><div class="loading">Loading inventory...</div></div>
749
  </section>
750
 
751
- <!-- Suppliers Section -->
752
  <section id="suppliers-section">
753
  <h2>Supplier Management</h2>
754
  <button class="accordion">Add New Supplier ▼</button>
@@ -777,7 +563,7 @@ HTML_TEMPLATE = """
777
  <div id="suppliers-table-container"><div class="loading">Loading suppliers...</div></div>
778
  </section>
779
 
780
- <!-- Suggestions Section -->
781
  <section id="suggestions-section">
782
  <h2>Purchase Suggestions</h2>
783
  <button class="accordion">Resolve Pending Suggestion ▼</button>
@@ -812,13 +598,47 @@ HTML_TEMPLATE = """
812
  <div id="suggestions-table-container"><div class="loading">Loading suggestions...</div></div>
813
  </section>
814
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
815
  </div>
816
 
817
  <script>
818
- // Encapsulate JS in an IIFE (Immediately Invoked Function Expression)
819
  (function() {
820
  // --- State & Configuration ---
821
  let currentSection = 'inventory'; // Default section
 
 
822
 
823
  // --- DOM Element References ---
824
  const loginSection = document.getElementById('login-section');
@@ -829,7 +649,7 @@ HTML_TEMPLATE = """
829
  const userDisplay = document.getElementById('user-display');
830
  const adminDisplay = document.getElementById('admin-display');
831
  const logoutBtn = document.getElementById('logout-btn');
832
- const navButtons = document.querySelectorAll('.nav-btn');
833
  const sections = document.querySelectorAll('section');
834
  const globalStatus = document.getElementById('global-status');
835
 
@@ -837,12 +657,14 @@ HTML_TEMPLATE = """
837
  const inventoryTableContainer = document.getElementById('inventory-table-container');
838
  const suppliersTableContainer = document.getElementById('suppliers-table-container');
839
  const suggestionsTableContainer = document.getElementById('suggestions-table-container');
 
840
 
841
  // Forms
842
  const inventoryActionForm = document.getElementById('inventory-action-form');
843
  const addItemForm = document.getElementById('add-item-form');
844
  const addSupplierForm = document.getElementById('add-supplier-form');
845
  const resolveSuggestionForm = document.getElementById('resolve-suggestion-form');
 
846
 
847
  // Suggestion Form Specific Elements
848
  const resolveSuggestionIdSelect = document.getElementById('resolve-suggestion-id');
@@ -854,6 +676,7 @@ HTML_TEMPLATE = """
854
  const refreshInventoryBtn = document.getElementById('refresh-inventory-btn');
855
  const refreshSuppliersBtn = document.getElementById('refresh-suppliers-btn');
856
  const refreshSuggestionsBtn = document.getElementById('refresh-suggestions-btn');
 
857
 
858
 
859
  // --- Utility Functions ---
@@ -862,13 +685,19 @@ HTML_TEMPLATE = """
862
  async function apiRequest(endpoint, method = 'GET', body = null, button = null) {
863
  const options = {
864
  method: method,
865
- headers: {}
 
 
866
  };
867
  if (body) {
868
- options.headers['Content-Type'] = 'application/json';
869
  options.body = JSON.stringify(body);
 
 
 
 
870
  }
871
 
 
872
  // Show spinner on button if provided
873
  if (button) {
874
  button.classList.add('loading');
@@ -877,16 +706,36 @@ HTML_TEMPLATE = """
877
 
878
  try {
879
  const response = await fetch(endpoint, options);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
880
  const data = await response.json(); // Assume server always returns JSON
881
 
882
  if (!response.ok) {
883
- // Throw an error with the message from the server's JSON response
884
  throw new Error(data.message || `HTTP error! status: ${response.status}`);
885
  }
886
  return data; // Contains { success: true, ... } or similar
887
  } catch (error) {
888
  console.error('API Request Error:', error);
889
- // Rethrow a simplified error message for display
 
 
 
 
890
  throw new Error(error.message || 'Network error or server unavailable.');
891
  } finally {
892
  // Hide spinner and re-enable button
@@ -923,7 +772,14 @@ HTML_TEMPLATE = """
923
  data.forEach(row => {
924
  html += '<tr>';
925
  columns.forEach(col => {
926
- html += `<td>${row[col] !== null && row[col] !== undefined ? row[col] : ''}</td>`;
 
 
 
 
 
 
 
927
  });
928
  html += '</tr>';
929
  });
@@ -937,12 +793,13 @@ HTML_TEMPLATE = """
937
  inventoryTableContainer.innerHTML = '<div class="loading">Loading inventory...</div>';
938
  try {
939
  const data = await apiRequest('/api/inventory', 'GET', null, button);
940
- if (data.success) {
941
  const columns = ["item_id", "item_name", "description", "current_quantity", "unit", "reorder_level", "preferred_supplier_name", "last_updated_str"];
942
  inventoryTableContainer.innerHTML = createTable(data.inventory, columns);
943
- } else {
944
- throw new Error(data.message || "Failed to load inventory.");
945
  }
 
946
  } catch (error) {
947
  inventoryTableContainer.innerHTML = `<p class="status-error">Error loading inventory: ${error.message}</p>`;
948
  }
@@ -952,10 +809,10 @@ HTML_TEMPLATE = """
952
  suppliersTableContainer.innerHTML = '<div class="loading">Loading suppliers...</div>';
953
  try {
954
  const data = await apiRequest('/api/suppliers', 'GET', null, button);
955
- if (data.success) {
956
  const columns = ["supplier_id", "supplier_name", "contact_info", "notes"];
957
  suppliersTableContainer.innerHTML = createTable(data.suppliers, columns);
958
- } else {
959
  throw new Error(data.message || "Failed to load suppliers.");
960
  }
961
  } catch (error) {
@@ -968,7 +825,7 @@ HTML_TEMPLATE = """
968
  resolveSuggestionIdSelect.innerHTML = '<option value="">-- Loading... --</option>'; // Clear previous options
969
  try {
970
  const data = await apiRequest('/api/suggestions', 'GET', null, button);
971
- if (data.success) {
972
  const columns = ["suggestion_id", "item_name", "suggested_quantity", "unit", "supplier_name", "reason", "created_at_str"];
973
  suggestionsTableContainer.innerHTML = createTable(data.suggestions, columns);
974
 
@@ -985,7 +842,7 @@ HTML_TEMPLATE = """
985
  resolveSuggestionIdSelect.innerHTML = '<option value="">-- No pending suggestions --</option>';
986
  }
987
 
988
- } else {
989
  throw new Error(data.message || "Failed to load suggestions.");
990
  }
991
  } catch (error) {
@@ -994,6 +851,36 @@ HTML_TEMPLATE = """
994
  }
995
  }
996
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
997
  // --- Event Handlers ---
998
 
999
  // Login Handler
@@ -1006,19 +893,37 @@ HTML_TEMPLATE = """
1006
 
1007
  try {
1008
  const data = await apiRequest('/api/login', 'POST', { username, password }, button);
1009
- if (data.success) {
 
 
 
 
1010
  // Update UI for logged-in state
1011
  loginSection.classList.add('hidden');
1012
  appSection.classList.remove('hidden');
1013
  userDisplay.textContent = data.user.username;
1014
- adminDisplay.textContent = data.user.is_admin ? 'Admin' : 'User';
 
 
 
 
 
 
 
 
 
 
1015
  // Load initial data for the default section
1016
- navigateTo(currentSection); // Load data for the current/default view
1017
- } else {
 
1018
  throw new Error(data.message || "Login failed.");
1019
  }
1020
  } catch (error) {
1021
- showStatus(error.message, true, loginError);
 
 
 
1022
  }
1023
  });
1024
 
@@ -1026,16 +931,31 @@ HTML_TEMPLATE = """
1026
  logoutBtn.addEventListener('click', async () => {
1027
  try {
1028
  const data = await apiRequest('/api/logout', 'POST');
1029
- if (data.success) {
 
 
 
 
1030
  // Update UI for logged-out state
1031
  loginSection.classList.remove('hidden');
1032
  appSection.classList.add('hidden');
1033
  loginForm.reset(); // Clear login form fields
1034
- // Clear data tables? Optional, they will reload on next login.
 
 
 
 
 
 
1035
  inventoryTableContainer.innerHTML = '';
1036
  suppliersTableContainer.innerHTML = '';
1037
  suggestionsTableContainer.innerHTML = '';
1038
- } else {
 
 
 
 
 
1039
  showStatus(data.message || "Logout failed.", true);
1040
  }
1041
  } catch (error) {
@@ -1047,6 +967,17 @@ HTML_TEMPLATE = """
1047
  navButtons.forEach(button => {
1048
  button.addEventListener('click', () => {
1049
  const sectionId = button.getAttribute('data-section');
 
 
 
 
 
 
 
 
 
 
 
1050
  navigateTo(sectionId);
1051
  });
1052
  });
@@ -1065,6 +996,7 @@ HTML_TEMPLATE = """
1065
  if (sectionId === 'inventory') loadInventory();
1066
  else if (sectionId === 'suppliers') loadSuppliers();
1067
  else if (sectionId === 'suggestions') loadSuggestions();
 
1068
  } else {
1069
  sec.classList.remove('active');
1070
  }
@@ -1073,40 +1005,35 @@ HTML_TEMPLATE = """
1073
  globalStatus.classList.add('hidden'); // Hide any previous global status messages
1074
  }
1075
 
1076
- // Inventory Action Handler
1077
  inventoryActionForm.addEventListener('submit', async (e) => {
1078
  e.preventDefault();
1079
  const formData = new FormData(inventoryActionForm);
1080
  const action_type = formData.get('action_type');
1081
  const item_id = formData.get('item_id');
1082
  let quantity_change = parseFloat(formData.get('quantity_change'));
1083
- const reason = formData.get('reason') || `Action: ${action_type}`; // Default reason if empty
1084
  const button = inventoryActionForm.querySelector('button[type="submit"]');
1085
 
1086
- if (isNaN(quantity_change)) {
1087
- showStatus("Quantity must be a number.", true);
1088
- return;
1089
- }
1090
 
1091
- // Adjust quantity based on action type
1092
  if (action_type === 'sale') {
1093
- if (quantity_change > 0) quantity_change = -quantity_change; // Sales decrease stock
1094
  else { showStatus("Sale quantity must be positive.", true); return; }
1095
  } else if (action_type === 'delivery') {
1096
  if (quantity_change < 0) { showStatus("Delivery quantity must be positive.", true); return; }
1097
  }
1098
- // 'adjustment' allows positive or negative
1099
 
1100
  const payload = { quantity_change, reason };
1101
 
1102
  try {
1103
  const data = await apiRequest(`/api/inventory/${item_id}/quantity`, 'POST', payload, button);
1104
- if (data.success) {
1105
  showStatus(data.message || "Inventory updated successfully.", false);
1106
- inventoryActionForm.reset(); // Clear form
1107
- loadInventory(); // Refresh inventory table
1108
- loadSuggestions(); // Refresh suggestions as stock changed
1109
- } else {
1110
  throw new Error(data.message || "Failed to update inventory.");
1111
  }
1112
  } catch (error) {
@@ -1114,12 +1041,11 @@ HTML_TEMPLATE = """
1114
  }
1115
  });
1116
 
1117
- // Add Item Handler
1118
  addItemForm.addEventListener('submit', async (e) => {
1119
  e.preventDefault();
1120
  const formData = new FormData(addItemForm);
1121
  const payload = Object.fromEntries(formData.entries());
1122
- // Convert number fields explicitly, handle empty optional fields
1123
  payload.initial_quantity = parseFloat(payload.initial_quantity || 0);
1124
  payload.reorder_level = payload.reorder_level ? parseFloat(payload.reorder_level) : null;
1125
  payload.preferred_supplier_id = payload.preferred_supplier_id ? parseInt(payload.preferred_supplier_id) : null;
@@ -1132,11 +1058,11 @@ HTML_TEMPLATE = """
1132
 
1133
  try {
1134
  const data = await apiRequest('/api/inventory', 'POST', payload, button);
1135
- if (data.success) {
1136
  showStatus(data.message || "Item added successfully.", false);
1137
- addItemForm.reset(); // Clear form
1138
- loadInventory(); // Refresh list
1139
- } else {
1140
  throw new Error(data.message || "Failed to add item.");
1141
  }
1142
  } catch (error) {
@@ -1144,7 +1070,7 @@ HTML_TEMPLATE = """
1144
  }
1145
  });
1146
 
1147
- // Add Supplier Handler
1148
  addSupplierForm.addEventListener('submit', async (e) => {
1149
  e.preventDefault();
1150
  const formData = new FormData(addSupplierForm);
@@ -1153,11 +1079,11 @@ HTML_TEMPLATE = """
1153
 
1154
  try {
1155
  const data = await apiRequest('/api/suppliers', 'POST', payload, button);
1156
- if (data.success) {
1157
  showStatus(data.message || "Supplier added successfully.", false);
1158
- addSupplierForm.reset(); // Clear form
1159
- loadSuppliers(); // Refresh list
1160
- } else {
1161
  throw new Error(data.message || "Failed to add supplier.");
1162
  }
1163
  } catch (error) {
@@ -1165,7 +1091,7 @@ HTML_TEMPLATE = """
1165
  }
1166
  });
1167
 
1168
- // Resolve Suggestion Form - Show/Hide Received Quantity
1169
  resolveActionSelect.addEventListener('change', () => {
1170
  if (resolveActionSelect.value === 'received') {
1171
  receivedQtyGroup.classList.remove('hidden');
@@ -1173,11 +1099,11 @@ HTML_TEMPLATE = """
1173
  } else {
1174
  receivedQtyGroup.classList.add('hidden');
1175
  resolveReceivedQtyInput.required = false;
1176
- resolveReceivedQtyInput.value = ''; // Clear value when hidden
1177
  }
1178
  });
1179
 
1180
- // Resolve Suggestion Handler
1181
  resolveSuggestionForm.addEventListener('submit', async (e) => {
1182
  e.preventDefault();
1183
  const formData = new FormData(resolveSuggestionForm);
@@ -1186,10 +1112,7 @@ HTML_TEMPLATE = """
1186
  let received_quantity = formData.get('received_quantity');
1187
  const button = resolveSuggestionForm.querySelector('button[type="submit"]');
1188
 
1189
- if (!suggestion_id) {
1190
- showStatus("Please select a suggestion to resolve.", true);
1191
- return;
1192
- }
1193
 
1194
  const payload = { action };
1195
  if (action === 'received') {
@@ -1198,19 +1121,22 @@ HTML_TEMPLATE = """
1198
  return;
1199
  }
1200
  payload.received_quantity = parseFloat(received_quantity);
 
 
1201
  }
1202
 
 
1203
  try {
1204
  const data = await apiRequest(`/api/suggestions/${suggestion_id}/resolve`, 'POST', payload, button);
1205
- if (data.success) {
1206
  showStatus(data.message || "Suggestion resolved successfully.", false);
1207
- resolveSuggestionForm.reset(); // Clear form
1208
- receivedQtyGroup.classList.add('hidden'); // Re-hide quantity input
1209
  loadSuggestions(); // Refresh suggestions list & dropdown
1210
  if (action === 'received') {
1211
  loadInventory(); // Refresh inventory if items were received
1212
  }
1213
- } else {
1214
  throw new Error(data.message || "Failed to resolve suggestion.");
1215
  }
1216
  } catch (error) {
@@ -1218,12 +1144,53 @@ HTML_TEMPLATE = """
1218
  }
1219
  });
1220
 
1221
- // Refresh Button Handlers
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1222
  refreshInventoryBtn.addEventListener('click', () => loadInventory(refreshInventoryBtn));
1223
  refreshSuppliersBtn.addEventListener('click', () => loadSuppliers(refreshSuppliersBtn));
1224
  refreshSuggestionsBtn.addEventListener('click', () => loadSuggestions(refreshSuggestionsBtn));
 
1225
 
1226
- // Accordion Handler
1227
  document.querySelectorAll('.accordion').forEach(accordion => {
1228
  accordion.addEventListener('click', function() {
1229
  this.classList.toggle('active');
@@ -1238,14 +1205,31 @@ HTML_TEMPLATE = """
1238
  });
1239
  });
1240
 
1241
- // --- Initial Load ---
1242
  function initialize() {
1243
- // Check login status on page load (handled by Flask template rendering initially)
1244
- // If logged in, load data for the default section
1245
- if (appSection && !appSection.classList.contains('hidden')) {
1246
- navigateTo(currentSection); // Load default section data
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1247
  }
1248
- // Ensure panels are closed initially if accordions are added dynamically later
 
1249
  document.querySelectorAll('.panel').forEach(panel => panel.style.display = 'none');
1250
  document.querySelectorAll('.accordion').forEach(acc => acc.innerHTML = acc.innerHTML.replace('▲', '▼'));
1251
 
@@ -1261,20 +1245,18 @@ HTML_TEMPLATE = """
1261
  """
1262
 
1263
 
 
 
 
 
1264
  # --- Flask Routes ---
1265
 
1266
  @app.route('/')
1267
  def index():
1268
- """Serves the main application page or login page."""
1269
- # Check if user is logged in via session
1270
- if 'username' in session:
1271
- # User is logged in, render the main app page
1272
- # Pass session info to the template (though JS might fetch some again)
1273
- return render_template_string(HTML_TEMPLATE, session=session)
1274
- else:
1275
- # User not logged in, render the login page part of the template
1276
- # The template uses Jinja2 conditionals (`{{ 'hidden' if ... }}`) to show/hide sections
1277
- return render_template_string(HTML_TEMPLATE, session={}) # Pass empty session
1278
 
1279
 
1280
  @app.route('/api/login', methods=['POST'])
@@ -1285,7 +1267,8 @@ def api_login():
1285
  password = data.get('password')
1286
 
1287
  if not username or not password:
1288
- log_event(username, 'LOGIN_FAILURE', {'reason': 'Missing credentials', 'ip_address': request.remote_addr})
 
1289
  return jsonify({"success": False, "message": "Username and password are required"}), 400
1290
 
1291
  conn = None
@@ -1294,7 +1277,7 @@ def api_login():
1294
  cursor = conn.cursor()
1295
  cursor.execute("SELECT username, hashed_password, salt, is_admin FROM users WHERE username = ?", (username,))
1296
  user_row = cursor.fetchone()
1297
- conn.close()
1298
 
1299
  if user_row:
1300
  stored_hash = user_row['hashed_password']
@@ -1302,15 +1285,17 @@ def api_login():
1302
  if verify_password(stored_hash, salt, password):
1303
  # Login successful - store user info in session
1304
  session['username'] = user_row['username']
1305
- session['is_admin'] = bool(user_row['is_admin'])
 
1306
  log_event(username, 'LOGIN_SUCCESS', {'ip_address': request.remote_addr})
 
1307
  user_info = {"username": user_row['username'], "is_admin": bool(user_row['is_admin'])}
1308
  return jsonify({"success": True, "message": "Login successful", "user": user_info})
1309
  else:
1310
  log_event(username, 'LOGIN_FAILURE', {'reason': 'Invalid password', 'ip_address': request.remote_addr})
1311
  return jsonify({"success": False, "message": "Invalid username or password"}), 401
1312
  else:
1313
- log_event(username, 'LOGIN_FAILURE', {'reason': 'User not found', 'ip_address': request.remote_addr})
1314
  return jsonify({"success": False, "message": "Invalid username or password"}), 401
1315
 
1316
  except sqlite3.Error as e:
@@ -1320,7 +1305,8 @@ def api_login():
1320
  return jsonify({"success": False, "message": "Server error during login"}), 500
1321
  except Exception as e:
1322
  print(f"ERROR: Unexpected error during login for user {username}: {e}")
1323
- log_event(username, 'LOGIN_UNEXPECTED_ERROR', {'error': str(e), 'ip_address': request.remote_addr})
 
1324
  if conn: conn.close()
1325
  return jsonify({"success": False, "message": "An unexpected server error occurred"}), 500
1326
 
@@ -1329,208 +1315,86 @@ def api_login():
1329
  @login_required # Ensure user is logged in to log out
1330
  def api_logout():
1331
  """Logs the user out by clearing the session."""
1332
- username = session.get('username', 'Unknown') # Get username before popping
1333
  session.pop('username', None)
1334
  session.pop('is_admin', None)
 
 
1335
  log_event(username, 'LOGOUT', {'ip_address': request.remote_addr})
1336
  return jsonify({"success": True, "message": "Logged out successfully"})
1337
 
1338
  @app.route('/api/status')
1339
  def api_status():
1340
  """Checks if the user is currently logged in."""
 
1341
  if 'username' in session:
1342
  user_info = {"username": session['username'], "is_admin": session.get('is_admin', False)}
1343
  return jsonify({"logged_in": True, "user": user_info})
1344
  else:
1345
  return jsonify({"logged_in": False})
1346
 
1347
- # --- API Routes for Business Logic ---
1348
-
1349
- @app.route('/api/inventory', methods=['GET'])
1350
- @login_required
1351
- def api_get_inventory():
1352
- """API endpoint to get inventory summary."""
1353
- try:
1354
- items = get_inventory_summary_logic()
1355
- return jsonify({"success": True, "inventory": items})
1356
- except Exception as e:
1357
- print(f"API ERROR: /api/inventory GET: {e}")
1358
- return jsonify({"success": False, "message": f"Failed to retrieve inventory: {e}"}), 500
1359
-
1360
- @app.route('/api/inventory', methods=['POST'])
1361
- @login_required
1362
- def api_add_inventory_item():
1363
- """API endpoint to add a new inventory item."""
1364
- data = request.get_json()
1365
- if not data: return jsonify({"success": False, "message": "Invalid request body"}), 400
1366
-
1367
- # Basic validation - more robust validation is recommended
1368
- name = data.get('name')
1369
- unit = data.get('unit', 'units')
1370
- description = data.get('description', '')
1371
- initial_quantity = data.get('initial_quantity', 0)
1372
- reorder_level = data.get('reorder_level') # Can be None
1373
- preferred_supplier_id = data.get('preferred_supplier_id') # Can be None
1374
- username = session['username']
1375
-
1376
- if not name: return jsonify({"success": False, "message": "Item Name is required"}), 400
1377
-
1378
  try:
1379
- success, message, item_id = add_inventory_item_logic(
1380
- name, description, unit, reorder_level, preferred_supplier_id, initial_quantity, username
1381
- )
1382
- if success:
1383
- return jsonify({"success": True, "message": message, "item_id": item_id})
1384
- else:
1385
- # Determine appropriate status code based on message (e.g., 409 Conflict for duplicate)
1386
- status_code = 409 if 'already exists' in message else 400
1387
- return jsonify({"success": False, "message": message}), status_code
1388
  except Exception as e:
1389
- print(f"API ERROR: /api/inventory POST: {e}")
1390
- log_event(username, 'INVENTORY_ADD_API_ERROR', {'error': str(e), 'data': data})
1391
- return jsonify({"success": False, "message": f"Server error adding item: {e}"}), 500
1392
-
1393
-
1394
- @app.route('/api/inventory/<int:item_id>/quantity', methods=['POST']) # Using POST for simplicity, PUT is more RESTful
1395
- @login_required
1396
- def api_update_inventory_quantity(item_id):
1397
- """API endpoint to update inventory quantity."""
1398
  data = request.get_json()
1399
- if not data: return jsonify({"success": False, "message": "Invalid request body"}), 400
1400
-
1401
- quantity_change = data.get('quantity_change')
1402
- reason = data.get('reason', 'API Update')
1403
- username = session['username']
1404
-
1405
- if quantity_change is None:
1406
- return jsonify({"success": False, "message": "quantity_change is required"}), 400
1407
-
1408
- try:
1409
- # Ensure types are correct before passing to logic
1410
- item_id_int = int(item_id)
1411
- quantity_change_float = float(quantity_change)
1412
-
1413
- success, message, new_quantity = update_inventory_quantity_logic(
1414
- item_id_int, quantity_change_float, reason, username
1415
- )
1416
- if success:
1417
- return jsonify({"success": True, "message": message, "new_quantity": new_quantity})
1418
- else:
1419
- # Check if item not found
1420
- status_code = 404 if 'not found' in message else 400
1421
- return jsonify({"success": False, "message": message}), status_code
1422
- except (ValueError, TypeError):
1423
- return jsonify({"success": False, "message": "Invalid item_id or quantity_change format"}), 400
1424
- except Exception as e:
1425
- print(f"API ERROR: /api/inventory/{item_id}/quantity POST: {e}")
1426
- log_event(username, 'INVENTORY_UPDATE_API_ERROR', {'error': str(e), 'item_id': item_id, 'data': data})
1427
- return jsonify({"success": False, "message": f"Server error updating quantity: {e}"}), 500
1428
-
1429
-
1430
- @app.route('/api/suppliers', methods=['GET'])
1431
- @login_required
1432
- def api_get_suppliers():
1433
- """API endpoint to get supplier list."""
1434
- try:
1435
- suppliers = get_suppliers_logic()
1436
- return jsonify({"success": True, "suppliers": suppliers})
1437
- except Exception as e:
1438
- print(f"API ERROR: /api/suppliers GET: {e}")
1439
- return jsonify({"success": False, "message": f"Failed to retrieve suppliers: {e}"}), 500
1440
-
1441
- @app.route('/api/suppliers', methods=['POST'])
1442
- @login_required
1443
- def api_add_supplier():
1444
- """API endpoint to add a new supplier."""
1445
- data = request.get_json()
1446
- if not data: return jsonify({"success": False, "message": "Invalid request body"}), 400
1447
-
1448
- name = data.get('name')
1449
- contact_info = data.get('contact_info', '')
1450
- notes = data.get('notes', '')
1451
- username = session['username']
1452
 
1453
- if not name: return jsonify({"success": False, "message": "Supplier Name is required"}), 400
1454
-
1455
- try:
1456
- success, message, supplier_id = add_supplier_logic(name, contact_info, notes, username)
1457
- if success:
1458
- return jsonify({"success": True, "message": message, "supplier_id": supplier_id})
1459
- else:
1460
- status_code = 409 if 'already exists' in message else 400
1461
- return jsonify({"success": False, "message": message}), status_code
1462
- except Exception as e:
1463
- print(f"API ERROR: /api/suppliers POST: {e}")
1464
- log_event(username, 'SUPPLIER_ADD_API_ERROR', {'error': str(e), 'data': data})
1465
- return jsonify({"success": False, "message": f"Server error adding supplier: {e}"}), 500
1466
-
1467
-
1468
- @app.route('/api/suggestions', methods=['GET'])
1469
- @login_required
1470
- def api_get_suggestions():
1471
- """API endpoint to get pending purchase suggestions."""
1472
- try:
1473
- # Optional: Trigger reorder check before getting suggestions
1474
- check_and_suggest_reorders_logic(session['username']) # Check if new suggestions needed
1475
-
1476
- suggestions = get_pending_suggestions_logic()
1477
- return jsonify({"success": True, "suggestions": suggestions})
1478
- except Exception as e:
1479
- print(f"API ERROR: /api/suggestions GET: {e}")
1480
- return jsonify({"success": False, "message": f"Failed to retrieve suggestions: {e}"}), 500
1481
 
 
 
1482
 
1483
- @app.route('/api/suggestions/<int:suggestion_id>/resolve', methods=['POST'])
1484
- @login_required
1485
- def api_resolve_suggestion(suggestion_id):
1486
- """API endpoint to resolve a purchase suggestion."""
1487
- data = request.get_json()
1488
- if not data: return jsonify({"success": False, "message": "Invalid request body"}), 400
1489
-
1490
- action = data.get('action')
1491
- received_quantity = data.get('received_quantity') # Will be None if not provided
1492
- username = session['username']
1493
-
1494
- if not action or action not in ['ordered', 'received', 'cancelled']:
1495
- return jsonify({"success": False, "message": "Valid action ('ordered', 'received', 'cancelled') is required"}), 400
1496
-
1497
- if action == 'received':
1498
- if received_quantity is None:
1499
- return jsonify({"success": False, "message": "received_quantity is required for action 'received'"}), 400
1500
- try:
1501
- received_quantity = float(received_quantity)
1502
- if received_quantity <= 0:
1503
- return jsonify({"success": False, "message": "received_quantity must be positive"}), 400
1504
- except (ValueError, TypeError):
1505
- return jsonify({"success": False, "message": "Invalid received_quantity format"}), 400
1506
  else:
1507
- received_quantity = None # Ensure it's None if action is not 'received'
 
 
1508
 
1509
-
1510
- try:
1511
- # Ensure suggestion_id is int
1512
- suggestion_id_int = int(suggestion_id)
1513
-
1514
- success, message = resolve_suggestion_logic(
1515
- suggestion_id_int, action, received_quantity, username
1516
- )
1517
- if success:
1518
- return jsonify({"success": True, "message": message})
1519
- else:
1520
- status_code = 404 if 'not found' in message or 'already resolved' in message else 400
1521
- return jsonify({"success": False, "message": message}), status_code
1522
- except (ValueError, TypeError):
1523
- return jsonify({"success": False, "message": "Invalid suggestion_id format"}), 400
1524
- except Exception as e:
1525
- print(f"API ERROR: /api/suggestions/{suggestion_id}/resolve POST: {e}")
1526
- log_event(username, 'SUGGESTION_RESOLVE_API_ERROR', {'error': str(e), 'suggestion_id': suggestion_id, 'data': data})
1527
- return jsonify({"success": False, "message": f"Server error resolving suggestion: {e}"}), 500
1528
 
1529
 
1530
  # --- Application Entry Point ---
1531
 
1532
  if __name__ == "__main__":
1533
- print("INFO: Starting Business Automation Suite v2.0 (Flask)...")
1534
  print(f"INFO: Using database file: {os.path.abspath(DB_NAME)}")
1535
 
1536
  # Ensure database exists and schema is set up before launching Flask
@@ -1538,10 +1402,12 @@ if __name__ == "__main__":
1538
  setup_database()
1539
  except Exception as e:
1540
  print(f"FATAL: Could not initialize database. Exiting. Error: {e}")
1541
- exit(1) # Exit if DB setup fails
1542
 
1543
  print("INFO: Launching Flask application...")
1544
- # Use port 7860 for consistency with Gradio default on HF
1545
  # host='0.0.0.0' is essential for accessibility within Docker/HF Space
1546
- app.run(host='0.0.0.0', port=7860, debug=False) # Set debug=False for 'production' on HF
 
 
 
1547
  print("INFO: Application stopped.")
 
1
  # -*- coding: utf-8 -*-
2
  """
3
+ Business Automation Suite - AI-Ready Core v3.0 (User Management Added)
4
  Designed by Gemini Advanced Development Unit under impeccable direction.
5
  Target Platform: Hugging Face Spaces (Free Tier)
6
  Architecture: Single-File Ultra-Efficient Application with API Backend
 
18
 
19
  # --- Constants and Configuration ---
20
 
21
+ DB_NAME = "business_suite_v3.db" # Use a different DB name for v3 or keep v2 name if migrating data
22
  SALT_LENGTH = 16
23
+ SECRET_KEY = secrets.token_hex(24) # Regenerate or get from environment in production
24
 
25
+ # --- Database Core: The Central Nervous System ---
26
 
27
  def get_db_connection() -> sqlite3.Connection:
28
  """Establishes and returns a database connection. Enables Foreign Keys."""
 
36
  required_tables = {"users", "business_config", "inventory_items", "suppliers", "purchase_suggestions", "event_log"}
37
  conn_check = None
38
  try:
 
39
  db_exists = os.path.exists(DB_NAME)
40
  if db_exists:
41
  conn_check = get_db_connection()
 
51
  conn = get_db_connection()
52
  cursor = conn.cursor()
53
 
54
+ # Users Table (Schema unchanged, but central to this update)
55
  cursor.execute("""
56
  CREATE TABLE IF NOT EXISTS users (
57
  username TEXT PRIMARY KEY,
 
160
 
161
  except sqlite3.Error as e:
162
  print(f"FATAL: Database setup/check failed: {e}")
163
+ if conn_check: conn_check.close()
164
  raise
165
+ except Exception as e:
166
  print(f"FATAL: An unexpected error occurred during database setup: {e}")
167
  if conn_check: conn_check.close()
168
  raise
 
173
  """Hashes the password with the given salt using SHA-256."""
174
  password_bytes = password.encode('utf-8')
175
  salt_bytes = salt.encode('utf-8')
176
+ hashed = hashlib.pbkdf2_hmac('sha256', password_bytes, salt_bytes, 100000)
177
  return hashed.hex()
178
 
179
  def verify_password(stored_password_hash: str, salt: str, provided_password: str) -> bool:
 
181
  provided_hash = hash_password(provided_password, salt)
182
  return provided_hash == stored_password_hash
183
 
184
+ # --- Authentication/Authorization Decorators ---
185
+
186
+ def login_required(f):
187
+ """Decorator to restrict access to logged-in users."""
188
+ @wraps(f)
189
+ def decorated_function(*args, **kwargs):
190
+ if 'username' not in session:
191
+ # For API requests, return 401 Unauthorized
192
+ if request.endpoint and request.endpoint.startswith('api_'):
193
+ return jsonify({"success": False, "message": "Authentication required"}), 401
194
+ # For page loads, redirect to login (index route handles this now)
195
+ return redirect(url_for('index')) # Redirect back to index which renders login if not auth
196
+ return f(*args, **kwargs)
197
+ return decorated_function
198
+
199
+ def admin_required(f):
200
+ """Decorator to restrict access to admin users."""
201
+ @wraps(f)
202
+ @login_required # Ensure user is logged in first
203
+ def decorated_function(*args, **kwargs):
204
+ if not session.get('is_admin', False):
205
+ # For API requests, return 403 Forbidden
206
+ if request.endpoint and request.endpoint.startswith('api_'):
207
+ return jsonify({"success": False, "message": "Admin access required"}), 403
208
+ # For page loads (not currently used for admin-only pages, but good practice)
209
+ return jsonify({"success": False, "message": "Admin access required"}), 403 # Should ideally redirect or render error page
210
+ return f(*args, **kwargs)
211
+ return decorated_function
212
+
213
+
214
+ # --- Event Logging Module ---
215
 
216
  def log_event(username: Optional[str], event_type: str, details: Dict[str, Any]):
217
  """Logs an event to the database."""
218
+ # Ensure details are serializable (handle common non-serializable types)
219
  serializable_details = {}
220
  for k, v in details.items():
221
  if isinstance(v, (str, int, float, bool, list, dict, tuple)) or v is None:
222
  serializable_details[k] = v
223
  else:
224
+ try:
225
+ serializable_details[k] = json.dumps(v) # Try serializing complex objects
226
+ except TypeError:
227
+ serializable_details[k] = str(v) # Fallback to string conversion
228
+
229
 
230
  try:
231
  conn = get_db_connection()
232
  cursor = conn.cursor()
233
+ # Add remote_addr to logs where available (especially for login)
234
+ event_details = serializable_details
235
+ if request and request.remote_addr:
236
+ event_details['ip_address'] = request.remote_addr
237
+
238
+
239
  cursor.execute("""
240
  INSERT INTO event_log (username, event_type, details)
241
  VALUES (?, ?, ?)
242
+ """, (username, event_type, json.dumps(event_details)))
243
  conn.commit()
244
  conn.close()
245
  except sqlite3.Error as e:
246
  print(f"ERROR: Failed to log event {event_type} for user {username}: {e}")
247
  except TypeError as te:
248
  print(f"ERROR: Failed to serialize details for event {event_type}: {te} - Details: {serializable_details}")
249
+ except Exception as ex: # Catch unexpected errors during logging itself
250
+ print(f"ERROR: Unexpected error in log_event for {event_type}: {ex}")
251
 
252
 
253
+ # --- Business Logic Core (Inventory, Suppliers, Suggestions - Unchanged) ---
254
+ # (Omitted here for brevity as they are the same as v2.0 logic functions,
255
+ # assuming they are present above the API routes in the final app.py file)
256
+ # e.g., get_business_config_logic, update_business_config_logic,
257
+ # get_inventory_summary_logic, add_inventory_item_logic, update_inventory_quantity_logic,
258
+ # get_suppliers_logic, add_supplier_logic,
259
+ # check_and_suggest_reorders_logic, get_pending_suggestions_logic, resolve_suggestion_logic
 
 
 
 
 
 
 
 
 
260
 
261
+ # --- NEW: User Management Logic ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
262
 
263
+ def get_users_logic() -> List[Dict[str, Any]]:
264
+ """Retrieves a list of all users (excluding sensitive info)."""
265
+ users = []
266
  try:
267
  conn = get_db_connection()
268
  cursor = conn.cursor()
269
+ # Select only safe fields for UI
270
+ cursor.execute("SELECT username, is_admin, created_at FROM users ORDER BY username;")
271
+ users = [dict(row) for row in cursor.fetchall()] # Convert to dicts
 
 
 
 
 
 
 
 
272
  conn.close()
273
  except sqlite3.Error as e:
274
+ print(f"ERROR: Failed to retrieve users: {e}")
275
  # Consider raising an exception or returning an error structure
276
+ return users
277
 
 
 
 
 
 
 
 
 
 
278
 
279
+ def add_user_logic(username: str, password: str, is_admin: bool, creating_user_username: str) -> Tuple[bool, str]:
280
+ """Adds a new user to the database."""
281
+ if not username or not password:
282
+ return False, "Username and password are required."
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
283
 
 
284
  try:
285
+ # Basic sanitization (more robust validation recommended)
286
+ username = username.strip()
287
+ if len(username) < 3: return False, "Username must be at least 3 characters long."
288
+ if len(password) < 6: return False, "Password must be at least 6 characters long." # Example policy
289
+
290
+ salt = secrets.token_hex(SALT_LENGTH)
291
+ hashed_password = hash_password(password, salt)
292
 
293
  conn = get_db_connection()
294
  cursor = conn.cursor()
295
 
296
+ # Check if username already exists
297
+ cursor.execute("SELECT COUNT(*) FROM users WHERE username = ?", (username,))
298
+ if cursor.fetchone()[0] > 0:
 
299
  conn.close()
300
+ log_event(creating_user_username, 'ADD_USER_FAILURE', {'reason': 'Username exists', 'new_username': username})
301
+ return False, f"Username '{username}' already exists."
 
 
 
 
 
 
 
302
 
303
+ # Insert the new user
304
  cursor.execute("""
305
+ INSERT INTO users (username, hashed_password, salt, is_admin)
306
+ VALUES (?, ?, ?, ?)
307
+ """, (username, hashed_password, salt, 1 if is_admin else 0))
 
308
 
309
  conn.commit()
310
  conn.close()
311
+ log_event(creating_user_username, 'USER_ADDED', {'new_username': username, 'is_admin': is_admin})
312
+ return True, f"User '{username}' added successfully."
313
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
314
  except sqlite3.IntegrityError:
315
+ # Should be caught by the SELECT COUNT(*) but defense in depth
316
+ log_event(creating_user_username, 'ADD_USER_FAILURE', {'reason': 'Integrity error (duplicate)', 'new_username': username})
317
+ return False, f"Username '{username}' already exists (integrity error)."
318
  except sqlite3.Error as e:
319
+ print(f"ERROR: Failed to add user {username}: {e}")
320
+ log_event(creating_user_username, 'ADD_USER_ERROR', {'new_username': username, 'error': str(e)})
321
+ return False, f"Database error adding user: {e}"
322
+ except Exception as e:
323
+ print(f"ERROR: Unexpected error in add_user_logic for {username}: {e}")
324
+ log_event(creating_user_username, 'ADD_USER_UNEXPECTED_ERROR', {'new_username': username, 'error': str(e)})
325
+ return False, f"An unexpected server error occurred: {e}"
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
326
 
 
 
 
 
 
 
 
 
 
 
327
 
328
  # --- Flask Application Setup ---
329
  app = Flask(__name__)
330
+ app.secret_key = SECRET_KEY # MUST be set for sessions to work
 
 
 
 
 
 
 
 
 
 
 
 
 
331
 
332
  # --- HTML/CSS/JS Templates (Embedded in Python String) ---
333
 
334
+ # NOTE: This is the *only* place where the HTML structure changes.
335
+ # The JS part below will be updated to handle the new User section.
336
  HTML_TEMPLATE = """
337
  <!DOCTYPE html>
338
  <html lang="en">
339
  <head>
340
  <meta charset="UTF-8">
341
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
342
+ <title>Business Suite v3.0</title>
343
  <style>
344
+ /* Ultra-minimalist CSS for efficiency - Inherited from v2 */
345
  body { font-family: sans-serif; line-height: 1.6; margin: 0; padding: 0; background-color: #f4f4f4; color: #333; }
346
  .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; }
347
  header { display: flex; justify-content: space-between; align-items: center; border-bottom: 1px solid #eee; padding-bottom: 10px; margin-bottom: 20px; }
 
362
  tr:nth-child(even) { background-color: #f9f9f9; }
363
  form { margin-top: 20px; padding: 15px; background-color: #f9f9f9; border: 1px solid #eee; border-radius: 5px; }
364
  form label { display: block; margin-bottom: 5px; font-weight: bold; }
365
+ form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea, form input[type="checkbox"] {
 
366
  padding: 10px;
367
  margin-bottom: 10px;
368
  border: 1px solid #ccc;
369
  border-radius: 4px;
370
+ box-sizing: border-box; /* Include padding and border */
371
+ display: inline-block; /* Align with labels */
372
+ vertical-align: middle; /* Align with labels */
373
  }
374
+ form input[type="checkbox"] { width: auto; margin-right: 5px; }
375
+ form label[for] { /* Specific styling for labels linked to inputs */
376
+ display: inline-block; /* Allow label and input to be on the same line */
377
+ margin-bottom: 10px;
378
+ margin-right: 15px; /* Space between label/input pairs */
379
+ vertical-align: middle;
380
+ font-weight: normal; /* Reduce weight for inline labels */
381
+ }
382
+ form input[type="text"], form input[type="number"], form input[type="password"], form select, form textarea {
383
+ width: calc(100% - 22px); /* Default for full width */
384
+ display: block; /* Revert to block for full-width inputs */
385
+ margin-right: 0;
386
+ }
387
+ .form-group { margin-bottom: 0; }
388
+ .form-group label[for] { display: block; font-weight: bold; margin-bottom: 5px; } /* Revert labels in form-group */
389
+
390
  form textarea { height: 60px; }
391
  form button { background-color: #28a745; color: white; border: none; padding: 12px 20px; border-radius: 4px; cursor: pointer; font-size: 1em; }
392
  form button:hover { background-color: #218838; }
393
  .form-grid { display: grid; grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); gap: 15px; }
 
394
  .status-message { padding: 10px; margin-top: 15px; border-radius: 4px; font-weight: bold; text-align: center; }
395
  .status-success { background-color: #d4edda; color: #155724; border: 1px solid #c3e6cb; }
396
  .status-error { background-color: #f8d7da; color: #721c24; border: 1px solid #f5c6cb; }
397
+ .hidden { display: none !important; } /* Use important to override form-grid display */
398
  .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; }
399
  .accordion:hover, .accordion.active { background-color: #ccc; }
400
  .panel { padding: 0 18px; background-color: white; display: none; overflow: hidden; border: 1px solid #eee; border-top: none; border-radius: 0 0 4px 4px; }
401
+ /* Login Form Specifics - Inherited from v2 */
402
  #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; }
403
  #login-form h2 { text-align: center; margin-bottom: 25px; color: #333; border: none;}
404
  #login-form button { width: 100%; background-color: #007bff; }
 
409
  button .spinner { display: none; } /* Hide spinner by default */
410
  button.loading .spinner { display: inline-block; } /* Show spinner when button has loading class */
411
  button.loading span { vertical-align: middle; } /* Align text with spinner */
412
+
413
+ /* Specific style for the checkbox label */
414
+ #add-user-form .form-group label[for="add-user-is-admin"] {
415
+ display: inline-block; /* Keep label and checkbox on the same line */
416
+ margin-right: 5px;
417
+ font-weight: normal;
418
+ vertical-align: middle;
419
+ width: auto; /* Allow label width to shrink */
420
+ }
421
+ #add-user-form .form-group input[type="checkbox"] {
422
+ width: auto; /* Ensure checkbox width is default */
423
+ margin-right: 15px;
424
+ vertical-align: middle;
425
+ }
426
+
427
  </style>
428
  </head>
429
  <body>
 
458
  <button data-section="inventory" class="nav-btn active">📦 Inventory</button>
459
  <button data-section="suppliers" class="nav-btn">🤝 Suppliers</button>
460
  <button data-section="suggestions" class="nav-btn">💡 Suggestions</button>
461
+ {# NEW: User Management Button - Only visible if is_admin is true #}
462
+ <button data-section="users" class="nav-btn {{ '' if session.get('is_admin') else 'hidden' }}">👥 Users</button>
463
  </nav>
464
 
465
  <div id="global-status" class="status-message hidden"></div>
466
 
467
+ <!-- Inventory Section - Inherited from v2 -->
468
  <section id="inventory-section" class="active">
469
  <h2>Inventory Management</h2>
470
  <button class="accordion">Perform Inventory Actions ▼</button>
 
486
  <label for="inv-quantity-change">Quantity:</label>
487
  <input type="number" id="inv-quantity-change" name="quantity_change" required step="any">
488
  </div>
489
+ <div class="form-group" style="grid-column: 1 / -1;">
490
  <label for="inv-reason">Reason/Note:</label>
491
  <input type="text" id="inv-reason" name="reason" placeholder="e.g., Customer order #123, Stocktake correction">
492
  </div>
 
534
  <div id="inventory-table-container"><div class="loading">Loading inventory...</div></div>
535
  </section>
536
 
537
+ <!-- Suppliers Section - Inherited from v2 -->
538
  <section id="suppliers-section">
539
  <h2>Supplier Management</h2>
540
  <button class="accordion">Add New Supplier ▼</button>
 
563
  <div id="suppliers-table-container"><div class="loading">Loading suppliers...</div></div>
564
  </section>
565
 
566
+ <!-- Suggestions Section - Inherited from v2 -->
567
  <section id="suggestions-section">
568
  <h2>Purchase Suggestions</h2>
569
  <button class="accordion">Resolve Pending Suggestion ▼</button>
 
598
  <div id="suggestions-table-container"><div class="loading">Loading suggestions...</div></div>
599
  </section>
600
 
601
+ {# NEW: User Management Section #}
602
+ <section id="users-section">
603
+ <h2>User Management</h2>
604
+ <p>Manage system users. Requires Admin privileges.</p>
605
+
606
+ <button class="accordion">Add New User ▼</button>
607
+ <div class="panel">
608
+ <form id="add-user-form"> {/* Not using form-grid here for simpler layout */}
609
+ <div class="form-group">
610
+ <label for="add-user-username">Username *:</label>
611
+ <input type="text" id="add-user-username" name="username" required minlength="3">
612
+ </div>
613
+ <div class="form-group">
614
+ <label for="add-user-password">Password *:</label>
615
+ <input type="password" id="add-user-password" name="password" required minlength="6">
616
+ </div>
617
+ <div class="form-group">
618
+ <label for="add-user-is-admin">Is Admin:</label>
619
+ <input type="checkbox" id="add-user-is-admin" name="is_admin" value="true">
620
+ </div>
621
+ <div class="form-group">
622
+ <button type="submit"><span>Add User</span><div class="spinner"></div></button>
623
+ </div>
624
+ </form>
625
+ </div>
626
+
627
+ <h3>System Users</h3>
628
+ <button id="refresh-users-btn"><span>Refresh List</span><div class="spinner"></div></button>
629
+ <div id="users-table-container"><div class="loading">Loading users...</div></div>
630
+ </section>
631
+
632
+
633
  </div>
634
 
635
  <script>
636
+ // Encapsulate JS in an IIFE
637
  (function() {
638
  // --- State & Configuration ---
639
  let currentSection = 'inventory'; // Default section
640
+ let isLoggedIn = {{ 'true' if session.get('username') else 'false' }}; // Initial state from Flask
641
+ let isAdmin = {{ 'true' if session.get('is_admin') else 'false' }}; // Initial state from Flask
642
 
643
  // --- DOM Element References ---
644
  const loginSection = document.getElementById('login-section');
 
649
  const userDisplay = document.getElementById('user-display');
650
  const adminDisplay = document.getElementById('admin-display');
651
  const logoutBtn = document.getElementById('logout-btn');
652
+ const navButtons = document.querySelectorAll('.nav-btn'); // Now includes the users button
653
  const sections = document.querySelectorAll('section');
654
  const globalStatus = document.getElementById('global-status');
655
 
 
657
  const inventoryTableContainer = document.getElementById('inventory-table-container');
658
  const suppliersTableContainer = document.getElementById('suppliers-table-container');
659
  const suggestionsTableContainer = document.getElementById('suggestions-table-container');
660
+ const usersTableContainer = document.getElementById('users-table-container'); // NEW
661
 
662
  // Forms
663
  const inventoryActionForm = document.getElementById('inventory-action-form');
664
  const addItemForm = document.getElementById('add-item-form');
665
  const addSupplierForm = document.getElementById('add-supplier-form');
666
  const resolveSuggestionForm = document.getElementById('resolve-suggestion-form');
667
+ const addUserForm = document.getElementById('add-user-form'); // NEW
668
 
669
  // Suggestion Form Specific Elements
670
  const resolveSuggestionIdSelect = document.getElementById('resolve-suggestion-id');
 
676
  const refreshInventoryBtn = document.getElementById('refresh-inventory-btn');
677
  const refreshSuppliersBtn = document.getElementById('refresh-suppliers-btn');
678
  const refreshSuggestionsBtn = document.getElementById('refresh-suggestions-btn');
679
+ const refreshUsersBtn = document.getElementById('refresh-users-btn'); // NEW
680
 
681
 
682
  // --- Utility Functions ---
 
685
  async function apiRequest(endpoint, method = 'GET', body = null, button = null) {
686
  const options = {
687
  method: method,
688
+ headers: {
689
+ 'Content-Type': 'application/json', // Default content type
690
+ }
691
  };
692
  if (body) {
 
693
  options.body = JSON.stringify(body);
694
+ } else if (method !== 'GET' && method !== 'HEAD') {
695
+ // If it's a POST/PUT/DELETE but no body is needed, explicitly set body to null
696
+ // This prevents fetch from setting Content-Type: text/plain for empty bodies
697
+ options.body = null;
698
  }
699
 
700
+
701
  // Show spinner on button if provided
702
  if (button) {
703
  button.classList.add('loading');
 
706
 
707
  try {
708
  const response = await fetch(endpoint, options);
709
+ // Check for 401 Unauthorized - session might have expired
710
+ if (response.status === 401) {
711
+ showStatus("Session expired or not authorized. Please login.", true);
712
+ // Trigger a logout sequence to reset UI
713
+ setTimeout(() => window.location.reload(), 2000); // Simple refresh to show login
714
+ return; // Stop further processing
715
+ }
716
+ // Check for 403 Forbidden - user is logged in but not authorized for this action
717
+ if (response.status === 403) {
718
+ const errorData = await response.json(); // Try to get error message
719
+ showStatus(errorData.message || "Action not allowed for your user role.", true);
720
+ // Don't proceed with response.json() for success case
721
+ throw new Error(errorData.message || `Access forbidden: ${response.status}`); // Throw to be caught below
722
+ }
723
+
724
+
725
  const data = await response.json(); // Assume server always returns JSON
726
 
727
  if (!response.ok) {
728
+ // Handle other non-OK responses
729
  throw new Error(data.message || `HTTP error! status: ${response.status}`);
730
  }
731
  return data; // Contains { success: true, ... } or similar
732
  } catch (error) {
733
  console.error('API Request Error:', error);
734
+ // If it's an error from our explicit throw (403) or network/parsing issue
735
+ if (error.message.startsWith('Access forbidden') || error.message.startsWith('HTTP error')) {
736
+ throw error; // Re-throw to be handled by specific form/action handlers
737
+ }
738
+ // General error handling for unexpected issues
739
  throw new Error(error.message || 'Network error or server unavailable.');
740
  } finally {
741
  // Hide spinner and re-enable button
 
772
  data.forEach(row => {
773
  html += '<tr>';
774
  columns.forEach(col => {
775
+ // Format boolean columns like is_admin
776
+ let cellValue = row[col];
777
+ if (typeof cellValue === 'boolean') {
778
+ cellValue = cellValue ? 'Yes' : 'No';
779
+ } else if (cellValue === null || cellValue === undefined) {
780
+ cellValue = '';
781
+ }
782
+ html += `<td>${cellValue}</td>`;
783
  });
784
  html += '</tr>';
785
  });
 
793
  inventoryTableContainer.innerHTML = '<div class="loading">Loading inventory...</div>';
794
  try {
795
  const data = await apiRequest('/api/inventory', 'GET', null, button);
796
+ if (data && data.success) { // Check for data and success
797
  const columns = ["item_id", "item_name", "description", "current_quantity", "unit", "reorder_level", "preferred_supplier_name", "last_updated_str"];
798
  inventoryTableContainer.innerHTML = createTable(data.inventory, columns);
799
+ } else if (data) { // data exists but success is false
800
+ throw new Error(data.message || "Failed to load inventory.");
801
  }
802
+ // else: apiRequest handled 401/403 or network error, already showed status/redirected
803
  } catch (error) {
804
  inventoryTableContainer.innerHTML = `<p class="status-error">Error loading inventory: ${error.message}</p>`;
805
  }
 
809
  suppliersTableContainer.innerHTML = '<div class="loading">Loading suppliers...</div>';
810
  try {
811
  const data = await apiRequest('/api/suppliers', 'GET', null, button);
812
+ if (data && data.success) {
813
  const columns = ["supplier_id", "supplier_name", "contact_info", "notes"];
814
  suppliersTableContainer.innerHTML = createTable(data.suppliers, columns);
815
+ } else if (data) {
816
  throw new Error(data.message || "Failed to load suppliers.");
817
  }
818
  } catch (error) {
 
825
  resolveSuggestionIdSelect.innerHTML = '<option value="">-- Loading... --</option>'; // Clear previous options
826
  try {
827
  const data = await apiRequest('/api/suggestions', 'GET', null, button);
828
+ if (data && data.success) {
829
  const columns = ["suggestion_id", "item_name", "suggested_quantity", "unit", "supplier_name", "reason", "created_at_str"];
830
  suggestionsTableContainer.innerHTML = createTable(data.suggestions, columns);
831
 
 
842
  resolveSuggestionIdSelect.innerHTML = '<option value="">-- No pending suggestions --</option>';
843
  }
844
 
845
+ } else if (data) {
846
  throw new Error(data.message || "Failed to load suggestions.");
847
  }
848
  } catch (error) {
 
851
  }
852
  }
853
 
854
+ // NEW: Load Users Function
855
+ async function loadUsers(button = null) {
856
+ usersTableContainer.innerHTML = '<div class="loading">Loading users...</div>';
857
+ // Only attempt to load if user is admin (backend will also enforce)
858
+ if (!isAdmin) {
859
+ usersTableContainer.innerHTML = '<p class="status-error">Admin access required to view users.</p>';
860
+ return;
861
+ }
862
+ try {
863
+ const data = await apiRequest('/api/users', 'GET', null, button);
864
+ if (data && data.success) {
865
+ // Exclude sensitive fields like password/salt
866
+ const columns = ["username", "is_admin", "created_at"];
867
+ usersTableContainer.innerHTML = createTable(data.users, columns);
868
+ } else if (data) {
869
+ throw new Error(data.message || "Failed to load users.");
870
+ }
871
+ } catch (error) {
872
+ // Check if the error is a 403 Forbidden handled by apiRequest
873
+ if (error.message.startsWith('Access forbidden')) {
874
+ usersTableContainer.innerHTML = '<p class="status-error">Admin access required to view users.</p>';
875
+ // Also hide the nav button if not already (useful if admin status changes mid-session?)
876
+ document.querySelector('.nav-btn[data-section="users"]').classList.add('hidden');
877
+ } else {
878
+ usersTableContainer.innerHTML = `<p class="status-error">Error loading users: ${error.message}</p>`;
879
+ }
880
+ }
881
+ }
882
+
883
+
884
  // --- Event Handlers ---
885
 
886
  // Login Handler
 
893
 
894
  try {
895
  const data = await apiRequest('/api/login', 'POST', { username, password }, button);
896
+ if (data && data.success) {
897
+ // Update JS state
898
+ isLoggedIn = true;
899
+ isAdmin = data.user.is_admin;
900
+
901
  // Update UI for logged-in state
902
  loginSection.classList.add('hidden');
903
  appSection.classList.remove('hidden');
904
  userDisplay.textContent = data.user.username;
905
+ adminDisplay.textContent = isAdmin ? 'Admin' : 'User';
906
+
907
+ // Show Admin-only sections if user is admin
908
+ if (isAdmin) {
909
+ document.querySelector('.nav-btn[data-section="users"]').classList.remove('hidden');
910
+ // Add other admin-only UI elements here if they exist
911
+ } else {
912
+ document.querySelector('.nav-btn[data-section="users"]').classList.add('hidden');
913
+ }
914
+
915
+
916
  // Load initial data for the default section
917
+ navigateTo(currentSection); // Load data for the current/default view (usually inventory)
918
+
919
+ } else if (data) {
920
  throw new Error(data.message || "Login failed.");
921
  }
922
  } catch (error) {
923
+ // apiRequest might have already shown a global status for 401/403
924
+ if (!error.message.startsWith('Session expired')) {
925
+ showStatus(error.message, true, loginError);
926
+ }
927
  }
928
  });
929
 
 
931
  logoutBtn.addEventListener('click', async () => {
932
  try {
933
  const data = await apiRequest('/api/logout', 'POST');
934
+ if (data && data.success) {
935
+ // Update JS state
936
+ isLoggedIn = false;
937
+ isAdmin = false;
938
+
939
  // Update UI for logged-out state
940
  loginSection.classList.remove('hidden');
941
  appSection.classList.add('hidden');
942
  loginForm.reset(); // Clear login form fields
943
+ loginError.classList.add('hidden'); // Hide login error on successful logout
944
+
945
+ // Hide admin-only sections
946
+ document.querySelector('.nav-btn[data-section="users"]').classList.add('hidden');
947
+ // Hide other admin-only UI elements here if they exist
948
+
949
+ // Clear data tables (optional, but cleans up the UI)
950
  inventoryTableContainer.innerHTML = '';
951
  suppliersTableContainer.innerHTML = '';
952
  suggestionsTableContainer.innerHTML = '';
953
+ usersTableContainer.innerHTML = ''; // NEW: Clear users table
954
+
955
+ // Navigate back to a safe default section (e.g., inventory)
956
+ navigateTo('inventory'); // Resets active button/section state
957
+
958
+ } else if (data) {
959
  showStatus(data.message || "Logout failed.", true);
960
  }
961
  } catch (error) {
 
967
  navButtons.forEach(button => {
968
  button.addEventListener('click', () => {
969
  const sectionId = button.getAttribute('data-section');
970
+ // Prevent navigation if not logged in (redundant due to login_required, but good UI)
971
+ if (!isLoggedIn) {
972
+ showStatus("Please login to access the application.", true);
973
+ return;
974
+ }
975
+ // Prevent navigation to admin section if not admin (redundant due to admin_required, but good UI)
976
+ if (sectionId === 'users' && !isAdmin) {
977
+ showStatus("Admin access required to view this section.", true);
978
+ return; // Do not navigate
979
+ }
980
+
981
  navigateTo(sectionId);
982
  });
983
  });
 
996
  if (sectionId === 'inventory') loadInventory();
997
  else if (sectionId === 'suppliers') loadSuppliers();
998
  else if (sectionId === 'suggestions') loadSuggestions();
999
+ else if (sectionId === 'users') loadUsers(); // NEW: Load users
1000
  } else {
1001
  sec.classList.remove('active');
1002
  }
 
1005
  globalStatus.classList.add('hidden'); // Hide any previous global status messages
1006
  }
1007
 
1008
+ // Inventory Action Handler (Inherited from v2)
1009
  inventoryActionForm.addEventListener('submit', async (e) => {
1010
  e.preventDefault();
1011
  const formData = new FormData(inventoryActionForm);
1012
  const action_type = formData.get('action_type');
1013
  const item_id = formData.get('item_id');
1014
  let quantity_change = parseFloat(formData.get('quantity_change'));
1015
+ const reason = formData.get('reason') || `Action: ${action_type}`;
1016
  const button = inventoryActionForm.querySelector('button[type="submit"]');
1017
 
1018
+ if (isNaN(quantity_change)) { showStatus("Quantity must be a number.", true); return; }
 
 
 
1019
 
 
1020
  if (action_type === 'sale') {
1021
+ if (quantity_change > 0) quantity_change = -quantity_change;
1022
  else { showStatus("Sale quantity must be positive.", true); return; }
1023
  } else if (action_type === 'delivery') {
1024
  if (quantity_change < 0) { showStatus("Delivery quantity must be positive.", true); return; }
1025
  }
 
1026
 
1027
  const payload = { quantity_change, reason };
1028
 
1029
  try {
1030
  const data = await apiRequest(`/api/inventory/${item_id}/quantity`, 'POST', payload, button);
1031
+ if (data && data.success) {
1032
  showStatus(data.message || "Inventory updated successfully.", false);
1033
+ inventoryActionForm.reset();
1034
+ loadInventory();
1035
+ loadSuggestions(); // Suggestions might change
1036
+ } else if (data) {
1037
  throw new Error(data.message || "Failed to update inventory.");
1038
  }
1039
  } catch (error) {
 
1041
  }
1042
  });
1043
 
1044
+ // Add Item Handler (Inherited from v2)
1045
  addItemForm.addEventListener('submit', async (e) => {
1046
  e.preventDefault();
1047
  const formData = new FormData(addItemForm);
1048
  const payload = Object.fromEntries(formData.entries());
 
1049
  payload.initial_quantity = parseFloat(payload.initial_quantity || 0);
1050
  payload.reorder_level = payload.reorder_level ? parseFloat(payload.reorder_level) : null;
1051
  payload.preferred_supplier_id = payload.preferred_supplier_id ? parseInt(payload.preferred_supplier_id) : null;
 
1058
 
1059
  try {
1060
  const data = await apiRequest('/api/inventory', 'POST', payload, button);
1061
+ if (data && data.success) {
1062
  showStatus(data.message || "Item added successfully.", false);
1063
+ addItemForm.reset();
1064
+ loadInventory();
1065
+ } else if (data) {
1066
  throw new Error(data.message || "Failed to add item.");
1067
  }
1068
  } catch (error) {
 
1070
  }
1071
  });
1072
 
1073
+ // Add Supplier Handler (Inherited from v2)
1074
  addSupplierForm.addEventListener('submit', async (e) => {
1075
  e.preventDefault();
1076
  const formData = new FormData(addSupplierForm);
 
1079
 
1080
  try {
1081
  const data = await apiRequest('/api/suppliers', 'POST', payload, button);
1082
+ if (data && data.success) {
1083
  showStatus(data.message || "Supplier added successfully.", false);
1084
+ addSupplierForm.reset();
1085
+ loadSuppliers();
1086
+ } else if (data) {
1087
  throw new Error(data.message || "Failed to add supplier.");
1088
  }
1089
  } catch (error) {
 
1091
  }
1092
  });
1093
 
1094
+ // Resolve Suggestion Form - Show/Hide Received Quantity (Inherited from v2)
1095
  resolveActionSelect.addEventListener('change', () => {
1096
  if (resolveActionSelect.value === 'received') {
1097
  receivedQtyGroup.classList.remove('hidden');
 
1099
  } else {
1100
  receivedQtyGroup.classList.add('hidden');
1101
  resolveReceivedQtyInput.required = false;
1102
+ resolveReceivedQtyInput.value = '';
1103
  }
1104
  });
1105
 
1106
+ // Resolve Suggestion Handler (Inherited from v2)
1107
  resolveSuggestionForm.addEventListener('submit', async (e) => {
1108
  e.preventDefault();
1109
  const formData = new FormData(resolveSuggestionForm);
 
1112
  let received_quantity = formData.get('received_quantity');
1113
  const button = resolveSuggestionForm.querySelector('button[type="submit"]');
1114
 
1115
+ if (!suggestion_id) { showStatus("Please select a suggestion to resolve.", true); return; }
 
 
 
1116
 
1117
  const payload = { action };
1118
  if (action === 'received') {
 
1121
  return;
1122
  }
1123
  payload.received_quantity = parseFloat(received_quantity);
1124
+ } else {
1125
+ payload.received_quantity = null; // Ensure backend receives null if not received
1126
  }
1127
 
1128
+
1129
  try {
1130
  const data = await apiRequest(`/api/suggestions/${suggestion_id}/resolve`, 'POST', payload, button);
1131
+ if (data && data.success) {
1132
  showStatus(data.message || "Suggestion resolved successfully.", false);
1133
+ resolveSuggestionForm.reset();
1134
+ receivedQtyGroup.classList.add('hidden');
1135
  loadSuggestions(); // Refresh suggestions list & dropdown
1136
  if (action === 'received') {
1137
  loadInventory(); // Refresh inventory if items were received
1138
  }
1139
+ } else if (data) {
1140
  throw new Error(data.message || "Failed to resolve suggestion.");
1141
  }
1142
  } catch (error) {
 
1144
  }
1145
  });
1146
 
1147
+ // NEW: Add User Handler
1148
+ addUserForm.addEventListener('submit', async (e) => {
1149
+ e.preventDefault();
1150
+ const formData = new FormData(addUserForm);
1151
+ const username = formData.get('username');
1152
+ const password = formData.get('password');
1153
+ // Checkbox value 'true' comes from HTML value attribute if checked, undefined if not
1154
+ const is_admin = formData.get('is_admin') === 'true';
1155
+ const button = addUserForm.querySelector('button[type="submit"]');
1156
+
1157
+ if (!username || !password) {
1158
+ showStatus("Username and password are required.", true);
1159
+ return;
1160
+ }
1161
+ if (username.length < 3) {
1162
+ showStatus("Username must be at least 3 characters long.", true);
1163
+ return;
1164
+ }
1165
+ if (password.length < 6) {
1166
+ showStatus("Password must be at least 6 characters long.", true);
1167
+ return;
1168
+ }
1169
+
1170
+ const payload = { username, password, is_admin };
1171
+
1172
+ try {
1173
+ const data = await apiRequest('/api/users', 'POST', payload, button);
1174
+ if (data && data.success) {
1175
+ showStatus(data.message || "User added successfully.", false);
1176
+ addUserForm.reset(); // Clear form
1177
+ loadUsers(); // Refresh the user list table
1178
+ } else if (data) {
1179
+ throw new Error(data.message || "Failed to add user.");
1180
+ }
1181
+ } catch (error) {
1182
+ showStatus(error.message, true);
1183
+ }
1184
+ });
1185
+
1186
+
1187
+ // Refresh Button Handlers (Updated to include Users)
1188
  refreshInventoryBtn.addEventListener('click', () => loadInventory(refreshInventoryBtn));
1189
  refreshSuppliersBtn.addEventListener('click', () => loadSuppliers(refreshSuppliersBtn));
1190
  refreshSuggestionsBtn.addEventListener('click', () => loadSuggestions(refreshSuggestionsBtn));
1191
+ refreshUsersBtn.addEventListener('click', () => loadUsers(refreshUsersBtn)); // NEW
1192
 
1193
+ // Accordion Handler (Inherited from v2)
1194
  document.querySelectorAll('.accordion').forEach(accordion => {
1195
  accordion.addEventListener('click', function() {
1196
  this.classList.toggle('active');
 
1205
  });
1206
  });
1207
 
1208
+ // --- Initial Load / State Check ---
1209
  function initialize() {
1210
+ // Check initial state from Flask session variables embedded in HTML
1211
+ if (isLoggedIn) {
1212
+ loginSection.classList.add('hidden');
1213
+ appSection.classList.remove('hidden');
1214
+ // User info is already set by Flask template initially
1215
+
1216
+ // Adjust visibility of admin section based on initial state
1217
+ if (isAdmin) {
1218
+ document.querySelector('.nav-btn[data-section="users"]').classList.remove('hidden');
1219
+ } else {
1220
+ document.querySelector('.nav-btn[data-section="users"]').classList.add('hidden');
1221
+ }
1222
+
1223
+ // Load data for the default section after UI is visible
1224
+ // Use a small delay to ensure section elements are rendered/visible before loading data
1225
+ setTimeout(() => navigateTo(currentSection), 50);
1226
+
1227
+ } else {
1228
+ loginSection.classList.remove('hidden');
1229
+ appSection.classList.add('hidden');
1230
  }
1231
+
1232
+ // Ensure panels are closed initially
1233
  document.querySelectorAll('.panel').forEach(panel => panel.style.display = 'none');
1234
  document.querySelectorAll('.accordion').forEach(acc => acc.innerHTML = acc.innerHTML.replace('▲', '▼'));
1235
 
 
1245
  """
1246
 
1247
 
1248
+ # --- Flask Application Setup ---
1249
+ app = Flask(__name__)
1250
+ app.secret_key = SECRET_KEY
1251
+
1252
  # --- Flask Routes ---
1253
 
1254
  @app.route('/')
1255
  def index():
1256
+ """Serves the main application page or login page based on session."""
1257
+ # render_template_string passes Flask session variables to the Jinja2 template
1258
+ # The HTML template uses these to initially set element visibility
1259
+ return render_template_string(HTML_TEMPLATE, session=session)
 
 
 
 
 
 
1260
 
1261
 
1262
  @app.route('/api/login', methods=['POST'])
 
1267
  password = data.get('password')
1268
 
1269
  if not username or not password:
1270
+ # Log missing credentials without associating with a specific user (attacker might be guessing)
1271
+ log_event(None, 'LOGIN_FAILURE', {'reason': 'Missing credentials', 'username_attempt': username, 'ip_address': request.remote_addr})
1272
  return jsonify({"success": False, "message": "Username and password are required"}), 400
1273
 
1274
  conn = None
 
1277
  cursor = conn.cursor()
1278
  cursor.execute("SELECT username, hashed_password, salt, is_admin FROM users WHERE username = ?", (username,))
1279
  user_row = cursor.fetchone()
1280
+ conn.close() # Close connection as early as possible
1281
 
1282
  if user_row:
1283
  stored_hash = user_row['hashed_password']
 
1285
  if verify_password(stored_hash, salt, password):
1286
  # Login successful - store user info in session
1287
  session['username'] = user_row['username']
1288
+ session['is_admin'] = bool(user_row['is_admin']) # Store as Python boolean
1289
+ session.permanent = True # Optional: make session permanent
1290
  log_event(username, 'LOGIN_SUCCESS', {'ip_address': request.remote_addr})
1291
+ # Return user info including is_admin status to the frontend
1292
  user_info = {"username": user_row['username'], "is_admin": bool(user_row['is_admin'])}
1293
  return jsonify({"success": True, "message": "Login successful", "user": user_info})
1294
  else:
1295
  log_event(username, 'LOGIN_FAILURE', {'reason': 'Invalid password', 'ip_address': request.remote_addr})
1296
  return jsonify({"success": False, "message": "Invalid username or password"}), 401
1297
  else:
1298
+ log_event(username, 'LOGIN_FAILURE', {'reason': 'User not found', 'username_attempt': username, 'ip_address': request.remote_addr})
1299
  return jsonify({"success": False, "message": "Invalid username or password"}), 401
1300
 
1301
  except sqlite3.Error as e:
 
1305
  return jsonify({"success": False, "message": "Server error during login"}), 500
1306
  except Exception as e:
1307
  print(f"ERROR: Unexpected error during login for user {username}: {e}")
1308
+ # Log as system event or None user if username couldn't be retrieved
1309
+ log_event(username or 'Unknown', 'LOGIN_UNEXPECTED_ERROR', {'error': str(e), 'ip_address': request.remote_addr})
1310
  if conn: conn.close()
1311
  return jsonify({"success": False, "message": "An unexpected server error occurred"}), 500
1312
 
 
1315
  @login_required # Ensure user is logged in to log out
1316
  def api_logout():
1317
  """Logs the user out by clearing the session."""
1318
+ username = session.get('username', 'Unknown')
1319
  session.pop('username', None)
1320
  session.pop('is_admin', None)
1321
+ # Explicitly clear session data from Flask's storage
1322
+ session.clear()
1323
  log_event(username, 'LOGOUT', {'ip_address': request.remote_addr})
1324
  return jsonify({"success": True, "message": "Logged out successfully"})
1325
 
1326
  @app.route('/api/status')
1327
  def api_status():
1328
  """Checks if the user is currently logged in."""
1329
+ # This endpoint is useful for frontend validation if needed, but index route handles initial state
1330
  if 'username' in session:
1331
  user_info = {"username": session['username'], "is_admin": session.get('is_admin', False)}
1332
  return jsonify({"logged_in": True, "user": user_info})
1333
  else:
1334
  return jsonify({"logged_in": False})
1335
 
1336
+ # --- API Routes for Business Logic (Placeholder - Assume logic functions are defined above) ---
1337
+ # Example:
1338
+ # @app.route('/api/inventory', methods=['GET'])
1339
+ # @login_required
1340
+ # def api_get_inventory():
1341
+ # try:
1342
+ # items = get_inventory_summary_logic()
1343
+ # return jsonify({"success": True, "inventory": items})
1344
+ # except Exception as e:
1345
+ # return jsonify({"success": False, "message": f"Failed: {e}"}), 500
1346
+ # ... (other API routes for inventory, suppliers, suggestions from v2)
1347
+
1348
+
1349
+ # --- NEW: API Routes for User Management ---
1350
+
1351
+ @app.route('/api/users', methods=['GET'])
1352
+ @admin_required # Only admins can list users
1353
+ def api_get_users():
1354
+ """API endpoint to get list of users."""
 
 
 
 
 
 
 
 
 
 
 
 
1355
  try:
1356
+ users = get_users_logic()
1357
+ # Exclude password/salt from API response for security
1358
+ safe_users = [{"username": u['username'], "is_admin": bool(u['is_admin']), "created_at": u['created_at']} for u in users]
1359
+ return jsonify({"success": True, "users": safe_users})
 
 
 
 
 
1360
  except Exception as e:
1361
+ print(f"API ERROR: /api/users GET: {e}")
1362
+ # Log admin-only error under the admin's username
1363
+ log_event(session.get('username', 'Unknown Admin Attempt'), 'GET_USERS_API_ERROR', {'error': str(e)})
1364
+ return jsonify({"success": False, "message": f"Failed to retrieve users: {e}"}), 500
1365
+
1366
+ @app.route('/api/users', methods=['POST'])
1367
+ @admin_required # Only admins can add users
1368
+ def api_add_user():
1369
+ """API endpoint to add a new user."""
1370
  data = request.get_json()
1371
+ if not data:
1372
+ log_event(session.get('username', 'Unknown Admin Attempt'), 'ADD_USER_API_FAILURE', {'reason': 'No data'})
1373
+ return jsonify({"success": False, "message": "Invalid request body"}), 400
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1374
 
1375
+ username = data.get('username')
1376
+ password = data.get('password')
1377
+ is_admin = data.get('is_admin', False) # Default to False if not provided
1378
+ creating_user_username = session.get('username', 'Unknown Admin') # The admin performing the action
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1379
 
1380
+ # Pass validation responsibility to the logic function
1381
+ success, message = add_user_logic(username, password, bool(is_admin), creating_user_username)
1382
 
1383
+ if success:
1384
+ return jsonify({"success": True, "message": message})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1385
  else:
1386
+ # Determine status code: 409 Conflict for duplicate, 400 for bad input
1387
+ status_code = 409 if 'already exists' in message else 400
1388
+ return jsonify({"success": False, "message": message}), status_code
1389
 
1390
+ # Any unhandled exceptions would be caught by Flask's default error handler or gunicorn/web server
1391
+ # For impeccable error handling, add a broad try...except around the logic call here too
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1392
 
1393
 
1394
  # --- Application Entry Point ---
1395
 
1396
  if __name__ == "__main__":
1397
+ print("INFO: Starting Business Automation Suite v3.0 (Flask)...")
1398
  print(f"INFO: Using database file: {os.path.abspath(DB_NAME)}")
1399
 
1400
  # Ensure database exists and schema is set up before launching Flask
 
1402
  setup_database()
1403
  except Exception as e:
1404
  print(f"FATAL: Could not initialize database. Exiting. Error: {e}")
1405
+ exit(1)
1406
 
1407
  print("INFO: Launching Flask application...")
 
1408
  # host='0.0.0.0' is essential for accessibility within Docker/HF Space
1409
+ # debug=False is important for production environments
1410
+ # Use gunicorn or similar WSGI server for production instead of app.run()
1411
+ # For HF Spaces, `app.run()` with server_name/port usually works via their infrastructure
1412
+ app.run(host='0.0.0.0', port=7860, debug=False)
1413
  print("INFO: Application stopped.")