diff --git a/CHANGELOG.md b/CHANGELOG.md index 161d8aa..f2d5185 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,26 @@ # Changelog +## 0.10.1.15 - 2025-10-09 + + +### Fixed +- Global view on Index page now shows warranties from all users as expected: + - Backend: Added `GET /api/warranties/global/archived` and unified global queries to return complete sets with correlated subqueries for claim status; no accidental row collapse. + - Frontend: When scope is Global and Status is "All", archived warranties are merged from the new global archived endpoint into the main list. Archived view in Global scope now uses the global archived endpoint. + - Cache bust: Bumped `script.js` and service worker cache to ensure clients receive the updated logic. + - Files: `backend/warranties_routes.py`, `frontend/script.js`, `frontend/sw.js`, `frontend/index.html`, `frontend/status.html` + +### Added +- Model Number field added to warranties: + - Backend: Added `model_number` column and wired through GET/POST/PUT. + - Frontend: New and Edit modals now include a Model Number input. + - Cards: When present, Model Number displays on warranty cards (all views). + - Files: `backend/migrations/047_add_model_number_to_warranties.sql`, `backend/warranties_routes.py`, `frontend/index.html`, `frontend/status.html`, `frontend/script.js`, `locales/en/translation.json` + +### Enhanced +- Add Warranty modal tabs responsive alignment: + - Shrink tab label text and spacing at ≤740px to prevent wrapping and keep all five tabs aligned (Product, Warranty, Documents, Tags, Summary). + - Maintain icons with labels; no text hiding. Uses five-step progress indicator consistent across breakpoints. + - Files: `frontend/style.css` ## 0.10.1.14 - 2025-10-06 diff --git a/backend/db_handler.py b/backend/db_handler.py index da47a26..86f3c2f 100644 --- a/backend/db_handler.py +++ b/backend/db_handler.py @@ -17,14 +17,40 @@ DB_USER = os.environ.get('DB_USER', 'warranty_user') DB_PASSWORD = os.environ.get('DB_PASSWORD', 'warranty_password') connection_pool = None # Global connection pool for this module +# Track the PID that created the current pool to detect post-fork reuse +pool_pid: Optional[int] = None + +def _close_stale_pool_if_forked(current_pid: int) -> None: + """Close any existing pool if it was created in a different process. + + Gunicorn with preload_app=True forks workers after the app (and pool) may be + initialized. Psycopg2 connections/pools are not fork-safe. If we detect that + the pool was created in a different PID, we proactively close it in this + process so a fresh, per-process pool can be created. + """ + global connection_pool, pool_pid + if connection_pool is not None and pool_pid is not None and pool_pid != current_pid: + logger.warning(f"[DB_HANDLER] Detected PID change (pool pid {pool_pid} -> current pid {current_pid}). Closing stale pool and reinitializing...") + try: + # Close all connections owned by this (forked) process copy of the pool + connection_pool.closeall() + except Exception as close_err: + logger.warning(f"[DB_HANDLER] Error while closing stale pool in forked process: {close_err}") + finally: + connection_pool = None + pool_pid = None def init_db_pool(max_retries=5, retry_delay=5): - global connection_pool # Ensure we're modifying the global variable in this module + global connection_pool, pool_pid # Ensure we're modifying the global variable in this module attempt = 0 last_exception = None - if connection_pool is not None: - logger.info("[DB_HANDLER] Database connection pool already initialized.") + current_pid = os.getpid() + # If a pool exists but was created in a different PID, ensure we drop it first + _close_stale_pool_if_forked(current_pid) + + if connection_pool is not None and pool_pid == current_pid: + logger.info("[DB_HANDLER] Database connection pool already initialized for this process.") return connection_pool while attempt < max_retries: @@ -43,6 +69,7 @@ def init_db_pool(max_retries=5, retry_delay=5): application_name='warracker_optimized' # Identify connections ) logger.info("[DB_HANDLER] Database connection pool initialized successfully.") + pool_pid = current_pid return connection_pool # Return the pool for external check if needed except Exception as e: last_exception = e @@ -58,18 +85,33 @@ def init_db_pool(max_retries=5, retry_delay=5): raise Exception("Unknown error creating database pool") def get_db_connection(): - global connection_pool - if connection_pool is None: - logger.error("[DB_HANDLER] Database connection pool is None. Attempting to re-initialize.") - init_db_pool() # Attempt to initialize it - if connection_pool is None: # If still None after attempt - logger.critical("[DB_HANDLER] CRITICAL: Database pool re-initialization failed.") + global connection_pool, pool_pid + current_pid = os.getpid() + + # Detect and clean up any forked/stale pool + _close_stale_pool_if_forked(current_pid) + + if connection_pool is None or pool_pid != current_pid: + if connection_pool is None: + logger.info("[DB_HANDLER] Database connection pool not initialized in this process. Initializing now...") + else: + logger.warning("[DB_HANDLER] Pool PID mismatch detected. Reinitializing pool for current process...") + init_db_pool() # Attempt to initialize it for this PID + if connection_pool is None or pool_pid != current_pid: # If still invalid after attempt + logger.critical("[DB_HANDLER] CRITICAL: Database pool initialization failed for current process.") raise Exception("Database connection pool is not initialized and could not be re-initialized.") try: return connection_pool.getconn() except Exception as e: logger.error(f"[DB_HANDLER] Error getting connection from pool: {e}") - raise + # As a last resort, try reinitializing once in case the pool was invalidated + try: + _close_stale_pool_if_forked(os.getpid()) + init_db_pool() + return connection_pool.getconn() + except Exception: + # Re-raise original to preserve context + raise def release_db_connection(conn): global connection_pool diff --git a/backend/migrations/047_add_model_number_to_warranties.sql b/backend/migrations/047_add_model_number_to_warranties.sql new file mode 100644 index 0000000..3cd685c --- /dev/null +++ b/backend/migrations/047_add_model_number_to_warranties.sql @@ -0,0 +1,22 @@ +-- Migration: Add model_number column to warranties table +-- Date: 2025-10-09 + +DO $$ +BEGIN + IF NOT EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_name = 'warranties' + AND column_name = 'model_number' + ) THEN + ALTER TABLE warranties ADD COLUMN model_number VARCHAR(255) DEFAULT NULL; + RAISE NOTICE 'Added model_number column to warranties table'; + ELSE + RAISE NOTICE 'model_number column already exists in warranties table'; + END IF; +END $$; + +-- Optional index to speed up searches/filtering by model number +CREATE INDEX IF NOT EXISTS idx_warranties_model_number ON warranties(model_number); + + diff --git a/backend/warranties_routes.py b/backend/warranties_routes.py index 1cd9b64..f64840f 100644 --- a/backend/warranties_routes.py +++ b/backend/warranties_routes.py @@ -65,7 +65,7 @@ def get_warranties(): w.purchase_price, w.user_id, w.created_at, w.updated_at, w.is_lifetime, w.vendor, w.warranty_type, w.warranty_duration_years, w.warranty_duration_months, w.warranty_duration_days, w.product_photo_path, w.currency, w.paperless_invoice_id, w.paperless_manual_id, w.paperless_photo_id, w.paperless_other_id, - w.invoice_url, w.manual_url, w.other_document_url, + w.invoice_url, w.manual_url, w.other_document_url, w.model_number, CASE WHEN COUNT(c.id) = 0 THEN 'NO_CLAIMS' WHEN BOOL_OR(c.status IN ('Submitted', 'In Progress')) THEN 'OPEN' @@ -134,7 +134,7 @@ def get_archived_warranties(): w.purchase_price, w.user_id, w.created_at, w.updated_at, w.is_lifetime, w.vendor, w.warranty_type, w.warranty_duration_years, w.warranty_duration_months, w.warranty_duration_days, w.product_photo_path, w.currency, w.paperless_invoice_id, w.paperless_manual_id, w.paperless_photo_id, w.paperless_other_id, - w.invoice_url, w.manual_url, w.other_document_url, + w.invoice_url, w.manual_url, w.other_document_url, w.model_number, CASE WHEN COUNT(c.id) = 0 THEN 'NO_CLAIMS' WHEN BOOL_OR(c.status IN ('Submitted', 'In Progress')) THEN 'OPEN' @@ -249,6 +249,7 @@ def add_warranty(): notes = request.form.get('notes', '') vendor = request.form.get('vendor', None) warranty_type = request.form.get('warranty_type', None) + model_number = request.form.get('model_number', None) # Get URL fields for documents invoice_url = request.form.get('invoice_url', None) @@ -440,16 +441,16 @@ def add_warranty(): invoice_path, manual_path, other_document_path, product_url, purchase_price, user_id, is_lifetime, notes, vendor, warranty_type, warranty_duration_years, warranty_duration_months, warranty_duration_days, product_photo_path, currency, paperless_invoice_id, paperless_manual_id, paperless_photo_id, paperless_other_id, - invoice_url, manual_url, other_document_url + invoice_url, manual_url, other_document_url, model_number ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id ''', ( product_name, purchase_date, expiration_date, db_invoice_path, db_manual_path, db_other_document_path, product_url, purchase_price, user_id, is_lifetime, notes, vendor, warranty_type, warranty_duration_years, warranty_duration_months, warranty_duration_days, db_product_photo_path, currency, paperless_invoice_id, paperless_manual_id, paperless_photo_id, paperless_other_id, - invoice_url, manual_url, other_document_url + invoice_url, manual_url, other_document_url, model_number )) warranty_id = cur.fetchone()[0] @@ -711,6 +712,7 @@ def update_warranty(warranty_id): notes = request.form.get('notes', None) vendor = request.form.get('vendor', None) warranty_type = request.form.get('warranty_type', None) + model_number = request.form.get('model_number', None) # Get URL fields for documents invoice_url = request.form.get('invoice_url', None) @@ -1003,7 +1005,8 @@ def update_warranty(warranty_id): 'purchase_price': purchase_price, 'vendor': vendor, 'warranty_type': warranty_type, - 'currency': currency + 'currency': currency, + 'model_number': model_number } sql_fields = [] sql_values = [] @@ -1537,6 +1540,7 @@ def get_global_warranties(): conn = get_db_connection() with conn.cursor() as cur: # Get all warranties from all users with user information (exclude archived for default view) + # Use correlated subqueries for claim status to avoid GROUP BY collapsing or miscounting cur.execute(''' SELECT w.id, w.product_name, w.purchase_date, w.expiration_date, w.invoice_path, w.manual_path, w.other_document_path, @@ -1546,15 +1550,19 @@ def get_global_warranties(): w.invoice_url, w.manual_url, w.other_document_url, u.username, u.email, u.first_name, u.last_name, CASE - WHEN COUNT(c.id) = 0 THEN 'NO_CLAIMS' - WHEN BOOL_OR(c.status IN ('Submitted', 'In Progress')) THEN 'OPEN' - ELSE 'FINISHED' + WHEN EXISTS ( + SELECT 1 FROM warranty_claims c + WHERE c.warranty_id = w.id AND c.status IN ('Submitted', 'In Progress') + ) THEN 'OPEN' + WHEN EXISTS ( + SELECT 1 FROM warranty_claims c + WHERE c.warranty_id = w.id + ) THEN 'FINISHED' + ELSE 'NO_CLAIMS' END AS claim_status_summary FROM warranties w JOIN users u ON w.user_id = u.id - LEFT JOIN warranty_claims c ON w.id = c.warranty_id WHERE w.archived_at IS NULL - GROUP BY w.id, u.id ORDER BY u.username, CASE WHEN w.is_lifetime THEN 1 ELSE 0 END, w.expiration_date NULLS LAST, w.product_name ''') @@ -1614,6 +1622,119 @@ def get_global_warranties(): release_db_connection(conn) +@warranties_bp.route('/warranties/global/archived', methods=['GET']) +@token_required +def get_global_warranties_archived(): + """Get archived warranties from all users (public view for all authenticated users)""" + conn = None + try: + # Check if global view is enabled for this user + user_is_admin = request.user.get('is_admin', False) + + conn = get_db_connection() + with conn.cursor() as cur: + # Get both global view settings + cur.execute("SELECT key, value FROM site_settings WHERE key IN ('global_view_enabled', 'global_view_admin_only')") + settings = {row[0]: row[1] for row in cur.fetchall()} + + # Check if global view is enabled at all + global_view_enabled = settings.get('global_view_enabled', 'true').lower() == 'true' + if not global_view_enabled: + return jsonify({"error": "Global view is disabled by administrator"}), 403 + + # Check if global view is restricted to admins only + admin_only = settings.get('global_view_admin_only', 'false').lower() == 'true' + if admin_only and not user_is_admin: + return jsonify({"error": "Global view is restricted to administrators only"}), 403 + + # Release the connection since we'll get a new one below + release_db_connection(conn) + conn = None + conn = get_db_connection() + with conn.cursor() as cur: + # Get archived warranties from all users with user information + # Use correlated subqueries for claim status to avoid GROUP BY collapsing or miscounting + cur.execute(''' + SELECT + w.id, w.product_name, w.purchase_date, w.expiration_date, w.invoice_path, w.manual_path, w.other_document_path, + w.product_url, w.notes, w.purchase_price, w.user_id, w.created_at, w.updated_at, w.is_lifetime, + w.vendor, w.warranty_type, w.warranty_duration_years, w.warranty_duration_months, w.warranty_duration_days, w.product_photo_path, w.currency, + w.paperless_invoice_id, w.paperless_manual_id, w.paperless_photo_id, w.paperless_other_id, + w.invoice_url, w.manual_url, w.other_document_url, + u.username, u.email, u.first_name, u.last_name, + CASE + WHEN EXISTS ( + SELECT 1 FROM warranty_claims c + WHERE c.warranty_id = w.id AND c.status IN ('Submitted', 'In Progress') + ) THEN 'OPEN' + WHEN EXISTS ( + SELECT 1 FROM warranty_claims c + WHERE c.warranty_id = w.id + ) THEN 'FINISHED' + ELSE 'NO_CLAIMS' + END AS claim_status_summary + FROM warranties w + JOIN users u ON w.user_id = u.id + WHERE w.archived_at IS NOT NULL + ORDER BY w.archived_at DESC NULLS LAST, w.updated_at DESC NULLS LAST, w.product_name + ''') + + warranties = cur.fetchall() + columns = [desc[0] for desc in cur.description] + warranties_list = [] + + for row in warranties: + warranty_dict = dict(zip(columns, row)) + # Convert date objects to ISO format strings for JSON serialization + for key, value in warranty_dict.items(): + if isinstance(value, (datetime, date)): + warranty_dict[key] = value.isoformat() + # Convert Decimal objects to float for JSON serialization + elif isinstance(value, Decimal): + warranty_dict[key] = float(value) + + # Get serial numbers for this warranty + warranty_id = warranty_dict['id'] + cur.execute('SELECT serial_number FROM serial_numbers WHERE warranty_id = %s', (warranty_id,)) + serial_numbers = [row[0] for row in cur.fetchall()] + warranty_dict['serial_numbers'] = serial_numbers + + # Get tags for this warranty + cur.execute(''' + SELECT t.id, t.name, t.color + FROM tags t + JOIN warranty_tags wt ON t.id = wt.tag_id + WHERE wt.warranty_id = %s + ORDER BY t.name + ''', (warranty_id,)) + tags = [{'id': t[0], 'name': t[1], 'color': t[2]} for t in cur.fetchall()] + warranty_dict['tags'] = tags + + # Add user display name for better UI + first_name = warranty_dict.get('first_name', '').strip() if warranty_dict.get('first_name') else '' + last_name = warranty_dict.get('last_name', '').strip() if warranty_dict.get('last_name') else '' + username = warranty_dict.get('username', '').strip() if warranty_dict.get('username') else '' + + if first_name and last_name: + warranty_dict['user_display_name'] = f"{first_name} {last_name}" + elif first_name: + warranty_dict['user_display_name'] = first_name + elif username: + warranty_dict['user_display_name'] = username + else: + warranty_dict['user_display_name'] = 'Unknown User' + + warranties_list.append(warranty_dict) + + return jsonify(warranties_list) + except Exception as e: + logger.error(f"Error retrieving archived global warranties: {e}") + return jsonify({"error": "Failed to retrieve archived global warranties"}), 500 + finally: + if conn: + release_db_connection(conn) + + @warranties_bp.route('/currencies', methods=['GET']) @token_required def get_currencies(): diff --git a/frontend/about.html b/frontend/about.html index 84ec07f..ddf7292 100644 --- a/frontend/about.html +++ b/frontend/about.html @@ -323,7 +323,7 @@
A comprehensive warranty management system designed to help you track, organize, and manage all your product warranties in one secure, user-friendly platform.
@@ -421,7 +421,7 @@ // Update version display dynamically const versionDisplay = document.getElementById('versionDisplay'); if (versionDisplay && window.i18next) { - const currentVersion = '0.10.1.14'; // This should match version-checker.js + const currentVersion = '0.10.1.15'; // This should match version-checker.js versionDisplay.textContent = window.i18next.t('about.version') + ' v' + currentVersion; } diff --git a/frontend/index.html b/frontend/index.html index 9e87722..27ff2a5 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -415,6 +415,10 @@