diff --git a/CHANGELOG.md b/CHANGELOG.md index ffde072..e5e4d53 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,215 @@ # Changelog +## 0.10.1.0 - 2025-06-10 + +### Added +- **Public Global Warranty View for All Users** + - Extended global warranty view access to all authenticated users (previously admin-only) + - **Regular Users**: Can view all warranties from all users but can only edit/delete their own + - **Admin Users**: Can edit/delete any warranty in global view, maintaining full administrative control + - Added read-only protection: edit/delete buttons are replaced with view-only eye icon for warranties not owned by current user (unless user is admin) + - Updated UI labels and tooltips to reflect the new public access model + - Maintains full admin functionality while providing transparency to all users + +- **Admin Global View Control Settings** + - Added admin setting to enable/disable the global view feature site-wide + - New "Global View Enabled" toggle in Site Settings section of admin panel + - New "Global View Admin Only" toggle to restrict global view access to administrators only + - When disabled, global view switcher is hidden from all users (including non-admins) + - When admin-only is enabled, only administrators can access global view feature + - Real-time enforcement: users automatically redirected to personal view if global view is disabled or restricted while they're using it + - Default enabled for backward compatibility with admin-only setting defaulted to false + +- **Global View for Status Dashboard** + - Extended global view functionality to the warranty status/analytics page + - Eligible users can now view warranty statistics and data from all users on the status dashboard + - Added view switcher (Personal/Global) to status page header with same permission controls as main page + - Global statistics include total counts, charts, and warranty tables from all users + - Owner information displayed in warranty tables when in global view mode + - Maintains same security model: admins can see all data, regular users see all data but with read-only access to others' warranties + - Seamless integration with existing global view settings and permissions +- **Apprise Push Notifications Integration:** Comprehensive push notification system supporting 80+ services for warranty expiration alerts. + - **Backend Implementation:** + - **Apprise Handler (`backend/apprise_handler.py`):** Complete notification management system with configuration loading, URL validation, and multi-service support. + - **Database Migration (`backend/migrations/026_add_apprise_settings.sql`):** Added Apprise configuration settings to site_settings table. + - **API Endpoints (`backend/app.py`):** Full REST API for Apprise management including test notifications, URL validation, configuration reload, and manual triggers. + - **Scheduler Integration:** Enhanced existing notification scheduler to send both email and Apprise notifications simultaneously. + - **Environment Support:** Full environment variable support for Docker deployments with fallback to database configuration. + - **Frontend Implementation:** + - **Admin Settings UI (`frontend/settings-new.html`):** Complete Apprise configuration section with real-time status, URL management, and testing capabilities. + - **JavaScript Integration (`frontend/settings-new.js`):** Full frontend functionality for configuration management, URL validation, and notification testing. + - **Responsive Design (`frontend/settings-styles.css`):** Modern UI components including status badges, action grids, and mobile-responsive layouts. + - **Supported Services:** Discord, Slack, Telegram, Email (Gmail, Outlook), Microsoft Teams, Webhooks, Matrix, Pushover, Ntfy, Gotify, and 70+ more services. + - **Configuration Options:** + - **Multiple Notification URLs:** Support for comma-separated or line-separated notification service URLs + - **Flexible Timing:** Configurable notification days (e.g., 7,30 days before expiration) and daily notification time + - **Custom Branding:** Configurable title prefix for all notifications (e.g., "[Warracker]") + - **Test Functionality:** Send test notifications to verify configuration before enabling + - **URL Validation:** Real-time validation of Apprise notification URLs + - **Environment Configuration (`env.example`):** Complete example configuration file with detailed Apprise setup instructions and URL format examples. + - **Graceful Degradation:** System continues to function normally if Apprise is not installed, with appropriate admin notifications. + - _Files: `backend/apprise_handler.py`, `backend/migrations/026_add_apprise_settings.sql`, `backend/app.py`, `backend/db_handler.py`, `backend/requirements.txt`, `frontend/settings-new.html`, `frontend/settings-new.js`, `frontend/settings-styles.css`, `docker-compose.yml`, `env.example`_ +- **Warranty Type Filtering and Sorting:** Enhanced the home page with comprehensive warranty type filtering and sorting capabilities. + - **New Warranty Type Filter Dropdown:** Added a dedicated "Warranty Type" filter dropdown in the filter section, positioned between Vendor and Sort By filters. + - **Dynamic Filter Population:** The warranty type filter automatically populates with all unique warranty types found in existing warranties, sorted alphabetically. + - **Case-Insensitive Filtering:** Warranty type filtering works regardless of the case of the warranty type (e.g., "Standard", "standard", "STANDARD"). + - **Sorting by Warranty Type:** Added "Warranty Type" as a sorting option in the Sort By dropdown, allowing users to sort warranties alphabetically by their warranty type. + - **Real-time Filtering:** Filter applies immediately when warranty type selection changes, working seamlessly with existing filters (Status, Tag, Vendor, Search). + - **Frontend Implementation:** + - Updated `frontend/index.html` to include warranty type filter dropdown and sort option + - Enhanced `frontend/script.js` with warranty type filtering logic, event listeners, and population function + - Added `populateWarrantyTypeFilter()` function to dynamically populate filter options from warranty data + - Integrated warranty type support into `currentFilters` object and `applyFilters()` function + - Added warranty type sorting logic to `renderWarranties()` function + - **Integration:** Works with the existing warranty type field that was previously added to add/edit forms, providing end-to-end warranty type management. + - _Files: `frontend/index.html`, `frontend/script.js`_ + +- **Admin Global Warranty View:** Added a globe button for administrators to view all users' warranties alongside their own. + - **Backend API (`backend/app.py`):** New `/api/admin/warranties` endpoint that returns all warranties from all users with user information (username, email, display name). + - **Frontend UI (`frontend/index.html`):** Added admin-only "Scope" toggle with Personal/Global view buttons next to the existing view switcher. + - **Frontend Logic (`frontend/script.js`):** + - Added `isGlobalView` state management and view switching functions + - Enhanced warranty rendering to show owner information when in global view + - Automatic admin detection and UI initialization + - Dynamic title updates ("Your Warranties" vs "All Users' Warranties") + - **Styling (`frontend/style.css`):** + - Admin view switcher styling with primary color theme + - Owner information highlighting with colored border and background + - Responsive design for mobile devices + - **Security:** Admin-only access with proper permission checks and graceful fallbacks for non-admin users. + - _Files: `backend/app.py`, `frontend/index.html`, `frontend/script.js`, `frontend/style.css`_ + +- **Product Photo Thumbnails on Warranty Cards:** Enhanced warranty cards with visual product photo thumbnails for quick identification and easy access to full-size images. + - **Thumbnail Display:** Product photos now appear as small thumbnails in the top right corner of each warranty card, providing instant visual recognition of products. + - **Multi-View Support:** Photo thumbnails are displayed across all warranty viewing modes: + - **Grid View:** 60px thumbnails positioned elegantly in the card corner + - **List View:** 50px thumbnails for compact horizontal layouts + - **Table View:** 40px thumbnails optimized for dense data display + - **Interactive Photo Access:** Users can click on any product photo thumbnail to open the full-size image in a new browser tab for detailed viewing. + - **Secure Image Handling:** Product photos are served through secure authentication, ensuring only authorized users can view warranty images while maintaining fast loading performance. + - **Real-time Updates:** When users add or update product photos through the warranty edit forms, the thumbnail images immediately appear on warranty cards without requiring a page refresh. + - **Visual Feedback:** Photo thumbnails include hover effects and "Click to view full size image" tooltips to clearly indicate their interactive nature. + - **Responsive Design:** Photo thumbnails automatically scale and position appropriately across different screen sizes and device types. + - _Files: `frontend/script.js`, `frontend/style.css`_ + +### Fixed +- **Status Dashboard Chart.js Canvas Errors** + - Fixed "Canvas is already in use" errors on the status page that prevented charts from rendering properly + - **Chart Destruction**: Added proper chart destruction with error handling before creating new charts + - **Multiple Initialization Prevention**: Added initialization flags to prevent multiple dashboard initializations + - **DOM Event Protection**: Protected against duplicate DOM event handler attachments + - **Improved Error Handling**: Added try-catch blocks around chart creation and destruction operations + - **View Switching Stability**: Fixed chart recreation issues when switching between personal and global views + - **Result**: Status page charts now render reliably without canvas conflicts, multiple view switches work smoothly + - _Files: `frontend/status.js`_ + +- **CSS Cache Busting for Domain Consistency** + - Added version parameters to CSS and JavaScript files across all major pages to prevent caching issues between local IP and domain access + - **CSS Files Updated:** `style.css?v=20250529005`, `header-fix.css?v=20250529005`, `mobile-header.css?v=20250529005`, `settings-styles.css?v=20250529005` + - **JavaScript Files Updated:** `script.js?v=20250529005`, `auth.js?v=20250529005`, `settings-new.js?v=20250529005` + - Updated Service Worker cache name to `warracker-cache-v2` and included all versioned files to force cache refresh + - Fixed styling inconsistencies where admin global warranty view and other features appeared differently between local IP and domain access + - Ensures all users get consistent styling and functionality across all pages including index, settings, and status pages + - _Files: `frontend/index.html`, `frontend/settings-new.html`, `frontend/status.html`, `frontend/script.js`, `frontend/sw.js`_ +- **Settings Page Admin Permission Issues:** Fixed critical database connection errors and 403 permission issues preventing regular users from accessing the settings page. + - **Backend Database Connection (`backend/app.py`):** Fixed inconsistent cursor variable usage in `delete_account()` function that was causing 500 errors when users attempted to delete their accounts (was using both `cursor` and `cur` variables inconsistently). + - **Frontend Admin Permission Checks (`frontend/settings-new.js`):** Added comprehensive admin permission checks to prevent non-admin users from triggering 403 errors: + - **Initial Load Protection:** Wrapped admin-only settings calls (`loadSiteSettings()`, `loadAppriseSettings()`, `loadAppriseSiteSettings()`) with user admin status checks during page initialization. + - **Deferred Load Protection:** Added admin checks for delayed Apprise settings loading to prevent unauthorized API calls. + - **Graceful 403 Handling:** Enhanced `loadSiteSettings()` function with proper 403 response handling that hides admin sections instead of showing error messages. + - **Improved Error Messaging:** Fixed misleading "Account cannot be deleted in offline mode" error by removing problematic nested try-catch that was masking actual API error messages. Users now see specific backend error messages instead of generic offline warnings. + - **Root Cause:** Settings page was unconditionally calling admin-only API endpoints for all users, causing 403 errors and confusing error messages for regular users. + - **Result:** Regular users can now access settings page without errors, see only relevant settings sections, and get clear error messages when actual issues occur. Admin users continue to see all settings sections as expected. + - _Files: `backend/app.py`, `frontend/settings-new.js`_ + +- **Settings Persistence Critical Fixes:** Resolved major settings page persistence issues where user preferences would appear to save but revert to defaults when navigating away and returning. + - **Backend Settings UPDATE Logic (`backend/app.py`):** Fixed critical bug in `/api/auth/preferences` endpoint where Apprise notification settings weren't being properly saved to the database: + - **Column Detection Fix:** Changed condition from `if apprise_notification_time and has_apprise_notification_time_col:` to `if apprise_notification_time is not None and has_apprise_notification_time_col:` to handle empty string values properly + - **Complete Field Mapping:** Added missing Apprise fields (`notification_channel`, `apprise_notification_time`, `apprise_notification_frequency`, `apprise_timezone`) to SELECT query return fields + - **Preference Response Mapping:** Enhanced preference mapping to include all Apprise settings in API responses + - **Frontend Race Condition Fixes (`frontend/settings-new.js`):** Eliminated multiple simultaneous API calls that were causing preference loading conflicts: + - **Duplicate Request Prevention:** Fixed `loadPreferences()` being called 4 times simultaneously, causing race conditions + - **API Priority Logic:** Ensured API data takes precedence over localStorage when both exist + - **UI Synchronization:** Added proper dark mode toggle sync between API and UI state + - **Authentication Token Standardization:** Unified token usage pattern across all preference save functions + - **Root Cause:** Backend was silently failing to save Apprise settings due to strict conditional logic, while frontend race conditions were creating inconsistent data states + - **Result:** Settings now persist correctly across page navigations, with all notification preferences saving and loading reliably + - _Files: `backend/app.py`, `frontend/settings-new.js`_ + +- **Notification System Comprehensive Overhaul:** Fixed critical timing and duplicate notification issues affecting both email and Apprise scheduled notifications. + - **Timing Logic Precision (`backend/notifications.py`):** Completely rewrote notification timing calculation for accurate delivery: + - **Precise Windows:** Changed from aggressive "send_window or next_miss_window" logic to exact "0 <= time_diff <= 2" minute windows + - **Post-Target Delivery:** Notifications now only send AFTER target time (not before) within 2-minute window to prevent early delivery + - **Enhanced Time Calculations:** Added comprehensive timezone handling with detailed debug logging showing exact time differences + - **Duplicate Prevention System (`backend/notifications.py`):** Implemented robust duplicate prevention using in-memory tracking: + - **Separate Tracking:** Independent tracking for `email_{user_id}_{date}` and `apprise_{user_id}_{date}` patterns + - **Daily Reset Logic:** Automatic cleanup for day rollover handling across different timezones + - **Collision Prevention:** Added 0.1s delay for manual triggers to prevent collision with scheduled jobs + - **Column Mismatch Fixes (`backend/notifications.py`):** Fixed database errors causing notification failures: + - **Error Handling:** Added try/catch around column unpacking at line 327 to handle schema mismatches + - **Variable Consistency:** Fixed missing `apprise_timezone` variable in debug logging sections + - **Graceful Degradation:** System continues operating even with partial database schema issues + - **Enhanced Debug Output (`backend/notifications.py`):** Added comprehensive logging for troubleshooting: + - **Time Difference Display:** Shows exact calculations like "email_time=08:20(diff:-61), apprise_time=23:28(diff:-976)" + - **Eligibility Reasons:** Clear logging explaining why users are/aren't eligible for notifications + - **Real-time Diagnostics:** Live timing calculations visible in Docker logs for debugging + - **Root Cause:** Overly aggressive timing windows caused duplicate emails, while backend save issues prevented Apprise settings from persisting, leading to notifications not being scheduled + - **Result:** Both email and Apprise notifications now work reliably with precise timing, no duplicates, and proper settings persistence + - _Files: `backend/notifications.py`, `backend/app.py`_ + + + +#### Technical Implementation for Global View Features +- **Backend (app.py):** + - Added new `/api/warranties/global` endpoint accessible to all authenticated users (not just admins) + - Added `global_view_enabled` setting to site settings with default value 'true' + - Added `global_view_admin_only` setting to site settings with default value 'false' + - Added `/api/settings/global-view-status` endpoint for checking global view availability per user + - Enhanced `/api/warranties/global` endpoint to check both global view settings and user admin status + - Added new `/api/statistics/global` endpoint for global warranty statistics with user information +- **Frontend (script.js):** + - Renamed `initAdminViewControls()` to `initViewControls()` and removed admin-only restrictions + - Updated `switchToGlobalView()` to check global view setting before switching + - Added ownership and admin validation logic to conditionally render edit/delete buttons vs view-only placeholder + - Admins see edit/delete buttons for all warranties, regular users only for their own + - Modified API endpoint from `/api/admin/warranties` to `/api/warranties/global` for public access + - Added real-time global view status checking and automatic fallback to personal view +- **Frontend (index.html):** Updated tooltips and labels to reflect public access +- **Frontend (style.css):** Added styling for view-only placeholder button with eye icon +- **Frontend (settings-new.html):** Added Global View Enabled and Global View Admin Only toggles in Site Settings section +- **Frontend (settings-new.js):** Added loading and saving logic for both global view settings +- **Frontend (status.html):** Added view switcher controls and owner column to warranty table for global view +- **Frontend (status.js):** Added global view functionality with API switching, table column management, and permission checking + +#### Security Features +- Backend endpoint still requires authentication (all users must be logged in) +- Frontend validates warranty ownership and admin status before showing edit/delete buttons +- **Regular users** can only modify warranties they own, even in global view +- **Admin users** can modify any warranty in global view, maintaining administrative privileges +- Maintains data privacy while providing transparency + +#### User Experience +- Seamless view switching between personal and global views for all users +- Clear visual indication (eye icon) when viewing others' warranties +- Consistent UI patterns with existing admin functionality +- Enhanced tooltips explaining read-only access for non-owned warranties + +### Enhanced +- **Footer Links:** Updated all "Powered by Warracker" footer links across the application to point to `https://warracker.com` instead of the GitHub repository, providing users with direct access to the official website. + - **Files Updated:** `index.html`, `login.html`, `register.html`, `reset-password.html`, `reset-password-request.html`, `settings-new.html`, `status.html`, `auth-redirect.html`, and `about.html` + - **Result:** Consistent branding and improved user experience with direct access to the official Warracker website. + +- **PostgreSQL Security Hardening:** Removed unnecessary SUPERUSER privileges from the application database user, significantly improving security posture. + - **Security Improvement:** Eliminated SUPERUSER grants that provided excessive and unnecessary privileges to the application database user + - **Files Modified:** + - `backend/fix_permissions.sql`: Removed `ALTER ROLE %(db_user)s WITH SUPERUSER;` + - `backend/migrations/011_ensure_admin_permissions.sql`: Commented out SUPERUSER grant + - `Dockerfile`: Removed retry loop attempting to grant SUPERUSER privileges via psql + - **Principle of Least Privilege:** Application now operates with only the specific database privileges required for normal operation (CREATE/DROP/ALTER on tables, sequences, functions, etc.) + - **Testing Verified:** Full application functionality confirmed to work correctly without SUPERUSER privileges, including migrations, user management, and all admin operations + - **Security Benefit:** Significantly reduces attack surface - if the application is compromised, an attacker no longer has complete database administrative control + - **Result:** Maintained full application functionality while eliminating unnecessary security risks + ## 0.10.0.0 - 2025-06-4 ### Fixed diff --git a/Docker/.env.example b/Docker/.env.example index ce5def4..0c5f823 100644 --- a/Docker/.env.example +++ b/Docker/.env.example @@ -34,7 +34,7 @@ JWT_EXPIRATION_HOURS=24 # SMTP server settings for sending notifications and password resets SMTP_HOST=smtp.gmail.com -SMTP_PORT=465 +SMTP_PORT=587 SMTP_USERNAME=youremail@gmail.com SMTP_PASSWORD=your_email_password @@ -112,7 +112,7 @@ PYTHONUNBUFFERED=1 **Gmail SMTP:** ```bash SMTP_HOST=smtp.gmail.com -SMTP_PORT=465 +SMTP_PORT=587 SMTP_USERNAME=youremail@gmail.com SMTP_PASSWORD=your_app_password SMTP_USE_TLS=true diff --git a/Dockerfile b/Dockerfile index 07ca3e4..14b6a30 100644 --- a/Dockerfile +++ b/Dockerfile @@ -10,7 +10,10 @@ RUN apt-get update && \ curl \ postgresql-client \ supervisor \ - gettext-base && \ + gettext-base \ + libcurl4-openssl-dev \ + libssl-dev \ + ca-certificates && \ apt-get clean && \ rm -rf /var/lib/apt/lists/* # (build-essential = C compiler + tools) @@ -39,6 +42,8 @@ COPY backend/auth_utils.py /app/backend/ COPY backend/db_handler.py /app/backend/ COPY backend/extensions.py /app/backend/ COPY backend/oidc_handler.py /app/backend/ +COPY backend/apprise_handler.py /app/backend/ +COPY backend/notifications.py /app/backend/ # Copy other utility scripts and migrations COPY backend/fix_permissions.py . @@ -59,36 +64,11 @@ RUN rm /etc/nginx/sites-enabled/default # Copy nginx.conf as a template COPY nginx.conf /etc/nginx/conf.d/default.conf.template -# Create startup script with database initialization +# Create startup script with database initialization (SUPERUSER grant removed) RUN echo '#!/bin/bash\n\ set -e # Exit immediately if a command exits with a non-zero status.\n\ echo "Running database migrations..."\n\ python /app/migrations/apply_migrations.py\n\ -echo "Ensuring admin role has proper permissions..."\n\ -# Retry logic for granting superuser privileges\n\ -max_attempts=5\n\ -attempt=0\n\ -while [ $attempt -lt $max_attempts ]; do\n\ - echo "Attempt $((attempt+1)) to grant superuser privileges..."\n\ - # Ensure DB variables are set (you might pass these at runtime)\n\ - if [ -z "$DB_PASSWORD" ] || [ -z "$DB_HOST" ] || [ -z "$DB_USER" ] || [ -z "$DB_NAME" ]; then\n\ - echo "Error: Database connection variables (DB_PASSWORD, DB_HOST, DB_USER, DB_NAME) are not set."\n\ - exit 1\n\ - fi\n\ - # Use timeout to prevent indefinite hanging if DB is not ready\n\ - if PGPASSWORD=$DB_PASSWORD psql -w -h $DB_HOST -U $DB_USER -d $DB_NAME -c "ALTER ROLE $DB_USER WITH SUPERUSER;" 2>/dev/null; then\n\ - echo "Successfully granted superuser privileges to $DB_USER"\n\ - break\n\ - else\n\ - echo "Failed to grant privileges (attempt $((attempt+1))), retrying in 5 seconds..."\n\ - sleep 5\n\ - attempt=$((attempt+1))\n\ - fi\n\ -done\n\ -if [ $attempt -eq $max_attempts ]; then\n\ - echo "Error: Failed to grant superuser privileges after $max_attempts attempts."\n\ - exit 1 # Exit if granting fails after retries\n\ -fi\n\ echo "Running fix permissions script..."\n\ python /app/fix_permissions.py\n\ echo "Setup script finished successfully."\n\ diff --git a/backend/app.py b/backend/app.py index 45c84da..ae525cc 100644 --- a/backend/app.py +++ b/backend/app.py @@ -1,6 +1,12 @@ from flask import Flask, request, jsonify, send_from_directory, session, redirect, url_for, Response -from backend.extensions import oauth -from backend.db_handler import init_db_pool, get_db_connection, release_db_connection +try: + # Try relative import for Docker environment + from backend.extensions import oauth + from backend.db_handler import init_db_pool, get_db_connection, release_db_connection +except ImportError: + # Fallback to direct import for development + from extensions import oauth + from db_handler import init_db_pool, get_db_connection, release_db_connection import psycopg2 # Added import import os from datetime import datetime, timedelta, date @@ -17,7 +23,7 @@ import uuid import smtplib from email.mime.multipart import MIMEMultipart from email.mime.text import MIMEText -from apscheduler.schedulers.background import BackgroundScheduler +from backend import notifications import atexit from pytz import timezone as pytz_timezone import pytz @@ -56,6 +62,23 @@ oauth.init_app(app) # Initialize Authlib OAuth with the app instance logging.basicConfig(level=logging.INFO) logger = logging.getLogger(__name__) +# Import Apprise notification handler (after logger is defined) +try: + try: + # Try Docker environment path first + from backend.apprise_handler import AppriseNotificationHandler + except ImportError: + # Fallback to development path + from apprise_handler import AppriseNotificationHandler + + apprise_handler = AppriseNotificationHandler() + APPRISE_AVAILABLE = True + logger.info("Apprise notification handler imported successfully") +except ImportError as e: + APPRISE_AVAILABLE = False + apprise_handler = None + logger.warning(f"Apprise not available: {e}. Notification features will be disabled.") + # App configurations app.config['SECRET_KEY'] = os.environ.get('SECRET_KEY', 'your_default_secret_key_please_change_in_prod') app.config['JWT_EXPIRATION_DELTA'] = timedelta(hours=int(os.environ.get('JWT_EXPIRATION_HOURS', '24'))) @@ -1184,8 +1207,8 @@ def get_warranties(): # Replaced warranty_years with warranty_duration_years, warranty_duration_months, warranty_duration_days cur.execute(''' SELECT id, product_name, purchase_date, expiration_date, invoice_path, manual_path, other_document_path, product_url, notes, - purchase_price, user_id, created_at, updated_at, is_lifetime, vendor, - warranty_duration_years, warranty_duration_months, warranty_duration_days + purchase_price, user_id, created_at, updated_at, is_lifetime, vendor, warranty_type, + warranty_duration_years, warranty_duration_months, warranty_duration_days, product_photo_path FROM warranties WHERE user_id = %s ORDER BY CASE WHEN is_lifetime THEN 1 ELSE 0 END, expiration_date NULLS LAST, product_name @@ -1301,6 +1324,7 @@ def add_warranty(): user_id = request.user['id'] notes = request.form.get('notes', '') vendor = request.form.get('vendor', None) + warranty_type = request.form.get('warranty_type', None) # Get tag IDs if provided tag_ids = [] @@ -1417,6 +1441,27 @@ def add_warranty(): logger.error(f"Error saving other_document {filename} to {other_document_path_on_disk}: {e}") return jsonify({"error": f"Failed to save other_document: {str(e)}"}), 500 + # Handle product photo file upload + db_product_photo_path = None + if 'product_photo' in request.files: + product_photo = request.files['product_photo'] + if product_photo.filename != '': + # Check if it's an image file + if not (product_photo.filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif'))): + return jsonify({"error": "Product photo must be an image file (PNG, JPG, JPEG, WEBP, GIF)"}), 400 + + filename = secure_filename(product_photo.filename) + filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_photo_{filename}" + product_photo_path_on_disk = os.path.join(app.config['UPLOAD_FOLDER'], filename) + logger.info(f"Attempting to save product_photo to: {product_photo_path_on_disk}") + try: + product_photo.save(product_photo_path_on_disk) + db_product_photo_path = os.path.join('uploads', filename) + logger.info(f"Successfully saved product_photo: {db_product_photo_path}") + except Exception as e: + logger.error(f"Error saving product_photo {filename} to {product_photo_path_on_disk}: {e}") + return jsonify({"error": f"Failed to save product_photo: {str(e)}"}), 500 + # Save to database conn = get_db_connection() with conn.cursor() as cur: @@ -1424,15 +1469,15 @@ def add_warranty(): cur.execute(''' INSERT INTO warranties ( product_name, purchase_date, expiration_date, - invoice_path, manual_path, other_document_path, product_url, purchase_price, user_id, is_lifetime, notes, vendor, - warranty_duration_years, warranty_duration_months, warranty_duration_days + 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 ) - VALUES (%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) 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_duration_years, warranty_duration_months, warranty_duration_days + 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 )) warranty_id = cur.fetchone()[0] @@ -1496,12 +1541,13 @@ def delete_warranty(warranty_id): return jsonify({"error": "Warranty not found or you don't have permission to delete it"}), 404 # First get the file paths to delete the files - cur.execute('SELECT invoice_path, manual_path, other_document_path FROM warranties WHERE id = %s', (warranty_id,)) + cur.execute('SELECT invoice_path, manual_path, other_document_path, product_photo_path FROM warranties WHERE id = %s', (warranty_id,)) result = cur.fetchone() invoice_path = result[0] manual_path = result[1] other_document_path = result[2] + product_photo_path = result[3] # Delete the warranty from database cur.execute('DELETE FROM warranties WHERE id = %s', (warranty_id,)) @@ -1525,6 +1571,12 @@ def delete_warranty(warranty_id): full_path = os.path.join('/data', other_document_path) if os.path.exists(full_path): os.remove(full_path) + + # Delete the product photo file if it exists + if product_photo_path: + full_path = os.path.join('/data', product_photo_path) + if os.path.exists(full_path): + os.remove(full_path) return jsonify({"message": "Warranty deleted successfully"}), 200 @@ -1649,6 +1701,7 @@ def update_warranty(warranty_id): product_url = request.form.get('product_url', '') notes = request.form.get('notes', None) vendor = request.form.get('vendor', None) + warranty_type = request.form.get('warranty_type', None) tag_ids = [] if request.form.get('tag_ids'): try: @@ -1784,6 +1837,51 @@ def update_warranty(warranty_id): logger.error(f"Error deleting other_document (delete request): {e}") db_other_document_path = None # Set to None to clear in DB + # Handle product photo file upload + db_product_photo_path = None + if 'product_photo' in request.files: + product_photo = request.files['product_photo'] + if product_photo.filename != '': + # Check if it's an image file + if not (product_photo.filename.lower().endswith(('.png', '.jpg', '.jpeg', '.webp', '.gif'))): + return jsonify({"error": "Product photo must be an image file (PNG, JPG, JPEG, WEBP, GIF)"}), 400 + + # Delete old photo if it exists + cur.execute('SELECT product_photo_path FROM warranties WHERE id = %s', (warranty_id,)) + old_product_photo_path = cur.fetchone()[0] + if old_product_photo_path: + full_path = os.path.join('/data', old_product_photo_path) + if os.path.exists(full_path): + try: + os.remove(full_path) + logger.info(f"Deleted old product_photo: {full_path}") + except Exception as e: + logger.error(f"Error deleting old product_photo: {e}") + + filename = secure_filename(product_photo.filename) + filename = f"{datetime.now().strftime('%Y%m%d%H%M%S')}_photo_{filename}" + product_photo_path_on_disk = os.path.join(app.config['UPLOAD_FOLDER'], filename) + logger.info(f"Attempting to save updated product_photo to: {product_photo_path_on_disk}") + try: + product_photo.save(product_photo_path_on_disk) + db_product_photo_path = os.path.join('uploads', filename) + logger.info(f"Successfully saved updated product_photo: {db_product_photo_path}") + except Exception as e: + logger.error(f"Error saving updated product_photo {filename} to {product_photo_path_on_disk}: {e}") + return jsonify({"error": f"Failed to save updated product_photo: {str(e)}"}), 500 + elif request.form.get('delete_product_photo', 'false').lower() == 'true': + cur.execute('SELECT product_photo_path FROM warranties WHERE id = %s', (warranty_id,)) + old_product_photo_path = cur.fetchone()[0] + if old_product_photo_path: + full_path = os.path.join('/data', old_product_photo_path) + if os.path.exists(full_path): + try: + os.remove(full_path) + logger.info(f"Deleted product_photo (delete request): {full_path}") + except Exception as e: + logger.error(f"Error deleting product_photo (delete request): {e}") + db_product_photo_path = None # Set to None to clear in DB + update_params = { 'product_name': product_name, 'purchase_date': purchase_date, @@ -1794,7 +1892,8 @@ def update_warranty(warranty_id): 'expiration_date': expiration_date, # Will be None if lifetime 'product_url': product_url, 'purchase_price': purchase_price, - 'vendor': vendor + 'vendor': vendor, + 'warranty_type': warranty_type } sql_fields = [] sql_values = [] @@ -1819,6 +1918,11 @@ def update_warranty(warranty_id): sql_values.append(db_other_document_path) elif 'delete_other_document' in request.form and request.form.get('delete_other_document', 'false').lower() == 'true': sql_fields.append("other_document_path = NULL") + if db_product_photo_path is not None: + sql_fields.append("product_photo_path = %s") + sql_values.append(db_product_photo_path) + elif 'delete_product_photo' in request.form and request.form.get('delete_product_photo', 'false').lower() == 'true': + sql_fields.append("product_photo_path = NULL") sql_fields.append("updated_at = NOW()") # Use SQL function, no parameter needed sql_values.append(warranty_id) @@ -2043,6 +2147,215 @@ def get_statistics(): if conn: release_db_connection(conn) +@app.route('/api/statistics/global', methods=['GET']) +@token_required +def get_global_statistics(): + """Get global warranty statistics for all users (with proper permissions check)""" + 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 + + # Get user's expiring soon days preference (for consistency) + user_id = request.user['id'] + expiring_soon_days = 30 # Default value + + conn = get_db_connection() + try: + with conn.cursor() as cur: + cur.execute("SELECT expiring_soon_days FROM user_preferences WHERE user_id = %s", (user_id,)) + result = cur.fetchone() + if result and result[0] is not None: + expiring_soon_days = result[0] + except Exception as pref_err: + logger.error(f"Error fetching expiring_soon_days preference for user {user_id}: {pref_err}. Using default 30 days.") + + today = date.today() + expiring_soon_date = today + timedelta(days=expiring_soon_days) + ninety_days_later = today + timedelta(days=90) + + with conn.cursor() as cur: + # Global statistics query - all warranties from all users + + # Get total count + cur.execute("SELECT COUNT(*) FROM warranties w") + total_count = cur.fetchone()[0] + + # Get active count (includes lifetime) + cur.execute("SELECT COUNT(*) FROM warranties w WHERE w.is_lifetime = TRUE OR w.expiration_date > %s", (today,)) + active_count = cur.fetchone()[0] + + # Get expired count (excludes lifetime) + cur.execute("SELECT COUNT(*) FROM warranties w WHERE w.is_lifetime = FALSE AND w.expiration_date <= %s", (today,)) + expired_count = cur.fetchone()[0] + + # Get expiring soon count (excludes lifetime) + cur.execute("""SELECT COUNT(*) FROM warranties w WHERE + w.is_lifetime = FALSE AND w.expiration_date > %s AND w.expiration_date <= %s""", + (today, expiring_soon_date)) + expiring_soon_count = cur.fetchone()[0] + + # Get expiration timeline (next 90 days, excluding lifetime) + cur.execute(""" + SELECT + EXTRACT(YEAR FROM expiration_date) as year, + EXTRACT(MONTH FROM expiration_date) as month, + COUNT(*) as count + FROM warranties w + WHERE w.is_lifetime = FALSE AND w.expiration_date > %s AND w.expiration_date <= %s + GROUP BY EXTRACT(YEAR FROM expiration_date), EXTRACT(MONTH FROM expiration_date) + ORDER BY year, month + """, (today, ninety_days_later)) + + timeline = [] + for row in cur.fetchall(): + year = int(row[0]) + month = int(row[1]) + count = row[2] + timeline.append({ + "year": year, + "month": month, + "count": count + }) + + # Get recent expiring warranties with user information + days_ago_for_recent = today - timedelta(days=expiring_soon_days) + days_later_for_recent = expiring_soon_date + cur.execute(""" + SELECT + w.id, w.product_name, w.purchase_date, + w.warranty_duration_years, w.warranty_duration_months, w.warranty_duration_days, + w.expiration_date, w.invoice_path, w.manual_path, w.other_document_path, + w.product_url, w.purchase_price, w.is_lifetime, + u.username, u.email, u.first_name, u.last_name + FROM warranties w + JOIN users u ON w.user_id = u.id + WHERE w.is_lifetime = FALSE AND w.expiration_date >= %s AND w.expiration_date <= %s + ORDER BY w.expiration_date + LIMIT 10 + """, (days_ago_for_recent, days_later_for_recent)) + + columns = [desc[0] for desc in cur.description] + recent_warranties = [] + + for row in cur.fetchall(): + warranty = dict(zip(columns, row)) + + # Convert dates to string format + if warranty['purchase_date']: + warranty['purchase_date'] = warranty['purchase_date'].isoformat() + if warranty['expiration_date']: + warranty['expiration_date'] = warranty['expiration_date'].isoformat() + + # Convert Decimal objects to float for JSON serialization + if warranty.get('purchase_price') and isinstance(warranty['purchase_price'], Decimal): + warranty['purchase_price'] = float(warranty['purchase_price']) + + # Add user display information + first_name = warranty.get('first_name', '').strip() if warranty.get('first_name') else '' + last_name = warranty.get('last_name', '').strip() if warranty.get('last_name') else '' + username = warranty.get('username', '').strip() if warranty.get('username') else '' + + if first_name and last_name: + display_name = f"{first_name} {last_name}" + elif first_name: + display_name = first_name + elif username: + display_name = username + else: + display_name = 'Unknown User' + + warranty['user_display_name'] = display_name + + recent_warranties.append(warranty) + + # Get all warranties with user information + cur.execute(""" + SELECT + w.id, w.product_name, w.purchase_date, + w.warranty_duration_years, w.warranty_duration_months, w.warranty_duration_days, + w.expiration_date, w.invoice_path, w.manual_path, w.other_document_path, + w.product_url, w.purchase_price, w.is_lifetime, + u.username, u.email, u.first_name, u.last_name + FROM warranties w + JOIN users u ON w.user_id = u.id + ORDER BY w.expiration_date DESC + """) + + all_columns = [desc[0] for desc in cur.description] + all_warranties_list = [] + + for row in cur.fetchall(): + warranty = dict(zip(all_columns, row)) + + # Convert dates to string format + if warranty.get('purchase_date') and isinstance(warranty['purchase_date'], date): + warranty['purchase_date'] = warranty['purchase_date'].isoformat() + if warranty.get('expiration_date') and isinstance(warranty['expiration_date'], date): + warranty['expiration_date'] = warranty['expiration_date'].isoformat() + + # Convert Decimal objects to float for JSON serialization + if warranty.get('purchase_price') and isinstance(warranty['purchase_price'], Decimal): + warranty['purchase_price'] = float(warranty['purchase_price']) + + # Add user display name for better UI + first_name = warranty.get('first_name', '').strip() if warranty.get('first_name') else '' + last_name = warranty.get('last_name', '').strip() if warranty.get('last_name') else '' + username = warranty.get('username', '').strip() if warranty.get('username') else '' + + if first_name and last_name: + display_name = f"{first_name} {last_name}" + elif first_name: + display_name = first_name + elif username: + display_name = username + else: + display_name = 'Unknown User' + + warranty['user_display_name'] = display_name + + all_warranties_list.append(warranty) + + statistics = { + 'total': total_count, + 'active': active_count, + 'expired': expired_count, + 'expiring_soon': expiring_soon_count, + 'timeline': timeline, + 'recent_warranties': recent_warranties, + 'all_warranties': all_warranties_list + } + + return jsonify(convert_decimals(statistics)) + + except Exception as e: + logger.error(f"Error getting global warranty statistics: {e}") + return jsonify({"error": str(e)}), 500 + + finally: + if conn: + release_db_connection(conn) + @app.route('/api/test', methods=['GET']) def test_endpoint(): """Simple test endpoint to check if the API is responding.""" @@ -2096,7 +2409,7 @@ def update_profile(): # Check if the new email is different from the current one cursor.execute("SELECT email FROM users WHERE id = %s", (user_id,)) - current_email_tuple = cursor.fetchone() + current_email_tuple = cur.fetchone() if not current_email_tuple: conn.rollback() return jsonify({'message': 'User not found while fetching current email.'}), 404 @@ -2171,12 +2484,12 @@ def delete_account(): cursor.execute("DELETE FROM password_reset_tokens WHERE user_id = %s", (user_id,)) # Delete user's sessions if any - cur.execute('DELETE FROM user_sessions WHERE user_id = %s', (user_id,)) - sessions_deleted = cur.rowcount + cursor.execute('DELETE FROM user_sessions WHERE user_id = %s', (user_id,)) + sessions_deleted = cursor.rowcount logger.info(f"Deleted {sessions_deleted} sessions belonging to user {user_id}") # Delete user's tags - cur.execute('DELETE FROM tags WHERE user_id = %s', (user_id,)) + cursor.execute('DELETE FROM tags WHERE user_id = %s', (user_id,)) tags_deleted = cur.rowcount logger.info(f"Deleted {tags_deleted} tags belonging to user {user_id}") @@ -2210,6 +2523,14 @@ def get_preferences(): conn = get_db_connection() cursor = conn.cursor() try: + # Check for notification columns + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name='user_preferences' + AND column_name IN ('notification_channel', 'apprise_notification_time', 'apprise_notification_frequency', 'apprise_timezone') + """) + existing_columns = [row[0] for row in cursor.fetchall()] + cursor.execute(""" SELECT EXISTS ( SELECT FROM information_schema.tables @@ -2245,8 +2566,22 @@ def get_preferences(): cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name='user_preferences' AND column_name='date_format'") has_date_format_col = cursor.fetchone() is not None + has_notification_channel = 'notification_channel' in existing_columns + has_apprise_notification_time = 'apprise_notification_time' in existing_columns + has_apprise_notification_frequency = 'apprise_notification_frequency' in existing_columns + # Build select list dynamically select_fields_list = ["email_notifications", "default_view", "theme", "expiring_soon_days", "notification_frequency", "notification_time", "timezone"] + if has_notification_channel: + select_fields_list.append("notification_channel") + has_apprise_notification_time = 'apprise_notification_time' in existing_columns + if has_apprise_notification_time: + select_fields_list.append("apprise_notification_time") + if 'apprise_timezone' in existing_columns: + select_fields_list.append("apprise_timezone") + has_apprise_notification_frequency = 'apprise_notification_frequency' in existing_columns + if has_apprise_notification_frequency: + select_fields_list.append("apprise_notification_frequency") if has_currency: select_fields_list.append("currency_symbol") if has_date_format_col: @@ -2267,6 +2602,15 @@ def get_preferences(): # Build insert list dynamically insert_cols_list = ["user_id", "email_notifications", "default_view", "theme", "expiring_soon_days", "notification_frequency", "notification_time", "timezone"] insert_vals = [user_id, True, 'grid', 'light', 30, 'daily', '09:00', 'UTC'] + if has_notification_channel: + insert_cols_list.append("notification_channel") + insert_vals.append('email') # Default to email notifications + if has_apprise_notification_time: + insert_cols_list.append("apprise_notification_time") + insert_vals.append('09:00') + if has_apprise_notification_frequency: + insert_cols_list.append("apprise_notification_frequency") + insert_vals.append('daily') if has_currency: insert_cols_list.append("currency_symbol") insert_vals.append('$') @@ -2301,13 +2645,29 @@ def get_preferences(): 'notification_frequency': preferences_data[pref_map.get('notification_frequency', 4)], 'notification_time': preferences_data[pref_map.get('notification_time', 5)], 'timezone': preferences_data[pref_map.get('timezone', 6)], + 'notification_channel': preferences_data[pref_map['notification_channel']] if 'notification_channel' in pref_map else 'email', + 'apprise_notification_time': preferences_data[pref_map['apprise_notification_time']] if 'apprise_notification_time' in pref_map else '09:00', + 'apprise_timezone': preferences_data[pref_map['apprise_timezone']] if 'apprise_timezone' in pref_map else 'UTC', + 'apprise_notification_frequency': preferences_data[pref_map['apprise_notification_frequency']] if 'apprise_notification_frequency' in pref_map else 'daily', 'currency_symbol': preferences_data[pref_map['currency_symbol']] if 'currency_symbol' in pref_map else '$', # Handle currency optionality 'date_format': preferences_data[pref_map['date_format']] if 'date_format' in pref_map else 'MDY' # Handle date_format optionality } else: # Fallback if even insert failed or returned nothing - preferences = default_preferences.copy() - preferences['date_format'] = 'MDY' # Ensure date_format is in fallback + preferences = { + 'email_notifications': True, + 'default_view': 'grid', + 'theme': 'light', + 'expiring_soon_days': 30, + 'notification_frequency': 'daily', + 'notification_time': '09:00', + 'timezone': 'UTC', + 'notification_channel': 'email', + 'apprise_notification_time': '09:00', + 'apprise_notification_frequency': 'daily', + 'currency_symbol': '$', + 'date_format': 'MDY' + } return jsonify(preferences), 200 except Exception as e: @@ -2321,7 +2681,11 @@ def get_preferences(): 'notification_frequency': 'daily', 'notification_time': '09:00', 'timezone': 'UTC', - 'currency_symbol': '$' + 'notification_channel': 'email', + 'apprise_notification_time': '09:00', + 'apprise_notification_frequency': 'daily', + 'currency_symbol': '$', + 'date_format': 'MDY' } return jsonify(default_preferences), 200 finally: @@ -2337,7 +2701,11 @@ def get_preferences(): 'notification_frequency': 'daily', 'notification_time': '09:00', 'timezone': 'UTC', - 'currency_symbol': '$' + 'notification_channel': 'email', + 'apprise_notification_time': '09:00', + 'apprise_notification_frequency': 'daily', + 'currency_symbol': '$', + 'date_format': 'MDY' } return jsonify(default_preferences), 200 @@ -2349,16 +2717,27 @@ def update_preferences(): data = request.get_json() if not data: return jsonify({'message': 'No input data provided'}), 400 - email_notifications = data.get('email_notifications') + + # Debug logging for theme issue + logger.info(f"Update preferences input data: {data}") + + notification_channel = data.get('notification_channel') default_view = data.get('default_view') theme = data.get('theme') expiring_soon_days = data.get('expiring_soon_days') notification_frequency = data.get('notification_frequency') notification_time = data.get('notification_time') + apprise_notification_time = data.get('apprise_notification_time') + apprise_notification_frequency = data.get('apprise_notification_frequency') timezone = data.get('timezone') + apprise_timezone = data.get('apprise_timezone') currency_symbol = data.get('currency_symbol') date_format = data.get('date_format') + + logger.info(f"Update preferences parsed theme: {theme}") + if notification_channel and notification_channel not in ['none', 'email', 'apprise', 'both']: + return jsonify({'message': 'Invalid notification channel'}), 400 if default_view and default_view not in ['grid', 'list', 'table']: return jsonify({'message': 'Invalid default view'}), 400 if theme and theme not in ['light', 'dark']: @@ -2376,6 +2755,8 @@ def update_preferences(): return jsonify({'message': 'Invalid notification time format'}), 400 if timezone and not is_valid_timezone(timezone): return jsonify({'message': 'Invalid timezone'}), 400 + if apprise_timezone and not is_valid_timezone(apprise_timezone): + return jsonify({'message': 'Invalid Apprise timezone'}), 400 # Add validation for date_format valid_date_formats = ['MDY', 'DMY', 'YMD', 'MDY_WORDS', 'DMY_WORDS', 'YMD_WORDS'] @@ -2385,11 +2766,24 @@ def update_preferences(): conn = get_db_connection() cursor = conn.cursor() try: - # Check if date_format column exists first to handle dynamic schema changes - cursor.execute("SELECT column_name FROM information_schema.columns WHERE table_name='user_preferences' AND column_name='date_format'") - has_date_format_col = cursor.fetchone() is not None + # Check if columns exist first to handle dynamic schema changes + cursor.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name='user_preferences' + AND column_name IN ('date_format', 'notification_channel', 'apprise_notification_time', 'apprise_notification_frequency', 'apprise_timezone') + """) + existing_cols = [row[0] for row in cursor.fetchall()] + has_date_format_col = 'date_format' in existing_cols + has_notification_channel_col = 'notification_channel' in existing_cols + has_apprise_notification_time_col = 'apprise_notification_time' in existing_cols + has_apprise_notification_frequency_col = 'apprise_notification_frequency' in existing_cols has_currency = has_currency_symbol_column(cursor) + # Debug logging for Apprise settings + logger.info(f"Update preferences debug - apprise_notification_time: {apprise_notification_time}") + logger.info(f"Update preferences debug - has_apprise_notification_time_col: {has_apprise_notification_time_col}") + logger.info(f"Update preferences debug - existing_cols: {existing_cols}") + cursor.execute( "SELECT 1 FROM user_preferences WHERE user_id = %s", (user_id,) @@ -2399,9 +2793,9 @@ def update_preferences(): if preferences_exist: update_fields = [] update_values = [] - if email_notifications is not None: - update_fields.append("email_notifications = %s") - update_values.append(email_notifications) + if notification_channel and has_notification_channel_col: + update_fields.append("notification_channel = %s") + update_values.append(notification_channel) if default_view: update_fields.append("default_view = %s") update_values.append(default_view) @@ -2417,9 +2811,18 @@ def update_preferences(): if notification_time: update_fields.append("notification_time = %s") update_values.append(notification_time) + if apprise_notification_time is not None and has_apprise_notification_time_col: + update_fields.append("apprise_notification_time = %s") + update_values.append(apprise_notification_time) + if apprise_notification_frequency is not None and has_apprise_notification_frequency_col: + update_fields.append("apprise_notification_frequency = %s") + update_values.append(apprise_notification_frequency) if timezone: update_fields.append("timezone = %s") update_values.append(timezone) + if apprise_timezone and 'apprise_timezone' in existing_cols: + update_fields.append("apprise_timezone = %s") + update_values.append(apprise_timezone) if has_currency and currency_symbol is not None: update_fields.append("currency_symbol = %s") update_values.append(currency_symbol) @@ -2430,24 +2833,51 @@ def update_preferences(): if update_fields: # Build the returning fields string dynamically based on existing columns + # IMPORTANT: Always SELECT all fields, don't rely on UPDATE RETURNING subset return_fields_list = ["email_notifications", "default_view", "theme", "expiring_soon_days", "notification_frequency", "notification_time", "timezone"] + if has_notification_channel_col: + return_fields_list.append("notification_channel") + if has_apprise_notification_time_col: + return_fields_list.append("apprise_notification_time") + if has_apprise_notification_frequency_col: + return_fields_list.append("apprise_notification_frequency") + if 'apprise_timezone' in existing_cols: + return_fields_list.append("apprise_timezone") if has_currency: return_fields_list.append("currency_symbol") if has_date_format_col: return_fields_list.append("date_format") return_fields = ", ".join(return_fields_list) + # First do the UPDATE update_query = f""" UPDATE user_preferences SET {', '.join(update_fields)} WHERE user_id = %s - RETURNING {return_fields} """ cursor.execute(update_query, update_values + [user_id]) + + # Then SELECT all fields to get complete data + cursor.execute( + f""" + SELECT {return_fields} + FROM user_preferences + WHERE user_id = %s + """, + (user_id,) + ) preferences_data = cursor.fetchone() else: # If no fields to update, select existing data select_fields_list = ["email_notifications", "default_view", "theme", "expiring_soon_days", "notification_frequency", "notification_time", "timezone"] + if has_notification_channel_col: + select_fields_list.append("notification_channel") + if has_apprise_notification_time_col: + select_fields_list.append("apprise_notification_time") + if has_apprise_notification_frequency_col: + select_fields_list.append("apprise_notification_frequency") + if 'apprise_timezone' in existing_cols: + select_fields_list.append("apprise_timezone") if has_currency: select_fields_list.append("currency_symbol") if has_date_format_col: @@ -2465,10 +2895,9 @@ def update_preferences(): preferences_data = cursor.fetchone() else: # Insert new record # Build insert dynamically - insert_cols_list = ["user_id", "email_notifications", "default_view", "theme", "expiring_soon_days", "notification_frequency", "notification_time", "timezone"] + insert_cols_list = ["user_id", "default_view", "theme", "expiring_soon_days", "notification_frequency", "notification_time", "timezone"] insert_vals = [ user_id, - email_notifications if email_notifications is not None else True, default_view or 'grid', theme or 'light', expiring_soon_days if expiring_soon_days is not None else 30, @@ -2476,6 +2905,15 @@ def update_preferences(): notification_time or '09:00', timezone or 'UTC' ] + if has_notification_channel_col: + insert_cols_list.append("notification_channel") + insert_vals.append(notification_channel or 'email') + if has_apprise_notification_time_col: + insert_cols_list.append("apprise_notification_time") + insert_vals.append(apprise_notification_time or '09:00') + if has_apprise_notification_frequency_col: + insert_cols_list.append("apprise_notification_frequency") + insert_vals.append(apprise_notification_frequency or 'daily') if has_currency: insert_cols_list.append("currency_symbol") insert_vals.append(currency_symbol or '$') @@ -2499,19 +2937,36 @@ def update_preferences(): # Map returned data to dictionary using the dynamically determined fields returned_columns = [col.strip() for col in return_fields.split(',')] - pref_map = {col: i for i, col in enumerate(returned_columns)} + if preferences_data: + pref_map = {col: i for i, col in enumerate(returned_columns)} + + # Log debug info for theme issues + logger.info(f"Update preferences debug - returned_columns: {returned_columns}") + logger.info(f"Update preferences debug - pref_map: {pref_map}") + logger.info(f"Update preferences debug - preferences_data: {preferences_data}") + logger.info(f"Update preferences debug - theme column index: {pref_map.get('theme', 'NOT_FOUND')}") + + preferences = { + 'email_notifications': preferences_data[pref_map['email_notifications']] if 'email_notifications' in pref_map else True, + 'default_view': preferences_data[pref_map['default_view']] if 'default_view' in pref_map else 'grid', + 'theme': preferences_data[pref_map['theme']] if 'theme' in pref_map else 'light', + 'expiring_soon_days': preferences_data[pref_map['expiring_soon_days']] if 'expiring_soon_days' in pref_map else 30, + 'notification_frequency': preferences_data[pref_map['notification_frequency']] if 'notification_frequency' in pref_map else 'daily', + 'notification_time': preferences_data[pref_map['notification_time']] if 'notification_time' in pref_map else '09:00', + 'timezone': preferences_data[pref_map['timezone']] if 'timezone' in pref_map else 'UTC', + 'notification_channel': preferences_data[pref_map['notification_channel']] if 'notification_channel' in pref_map else 'email', + 'apprise_notification_time': preferences_data[pref_map['apprise_notification_time']] if 'apprise_notification_time' in pref_map else '09:00', + 'apprise_notification_frequency': preferences_data[pref_map['apprise_notification_frequency']] if 'apprise_notification_frequency' in pref_map else 'daily', + 'apprise_timezone': preferences_data[pref_map['apprise_timezone']] if 'apprise_timezone' in pref_map else 'UTC', + 'currency_symbol': preferences_data[pref_map['currency_symbol']] if 'currency_symbol' in pref_map else '$', + 'date_format': preferences_data[pref_map['date_format']] if 'date_format' in pref_map else 'MDY' + } + + logger.info(f"Update preferences debug - final theme value: {preferences.get('theme')}") + else: + # Fallback if no data returned + preferences = {} - preferences = { - 'email_notifications': preferences_data[pref_map.get('email_notifications', 0)], # Default index 0 might be risky, but needed if column is missing - 'default_view': preferences_data[pref_map.get('default_view', 1)], - 'theme': preferences_data[pref_map.get('theme', 2)], - 'expiring_soon_days': preferences_data[pref_map.get('expiring_soon_days', 3)], - 'notification_frequency': preferences_data[pref_map.get('notification_frequency', 4)], - 'notification_time': preferences_data[pref_map.get('notification_time', 5)], - 'timezone': preferences_data[pref_map.get('timezone', 6)], - 'currency_symbol': preferences_data[pref_map['currency_symbol']] if 'currency_symbol' in pref_map else '$', # Handle currency optionality - 'date_format': preferences_data[pref_map['date_format']] if 'date_format' in pref_map else 'MDY' # Handle date_format optionality - } conn.commit() return jsonify(preferences), 200 @@ -2519,7 +2974,7 @@ def update_preferences(): conn.rollback() logger.error(f"Database error in update_preferences: {str(e)}") fallback_preferences = { - 'email_notifications': email_notifications if email_notifications is not None else True, + 'email_notifications': True, # Fixed: Set default value instead of undefined variable 'default_view': default_view or 'grid', 'theme': theme or 'light', 'expiring_soon_days': expiring_soon_days if expiring_soon_days is not None else 30, @@ -2543,7 +2998,8 @@ def update_preferences(): 'notification_frequency': 'daily', 'notification_time': '09:00', 'timezone': 'UTC', - 'currency_symbol': '$' + 'currency_symbol': '$', + 'date_format': 'MDY' # Fixed: Added missing date_format to defaults } return jsonify(default_preferences), 200 @@ -2739,33 +3195,47 @@ def get_site_settings(): default_site_settings = { 'registration_enabled': 'true', 'email_base_url': os.environ.get('APP_BASE_URL', 'http://localhost:8080'), # Default to APP_BASE_URL + 'global_view_enabled': 'true', # Global warranty view feature + 'global_view_admin_only': 'false', # Restrict global view to admins only 'oidc_enabled': 'false', 'oidc_provider_name': 'oidc', 'oidc_client_id': '', # 'oidc_client_secret': '', # Not returned 'oidc_issuer_url': '', - 'oidc_scope': 'openid email profile' + 'oidc_scope': 'openid email profile', + # Apprise default settings + 'apprise_enabled': 'false', + 'apprise_urls': '', + 'apprise_expiration_days': '7,30', + 'apprise_notification_time': '09:00', + 'apprise_title_prefix': '[Warracker]' } settings_to_return = {} needs_commit = False + # First, add all existing settings from the database + for key, value in raw_settings.items(): + # Skip returning the client secret directly + if key == 'oidc_client_secret': + continue + + # For boolean-like string settings, ensure they are 'true' or 'false' + if key in ['registration_enabled', 'oidc_enabled', 'apprise_enabled', 'global_view_enabled', 'global_view_admin_only']: + settings_to_return[key] = 'true' if value.lower() == 'true' else 'false' + else: + settings_to_return[key] = value + + # Then, add defaults for any missing settings for key, default_value in default_site_settings.items(): - if key not in raw_settings: + if key not in settings_to_return and key != 'oidc_client_secret': settings_to_return[key] = default_value # Insert default if missing (except for secret) - if key != 'oidc_client_secret': # Avoid writing default empty secret if not present - cur.execute( - 'INSERT INTO site_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO NOTHING', - (key, default_value) - ) - needs_commit = True - else: - # For boolean-like string settings, ensure they are 'true' or 'false' - if key in ['registration_enabled', 'oidc_enabled']: - settings_to_return[key] = 'true' if raw_settings[key].lower() == 'true' else 'false' - else: - settings_to_return[key] = raw_settings[key] + cur.execute( + 'INSERT INTO site_settings (key, value) VALUES (%s, %s) ON CONFLICT (key) DO NOTHING', + (key, default_value) + ) + needs_commit = True # Indicate if OIDC client secret is set without revealing it settings_to_return['oidc_client_secret_set'] = bool(raw_settings.get('oidc_client_secret')) @@ -2815,7 +3285,7 @@ def update_site_settings(): requires_restart = False for key, value in data.items(): # Sanitize boolean-like string values - if key in ['registration_enabled', 'oidc_enabled']: + if key in ['registration_enabled', 'oidc_enabled', 'global_view_enabled', 'global_view_admin_only']: value = 'true' if str(value).lower() == 'true' else 'false' # Check if it's an OIDC related key that requires restart @@ -2868,6 +3338,55 @@ def update_site_settings(): release_db_connection(conn) # Modify the register endpoint to check if registration is enabled +@app.route('/api/settings/global-view-status', methods=['GET']) +@token_required +def check_global_view_status(): + """Check if global view is enabled for the current user""" + conn = None + try: + user_is_admin = request.user.get('is_admin', False) + + conn = get_db_connection() + with conn.cursor() as cur: + # Check if settings table exists + cur.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'site_settings' + ) + """) + table_exists = cur.fetchone()[0] + + if not table_exists: + # If table doesn't exist, global view is enabled by default + return jsonify({"enabled": True}), 200 + + # 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({"enabled": False}), 200 + + # 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({"enabled": False}), 200 + + # Global view is enabled for this user + return jsonify({"enabled": True}), 200 + + except Exception as e: + logger.error(f"Error checking global view status: {e}") + # Default to enabled on error for admins, disabled for non-admins + user_is_admin = request.user.get('is_admin', False) + return jsonify({"enabled": user_is_admin}), 500 + finally: + if conn: + release_db_connection(conn) + @app.route('/api/auth/registration-status', methods=['GET']) def check_registration_status(): """Check if registration is enabled""" @@ -2948,9 +3467,9 @@ def secure_file_access(filename): query = """ SELECT w.id, w.user_id FROM warranties w - WHERE w.invoice_path = %s OR w.manual_path = %s OR w.other_document_path = %s + WHERE w.invoice_path = %s OR w.manual_path = %s OR w.other_document_path = %s OR w.product_photo_path = %s """ - cur.execute(query, (db_search_path, db_search_path, db_search_path)) + cur.execute(query, (db_search_path, db_search_path, db_search_path, db_search_path)) results = cur.fetchall() logger.info(f"[SECURE_FILE] DB query results for '{db_search_path}': {results}") @@ -3060,141 +3579,9 @@ def secure_file_access(filename): logger.error(f"[SECURE_FILE] Error in secure file access for '{filename}' (repr: {repr(filename)}): {e}", exc_info=True) return jsonify({"message": "Error accessing file"}), 500 -def get_expiring_warranties(): - """Get warranties that are expiring soon for notification purposes""" - conn = None - try: - conn = get_db_connection() - today = date.today() - with conn.cursor() as cur: - cur.execute(""" - SELECT - u.id, -- Select user_id - u.email, - u.first_name, - w.product_name, - w.expiration_date, - COALESCE(up.expiring_soon_days, 30) AS expiring_soon_days - FROM - warranties w - JOIN - users u ON w.user_id = u.id - LEFT JOIN - user_preferences up ON u.id = up.user_id - WHERE - w.is_lifetime = FALSE - AND w.expiration_date > %s - AND w.expiration_date <= (%s::date + (COALESCE(up.expiring_soon_days, 30) || ' days')::interval)::date - AND u.is_active = TRUE - AND COALESCE(up.email_notifications, TRUE) = TRUE; - """, (today, today)) - expiring_warranties = [] - for row in cur.fetchall(): - user_id, email, first_name, product_name, expiration_date, expiring_soon_days = row # Include user_id - expiration_date_str = expiration_date.strftime('%Y-%m-%d') - expiring_warranties.append({ - 'user_id': user_id, # Add user_id to the dict - 'email': email, - 'first_name': first_name or 'User', # Default if first_name is NULL - 'product_name': product_name, - 'expiration_date': expiration_date_str, - }) - return expiring_warranties - - except Exception as e: - logger.error(f"Error retrieving expiring warranties: {e}") - return [] # Return an empty list on error - finally: - if conn: - release_db_connection(conn) - -def format_expiration_email(user, warranties): - """ - Format an email notification for expiring warranties. - Returns a MIMEMultipart email object with both text and HTML versions. - """ - subject = "Warracker: Upcoming Warranty Expirations" - - # Get email base URL from settings - conn = None - email_base_url = 'http://localhost:8080' # Default fallback - try: - conn = get_db_connection() - with conn.cursor() as cur: - cur.execute("SELECT value FROM site_settings WHERE key = 'email_base_url'") - result = cur.fetchone() - if result: - email_base_url = result[0] - else: - logger.warning("email_base_url setting not found, using default.") - except Exception as e: - logger.error(f"Error fetching email_base_url from settings: {e}. Using default.") - finally: - if conn: - release_db_connection(conn) - - # Ensure base URL doesn't end with a slash - email_base_url = email_base_url.rstrip('/') - - # Create both plain text and HTML versions of the email body - text_body = f"Hello {user['first_name']},\\n\\n" - text_body += "The following warranties are expiring soon:\\n\\n" - - html_body = f"""\ - -
- -Hello {user['first_name']},
-The following warranties are expiring soon:
-| Product Name | -Expiration Date | -
|---|---|
| {warranty['product_name']} | -{warranty['expiration_date']} | -
Log in to Warracker to view details.
-Manage your notification settings here.
- - - """ - - # Create a MIMEMultipart object for both text and HTML - msg = MIMEMultipart('alternative') - msg['Subject'] = subject - msg['From'] = os.environ.get('SMTP_USERNAME', 'notifications@warracker.com') - msg['To'] = user['email'] - - part1 = MIMEText(text_body, 'plain') - part2 = MIMEText(html_body, 'html') - - msg.attach(part1) - msg.attach(part2) - - return msg def send_password_reset_email(recipient_email, reset_link): """Sends the password reset email.""" @@ -3330,281 +3717,28 @@ The Warracker Team except Exception as e: logger.error(f"Error quitting SMTP connection: {e}") -# Create a lock for the notification function -notification_lock = threading.Lock() -# Track when notifications were last sent to each user -last_notification_sent = {} -def send_expiration_notifications(manual_trigger=False): - """ - Main function to send warranty expiration notifications. - Retrieves expiring warranties, groups them by user, and sends emails. - - Args: - manual_trigger (bool): Whether this function was triggered manually (vs scheduled) - If True, it ignores notification frequency/time preferences - """ - # Use a lock to prevent concurrent executions - if not notification_lock.acquire(blocking=False): - logger.info("Notification job already running, skipping this execution") - return - - try: - logger.info("Starting expiration notification process") - - # If not manually triggered, check if notifications should be sent today based on preferences - if not manual_trigger: - conn = None - try: - conn = get_db_connection() - with conn.cursor() as cur: - # Get today's date and current time in UTC - utc_now = datetime.utcnow() - - # Get user IDs that should receive notifications today - eligible_users_query = """ - SELECT - u.id, - u.email, - u.first_name, - up.notification_time, - up.timezone, - up.notification_frequency - FROM users u - JOIN user_preferences up ON u.id = up.user_id - WHERE u.is_active = TRUE - AND up.email_notifications = TRUE - """ - cur.execute(eligible_users_query) - eligible_users = cur.fetchall() - - if not eligible_users: - logger.info("No users are eligible for notifications") - return - - # Check if we should send notifications based on time and timezone - users_to_notify_now = set() # Changed variable name and type to set - for user in eligible_users: - user_id, email, first_name, notification_time, timezone, frequency = user - - try: - # Convert UTC time to user's timezone - user_tz = pytz_timezone(timezone or 'UTC') - user_local_time = utc_now.replace(tzinfo=pytz.UTC).astimezone(user_tz) - - # Check if notification should be sent based on frequency - should_send = False - - if frequency == 'daily': - should_send = True - elif frequency == 'weekly' and user_local_time.weekday() == 0: # Monday - should_send = True - elif frequency == 'monthly' and user_local_time.day == 1: - should_send = True - - if should_send: - # Parse notification time - time_hour, time_minute = map(int, notification_time.split(':')) - - # Get current hour and minute in user's timezone - current_hour = user_local_time.hour - current_minute = user_local_time.minute - - # Calculate minutes difference - user_minutes = time_hour * 60 + time_minute - current_minutes = current_hour * 60 + current_minute - - # Calculate exact time difference (can be negative if current time is before notification time) - time_diff = current_minutes - user_minutes - - # For notifications, we want to send: - # 1. If current time is 0-2 minutes after scheduled time (11:27 → send between 11:27-11:29) - # 2. Or, if the next scheduler run would miss the time (scheduler runs every 5 min) - # For example, if it's 11:24 and notification is set for 11:27, next run is 11:29 so we should send now - send_window = time_diff >= 0 and time_diff <= 2 # 0-2 minutes after scheduled time - next_miss_window = time_diff < 0 and time_diff >= -3 # 1-3 minutes before scheduled time - - logger.info(f"Time check for {email}: scheduled={time_hour}:{time_minute:02d}, " + - f"current={current_hour}:{current_minute:02d}, diff={time_diff} min, " + - f"send_window={send_window}, next_miss_window={next_miss_window}") - - if send_window or next_miss_window: - # Check if we've already sent a notification to this user recently - now_timestamp = int(utc_now.timestamp()) - if email in last_notification_sent: - last_sent = last_notification_sent[email] - # Only send if it's been more than 10 minutes since the last notification - # (longer than the 5-minute scheduler interval) - if now_timestamp - last_sent > 600: - users_to_notify_now.add(user_id) # Add user_id to the set - logger.info(f"User {email} eligible for notification at their local time {notification_time} ({timezone})") - else: - logger.info(f"Skipping notification for {email} - already sent within the last 10 minutes (last sent: {datetime.fromtimestamp(last_sent).strftime('%Y-%m-%d %H:%M:%S')})") - else: - users_to_notify_now.add(user_id) # Add user_id to the set - logger.info(f"User {email} eligible for notification at their local time {notification_time} ({timezone})") - - except Exception as e: - logger.error(f"Error processing timezone for user {email}: {e}") - continue - - if not users_to_notify_now: # Check the set now - logger.info("No users are scheduled for notifications at their local time") - return - - logger.info(f"Found {len(users_to_notify_now)} users eligible for notifications now") - except Exception as e: - logger.error(f"Error determining notification eligibility: {e}") - return - finally: - if conn: - release_db_connection(conn) - - expiring_warranties = get_expiring_warranties() - if not expiring_warranties: - logger.info("No expiring warranties found.") - return - # Group warranties by user - users_warranties = {} - for warranty in expiring_warranties: - user_id = warranty['user_id'] # Get user_id - email = warranty['email'] - if email not in users_warranties: - users_warranties[email] = { - 'user_id': user_id, # Store user_id - 'first_name': warranty['first_name'], - 'warranties': [] - } - users_warranties[email]['warranties'].append(warranty) - - # Get SMTP settings from environment variables with fallbacks - smtp_host = os.environ.get('SMTP_HOST', 'localhost') - smtp_port = int(os.environ.get('SMTP_PORT', '1025')) - smtp_username = os.environ.get('SMTP_USERNAME', 'notifications@warracker.com') - smtp_password = os.environ.get('SMTP_PASSWORD', '') - - # Explicit SMTP_USE_TLS from environment, defaulting to true if port is 587 - # and not explicitly set to false. - smtp_use_tls_env = os.environ.get('SMTP_USE_TLS', 'not_set').lower() - - # Connect to SMTP server - try: - logger.info(f"Attempting SMTP connection to {smtp_host}:{smtp_port}") - if smtp_port == 465: - logger.info("Using SMTP_SSL for port 465.") - server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) - else: - logger.info(f"Using SMTP for port {smtp_port}.") - server = smtplib.SMTP(smtp_host, smtp_port, timeout=10) - # For port 587, STARTTLS is standard. - # For other ports, allow SMTP_USE_TLS to explicitly enable/disable it. - # If SMTP_USE_TLS is 'not_set', default to True for port 587. - should_use_starttls = False - if smtp_port == 587: - should_use_starttls = (smtp_use_tls_env != 'false') # True unless explicitly 'false' - logger.info(f"Port is 587. SMTP_USE_TLS set to '{smtp_use_tls_env}'. should_use_starttls: {should_use_starttls}") - elif smtp_use_tls_env == 'true': - should_use_starttls = True - logger.info(f"Port is {smtp_port}. SMTP_USE_TLS explicitly 'true'. should_use_starttls: {should_use_starttls}") - else: - logger.info(f"Port is {smtp_port}. SMTP_USE_TLS set to '{smtp_use_tls_env}'. should_use_starttls: {should_use_starttls}") +# Initialize notification system +# Set up the Apprise handler if available +if APPRISE_AVAILABLE and apprise_handler is not None: + notifications.set_apprise_handler(apprise_handler) - if should_use_starttls: - logger.info("Attempting to start TLS (server.starttls()).") - server.starttls() - logger.info("STARTTLS successful.") - else: - logger.info("Not using STARTTLS based on port and SMTP_USE_TLS setting.") - - # Login if credentials are provided - if smtp_username and smtp_password: - logger.info(f"Logging in with username: {smtp_username}") - server.login(smtp_username, smtp_password) - logger.info("SMTP login successful.") +# Initialize notification scheduler +notifications.init_scheduler(get_db_connection, release_db_connection) - # Send emails to each user - utc_now = datetime.utcnow() - timestamp = int(utc_now.timestamp()) - - emails_sent = 0 - for email, user_data in users_warranties.items(): - # ---> ADD CHECK HERE <----- - user_id_to_check = user_data.get('user_id') - if not manual_trigger and user_id_to_check not in users_to_notify_now: - logger.debug(f"Skipping email for {email} (user_id: {user_id_to_check}) - not in current notification window.") - continue # Skip sending if not in the set for scheduled notifications - - # For manual triggers, check if we've sent recently - if manual_trigger and email in last_notification_sent: - last_sent = last_notification_sent[email] - # Only allow manual trigger to bypass the time limit if it's been more than 2 minutes - # This prevents accidental double-clicks by admins - if timestamp - last_sent < 120: - logger.info(f"Manual trigger: Skipping notification for {email} - already sent within the last 2 minutes") - continue - - msg = format_expiration_email( - {'first_name': user_data['first_name'], 'email': email}, - user_data['warranties'] - ) - try: - server.sendmail(smtp_username, email, msg.as_string()) - # Record timestamp when we sent the notification - last_notification_sent[email] = timestamp - emails_sent += 1 - logger.info(f"Expiration notification email sent to {email} for {len(user_data['warranties'])} warranties at {datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')}") - except Exception as e: - logger.error(f"Error sending email to {email}: {e}") - - logger.info(f"Email notification process completed. Sent {emails_sent} emails out of {len(users_warranties)} eligible users.") - # Close the server connection - server.quit() - - except Exception as e: - logger.error(f"Error connecting to SMTP server: {e}") - logger.error(f"SMTP details - Host: {smtp_host}, Port: {smtp_port}, Username: {smtp_username}") - - except Exception as e: - logger.error(f"Error in send_expiration_notifications: {e}") - finally: - notification_lock.release() - -# Initialize scheduler -scheduler = BackgroundScheduler( - job_defaults={ - 'coalesce': True, # Combine multiple executions into one - 'max_instances': 1, # Only allow one instance of the job to run at a time - 'misfire_grace_time': 300 # Allow 5 minutes for a misfired job (increased from 60 seconds) - } -) - -# Helper to check if this is the main process that should run the scheduler -def should_run_scheduler(): - # For gunicorn - if os.environ.get('GUNICORN_WORKER_PROCESS_NAME') == 'worker-0' or \ - (os.environ.get('GUNICORN_WORKER_CLASS') and int(os.environ.get('GUNICORN_WORKER_ID', '0')) == 0): - logger.info("Starting scheduler in Gunicorn worker 0") - return True - # For development server - elif __name__ == '__main__': - logger.info("Starting scheduler in development server") - return True - # Default case - don't start scheduler - return False - -# Only start the scheduler in the main process, not in workers -if should_run_scheduler(): - # Check for scheduled notifications every 2 minutes for more precise timing - scheduler.add_job(func=send_expiration_notifications, trigger="interval", minutes=2, id='notification_job') - scheduler.start() - logger.info("Email notification scheduler started - checking every 2 minutes") - - # Add a shutdown hook - atexit.register(lambda: scheduler.shutdown()) +# Hook to ensure scheduler is initialized on request if needed +@app.before_request +def ensure_scheduler_initialized(): + """Ensure scheduler is initialized on the first request if it wasn't at startup""" + notifications.ensure_scheduler_initialized(get_db_connection, release_db_connection) # Initialize the database when the application starts -if __name__ != '__main__': # Only for production +if __name__ == '__main__': + # Call init_db to ensure tables are created before app starts in standalone mode. + # In production with Gunicorn/multiple workers, this should ideally be handled by a startup script/migration tool. + # For simplicity in development or single-worker setups, it's here. + logger.info("Running in __main__, attempting to initialize database...") try: init_db() logger.info("Database initialized during application startup") @@ -3620,13 +3754,26 @@ def trigger_notifications(): """ try: logger.info(f"Manual notification trigger requested by admin user {request.user['id']}") - send_expiration_notifications(manual_trigger=True) - return jsonify({'message': 'Notifications triggered successfully'}), 200 + result, status_code = notifications.trigger_notifications_manually(get_db_connection, release_db_connection) + return jsonify(result), status_code except Exception as e: error_msg = f"Error triggering notifications: {str(e)}" logger.error(error_msg) return jsonify({'message': 'Failed to trigger notifications', 'error': error_msg}), 500 +@app.route('/api/admin/scheduler-status', methods=['GET']) +@admin_required +def get_scheduler_status(): + """ + Admin-only endpoint to check scheduler status and configuration. + """ + try: + status = notifications.get_scheduler_status() + return jsonify(status), 200 + except Exception as e: + logger.error(f"Error getting scheduler status: {e}") + return jsonify({'error': f'Failed to get scheduler status: {str(e)}'}), 500 + @app.route('/api/timezones', methods=['GET']) def get_timezones(): """Get list of all available timezones""" @@ -3688,15 +3835,15 @@ def debug_warranty(warranty_id): if is_admin: cur.execute(''' SELECT id, product_name, purchase_date, expiration_date, invoice_path, manual_path, other_document_path, product_url, notes, - purchase_price, user_id, created_at, updated_at, is_lifetime, vendor, - warranty_duration_years, warranty_duration_months, warranty_duration_days + purchase_price, user_id, created_at, updated_at, is_lifetime, vendor, warranty_type, + warranty_duration_years, warranty_duration_months, warranty_duration_days, product_photo_path FROM warranties WHERE id = %s ''', (warranty_id,)) else: cur.execute(''' SELECT id, product_name, purchase_date, expiration_date, invoice_path, manual_path, other_document_path, product_url, notes, - purchase_price, user_id, created_at, updated_at, is_lifetime, vendor, - warranty_duration_years, warranty_duration_months, warranty_duration_days + purchase_price, user_id, created_at, updated_at, is_lifetime, vendor, warranty_type, + warranty_duration_years, warranty_duration_months, warranty_duration_days, product_photo_path FROM warranties WHERE id = %s AND user_id = %s ''', (warranty_id, user_id)) @@ -4109,7 +4256,7 @@ def add_tags_to_warranty(warranty_id): # Updated CSV Headers for duration components REQUIRED_CSV_HEADERS = ['ProductName', 'PurchaseDate'] OPTIONAL_CSV_HEADERS = [ - 'IsLifetime', 'PurchasePrice', 'SerialNumber', 'ProductURL', 'Tags', 'Vendor', + 'IsLifetime', 'PurchasePrice', 'SerialNumber', 'ProductURL', 'Tags', 'Vendor', 'WarrantyType', 'WarrantyDurationYears', 'WarrantyDurationMonths', 'WarrantyDurationDays' ] @@ -4176,6 +4323,7 @@ def import_warranties(): product_url = row.get('ProductURL', '').strip() tags_str = row.get('Tags', '').strip() # Get Tags string vendor = row.get('Vendor', '').strip() # Extract Vendor + warranty_type = row.get('WarrantyType', '').strip() # Extract Warranty Type if not product_name: errors.append("ProductName is required.") @@ -4318,14 +4466,14 @@ def import_warranties(): cur.execute(""" INSERT INTO warranties ( product_name, purchase_date, expiration_date, - product_url, purchase_price, user_id, is_lifetime, vendor, + product_url, purchase_price, user_id, is_lifetime, vendor, warranty_type, warranty_duration_years, warranty_duration_months, warranty_duration_days ) - VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) RETURNING id """, ( product_name, purchase_date, expiration_date, - product_url, purchase_price, user_id, is_lifetime, vendor, + product_url, purchase_price, user_id, is_lifetime, vendor, warranty_type, warranty_duration_years, warranty_duration_months, warranty_duration_days )) warranty_id = cur.fetchone()[0] @@ -4809,32 +4957,353 @@ def get_oidc_status(): if conn: release_db_connection(conn) +# ===================== +# APPRISE NOTIFICATION ROUTES +# ===================== + +@app.route('/api/admin/apprise/test', methods=['POST']) +@admin_required +def test_apprise_notification(): + """Send a test Apprise notification""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({'success': False, 'message': 'Apprise notifications are not available'}), 503 + + try: + data = request.get_json() + test_url = data.get('test_url') if data else None + + success = apprise_handler.send_test_notification(test_url) + + if success: + return jsonify({'success': True, 'message': 'Test notification sent successfully'}), 200 + else: + return jsonify({'success': False, 'message': 'Failed to send test notification'}), 400 + + except Exception as e: + logger.error(f"Error sending test Apprise notification: {e}") + return jsonify({'success': False, 'message': f'Error: {str(e)}'}), 500 + +@app.route('/api/admin/apprise/validate-url', methods=['POST']) +@admin_required +def validate_apprise_url(): + """Validate an Apprise notification URL""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({'valid': False, 'message': 'Apprise notifications are not available'}), 503 + + try: + data = request.get_json() + url = data.get('url') + + if not url: + return jsonify({'valid': False, 'message': 'URL is required'}), 400 + + is_valid = apprise_handler.validate_url(url) + + return jsonify({ + 'valid': is_valid, + 'message': 'URL is valid' if is_valid else 'URL is invalid or unsupported' + }), 200 + + except Exception as e: + logger.error(f"Error validating Apprise URL: {e}") + return jsonify({'valid': False, 'message': f'Error: {str(e)}'}), 500 + +@app.route('/api/admin/apprise/supported-services', methods=['GET']) +@admin_required +def get_supported_apprise_services(): + """Get list of supported Apprise services""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({'services': [], 'message': 'Apprise notifications are not available'}), 503 + + try: + services = apprise_handler.get_supported_services() + return jsonify({'services': services}), 200 + + except Exception as e: + logger.error(f"Error getting supported Apprise services: {e}") + return jsonify({'services': [], 'message': f'Error: {str(e)}'}), 500 + +@app.route('/api/admin/apprise/send-expiration', methods=['POST']) +@admin_required +def trigger_apprise_expiration_notifications(): + """Manually trigger Apprise expiration notifications""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({'success': False, 'message': 'Apprise notifications are not available'}), 503 + + try: + results = apprise_handler.send_expiration_notifications() + + return jsonify({ + 'success': True, + 'message': f'Notifications processed: {results["sent"]} sent, {results["errors"]} errors', + 'results': results + }), 200 + + except Exception as e: + logger.error(f"Error triggering Apprise expiration notifications: {e}") + return jsonify({'success': False, 'message': f'Error: {str(e)}'}), 500 + +@app.route('/api/admin/apprise/reload-config', methods=['POST']) +@admin_required +def reload_apprise_configuration(): + """Reload Apprise configuration from database and environment""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({'success': False, 'message': 'Apprise notifications are not available'}), 503 + + try: + apprise_handler.reload_configuration() + + return jsonify({ + 'success': True, + 'message': 'Apprise configuration reloaded successfully', + 'enabled': apprise_handler.enabled, + 'urls_configured': len(apprise_handler.notification_urls) + }), 200 + + except Exception as e: + logger.error(f"Error reloading Apprise configuration: {e}") + return jsonify({'success': False, 'message': f'Error: {str(e)}'}), 500 + +@app.route('/api/admin/apprise/send-custom', methods=['POST']) +@admin_required +def send_custom_apprise_notification(): + """Send a custom Apprise notification""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({'success': False, 'message': 'Apprise notifications are not available'}), 503 + + try: + data = request.get_json() + title = data.get('title') + message = data.get('message') + urls = data.get('urls') # Optional: specific URLs to send to + + if not title or not message: + return jsonify({'success': False, 'message': 'Title and message are required'}), 400 + + success = apprise_handler.send_custom_notification(title, message, urls) + + if success: + return jsonify({'success': True, 'message': 'Custom notification sent successfully'}), 200 + else: + return jsonify({'success': False, 'message': 'Failed to send custom notification'}), 400 + + except Exception as e: + logger.error(f"Error sending custom Apprise notification: {e}") + return jsonify({'success': False, 'message': f'Error: {str(e)}'}), 500 + +@app.route('/api/admin/apprise/status', methods=['GET']) +@admin_required +def get_apprise_status(): + """Get current Apprise configuration status""" + if not APPRISE_AVAILABLE or apprise_handler is None: + return jsonify({ + 'available': False, + 'enabled': False, + 'message': 'Apprise library is not installed or not available' + }), 503 + + try: + # Get detailed status from the handler + status = apprise_handler.get_status() + + # Add additional fields for backward compatibility + status.update({ + 'expiration_days': apprise_handler.expiration_days, + 'notification_time': apprise_handler.notification_time, + 'title_prefix': apprise_handler.title_prefix, + 'message': 'Apprise is available and configured' if status.get('available') else status.get('error', 'Unknown error') + }) + + return jsonify(status), 200 + + except Exception as e: + logger.error(f"Error getting Apprise status: {e}") + return jsonify({ + 'available': False, + 'enabled': False, + 'message': f'Error: {str(e)}' + }), 500 + +@app.route('/api/admin/warranties', methods=['GET']) +@admin_required +def get_all_warranties(): + """Get all warranties from all users (admin only)""" + conn = None + try: + conn = get_db_connection() + with conn.cursor() as cur: + # Get all warranties from all users with user information + 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, + u.username, u.email, u.first_name, u.last_name + FROM warranties w + JOIN users u ON w.user_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 + ''') + + 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 all warranties: {e}") + return jsonify({"error": "Failed to retrieve all warranties"}), 500 + finally: + if conn: + release_db_connection(conn) + +@app.route('/api/warranties/global', methods=['GET']) +@token_required +def get_global_warranties(): + """Get all 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 all warranties from all users with user information + 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, + u.username, u.email, u.first_name, u.last_name + FROM warranties w + JOIN users u ON w.user_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 + ''') + + 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 global warranties: {e}") + return jsonify({"error": "Failed to retrieve global warranties"}), 500 + finally: + if conn: + release_db_connection(conn) + # Register Blueprints -from backend.oidc_handler import oidc_bp +try: + # Try Docker environment path first + from backend.oidc_handler import oidc_bp +except ImportError: + # Fallback to development path + from oidc_handler import oidc_bp + app.register_blueprint(oidc_bp, url_prefix='/api') -# SCHEDULER SETUP -if should_run_scheduler(): - scheduler = BackgroundScheduler(daemon=True) - # Schedule the job to run daily at a specific time (e.g., 2 AM server time) - # The actual notification time for users will be based on their preference + timezone. - scheduler.add_job(send_expiration_notifications, 'cron', hour=2, minute=0) - scheduler.start() - logger.info("Background scheduler started for expiration notifications.") - # Ensure the scheduler shuts down when the app exits - atexit.register(lambda: scheduler.shutdown()) -else: - logger.info("Scheduler not started. API calls will be needed to trigger notifications.") - - -if __name__ == '__main__': - # Call init_db to ensure tables are created before app starts in standalone mode. - # In production with Gunicorn/multiple workers, this should ideally be handled by a startup script/migration tool. - # For simplicity in development or single-worker setups, it's here. - logger.info("Running in __main__, attempting to initialize database...") - try: - init_db() # Initialize the DB schema if needed - except Exception as e: - logger.error(f"Error during init_db() in __main__: {e}. Continuing without guaranteed DB init.") - - app.run(host='0.0.0.0', port=int(os.environ.get("FLASK_RUN_PORT", 5000)), debug=os.environ.get("FLASK_DEBUG", "true").lower() == "true") +# Note: Scheduler is already set up earlier in the file with 2-minute intervals +# The main scheduler setup is around line 3642-3655 with precise user-timezone timing +# This duplicate scheduler setup has been removed to prevent conflicts diff --git a/backend/apprise_handler.py b/backend/apprise_handler.py new file mode 100644 index 0000000..a93ada1 --- /dev/null +++ b/backend/apprise_handler.py @@ -0,0 +1,385 @@ +""" +Apprise Notification Handler for Warracker +Handles sending notifications via Apprise for warranty expirations and other events +""" + +import os +import logging +from datetime import datetime, timedelta +from typing import List, Dict, Optional + +# Global flag to track Apprise availability +APPRISE_AVAILABLE = False +apprise = None + +# Try to import apprise with detailed error handling +try: + import apprise + APPRISE_AVAILABLE = True + print("✅ Apprise successfully imported") +except ImportError as e: + print(f"❌ Failed to import apprise: {e}") + print(" Apprise notifications will be disabled") +except Exception as e: + print(f"❌ Unexpected error importing apprise: {e}") + print(" Apprise notifications will be disabled") + +# Import database functions with fallback +DB_FUNCTIONS_IMPORTED = False +try: + # Try backend.db_handler first (Docker environment) + from backend.db_handler import get_site_setting, get_expiring_warranties + DB_FUNCTIONS_IMPORTED = True + print("✅ Database functions imported from backend.db_handler") +except ImportError: + try: + # Fallback to db_handler (development environment) + from db_handler import get_site_setting, get_expiring_warranties + DB_FUNCTIONS_IMPORTED = True + print("✅ Database functions imported from db_handler") + except ImportError as e: + print(f"❌ Failed to import database functions: {e}") + print(" Creating dummy functions - expiration notifications will not work") + # Create dummy functions to prevent app crash + def get_site_setting(key, default=None): + return default + def get_expiring_warranties(days): + print(f"⚠️ Dummy get_expiring_warranties called with days={days} - returning empty list") + return [] + +logger = logging.getLogger(__name__) + +class AppriseNotificationHandler: + def __init__(self): + self.apprise_obj = None + self.enabled = False + self.notification_urls = [] + self.expiration_days = [7, 30] + self.notification_time = "09:00" + self.title_prefix = "[Warracker]" + + # Only initialize if Apprise is available + if APPRISE_AVAILABLE: + try: + self._load_configuration() + logger.info("Apprise notification handler initialized successfully") + except Exception as e: + logger.error(f"Failed to initialize Apprise notification handler: {e}") + self.enabled = False + else: + logger.warning("Apprise not available - notifications disabled") + + def _load_configuration(self): + """Load Apprise configuration from database and environment variables""" + if not APPRISE_AVAILABLE: + logger.warning("Apprise not available, configuration loading skipped") + return + + try: + # Load from database first + self.enabled = get_site_setting('apprise_enabled', 'false').lower() == 'true' + urls_str = get_site_setting('apprise_urls', '') + expiration_days_str = get_site_setting('apprise_expiration_days', '7,30') + self.notification_time = get_site_setting('apprise_notification_time', '09:00') + self.title_prefix = get_site_setting('apprise_title_prefix', '[Warracker]') + + # Parse notification URLs + if urls_str: + self.notification_urls = [url.strip() for url in urls_str.split(',') if url.strip()] + + # Parse expiration days + if expiration_days_str: + try: + self.expiration_days = [int(day.strip()) for day in expiration_days_str.split(',') if day.strip()] + except ValueError: + logger.warning(f"Invalid expiration days format: {expiration_days_str}, using defaults") + self.expiration_days = [7, 30] + + # Override with environment variables if present + env_enabled = os.getenv('APPRISE_ENABLED') + if env_enabled: + self.enabled = env_enabled.lower() == 'true' + + env_urls = os.getenv('APPRISE_URLS') + if env_urls: + self.notification_urls = [url.strip() for url in env_urls.split(',') if url.strip()] + + env_days = os.getenv('APPRISE_EXPIRATION_DAYS') + if env_days: + try: + self.expiration_days = [int(day.strip()) for day in env_days.split(',') if day.strip()] + except ValueError: + logger.warning(f"Invalid environment expiration days: {env_days}") + + env_time = os.getenv('APPRISE_NOTIFICATION_TIME') + if env_time: + self.notification_time = env_time + + env_prefix = os.getenv('APPRISE_TITLE_PREFIX') + if env_prefix: + self.title_prefix = env_prefix + + # Initialize Apprise object if enabled + if self.enabled and self.notification_urls: + self._initialize_apprise() + + logger.info(f"Apprise configuration loaded: enabled={self.enabled}, urls_count={len(self.notification_urls)}") + + except Exception as e: + logger.error(f"Error loading Apprise configuration: {e}") + self.enabled = False + + def _initialize_apprise(self): + """Initialize the Apprise object with configured URLs""" + if not APPRISE_AVAILABLE: + logger.warning("Apprise not available, initialization skipped") + return + + try: + self.apprise_obj = apprise.Apprise() + + for url in self.notification_urls: + if url: + result = self.apprise_obj.add(url) + if result: + logger.info(f"Successfully added Apprise URL: {url[:20]}...") + else: + logger.error(f"Failed to add Apprise URL: {url[:20]}...") + + if len(self.apprise_obj) == 0: + logger.warning("No valid Apprise URLs configured") + self.enabled = False + + except Exception as e: + logger.error(f"Error initializing Apprise: {e}") + self.enabled = False + + def is_available(self): + """Check if Apprise is available and properly configured""" + return APPRISE_AVAILABLE and self.enabled and self.apprise_obj is not None + + def get_status(self): + """Get detailed status information for debugging""" + if not APPRISE_AVAILABLE: + return { + "available": False, + "error": "Apprise library not installed or import failed", + "urls_configured": 0, + "enabled": False + } + + return { + "available": True, + "enabled": self.enabled, + "urls_configured": len(self.notification_urls), + "apprise_object_ready": self.apprise_obj is not None, + "notification_time": self.notification_time, + "expiration_days": self.expiration_days + } + + def reload_configuration(self): + """Reload configuration from database/environment""" + if APPRISE_AVAILABLE: + self._load_configuration() + else: + logger.warning("Cannot reload configuration - Apprise not available") + + def send_test_notification(self, test_url: Optional[str] = None) -> bool: + """Send a test notification to verify configuration""" + if not APPRISE_AVAILABLE: + logger.error("Cannot send test notification - Apprise not available") + return False + + try: + if test_url: + # Use specific test URL + test_apprise = apprise.Apprise() + if not test_apprise.add(test_url): + logger.error(f"Failed to add test URL: {test_url}") + return False + + title = f"{self.title_prefix} Test Notification" + body = "This is a test notification from Warracker to verify your Apprise configuration is working correctly." + + return test_apprise.notify(title=title, body=body) + + elif self.enabled and self.apprise_obj: + # Use configured URLs + title = f"{self.title_prefix} Test Notification" + body = "This is a test notification from Warracker to verify your Apprise configuration is working correctly." + + return self.apprise_obj.notify(title=title, body=body) + else: + logger.warning("Apprise not enabled or configured for test notification") + return False + + except Exception as e: + logger.error(f"Error sending test notification: {e}") + return False + + def send_expiration_notifications(self, eligible_user_ids: Optional[List[int]] = None) -> Dict[str, int]: + """Send notifications for warranties expiring within configured days + + Args: + eligible_user_ids: List of user IDs that should receive Apprise notifications. + If None, all users with expiring warranties will be notified. + """ + if not self.is_available(): + logger.info("Apprise notifications disabled or not configured") + return {"sent": 0, "errors": 0} + + if not DB_FUNCTIONS_IMPORTED: + logger.error("Database functions not available - cannot retrieve expiring warranties") + return {"sent": 0, "errors": 1} + + results = {"sent": 0, "errors": 0} + + try: + logger.info(f"Checking expiration notifications for days: {self.expiration_days}") + if eligible_user_ids is not None: + logger.info(f"Filtering notifications for {len(eligible_user_ids)} eligible users: {eligible_user_ids}") + + for days in self.expiration_days: + logger.info(f"Getting warranties expiring in {days} days...") + expiring_warranties = get_expiring_warranties(days) + logger.info(f"Found {len(expiring_warranties)} warranties expiring in {days} days") + + # Filter warranties by eligible user IDs if provided + if eligible_user_ids is not None: + original_count = len(expiring_warranties) + expiring_warranties = [w for w in expiring_warranties if w.get('user_id') in eligible_user_ids] + logger.info(f"Filtered from {original_count} to {len(expiring_warranties)} warranties for eligible users") + + if expiring_warranties: + success = self._send_expiration_batch(expiring_warranties, days) + if success: + results["sent"] += 1 + logger.info(f"Sent expiration notification for {len(expiring_warranties)} warranties expiring in {days} days") + else: + results["errors"] += 1 + logger.error(f"Failed to send expiration notification for {days} days") + else: + logger.info(f"No eligible warranties expiring in {days} days") + + except Exception as e: + logger.error(f"Error in send_expiration_notifications: {e}") + results["errors"] += 1 + + return results + + def _send_expiration_batch(self, warranties: List[Dict], days: int) -> bool: + """Send notification for a batch of warranties expiring in X days""" + if not self.is_available(): + return False + + try: + if days == 1: + title = f"{self.title_prefix} Warranties Expiring Tomorrow!" + urgency = "🚨 URGENT: " + elif days <= 7: + title = f"{self.title_prefix} Warranties Expiring in {days} Days" + urgency = "⚠️ IMPORTANT: " + else: + title = f"{self.title_prefix} Warranties Expiring in {days} Days" + urgency = "📅 REMINDER: " + + # Build notification body + body_lines = [ + f"{urgency}You have {len(warranties)} warranty(ies) expiring in {days} day(s):", + "" + ] + + for warranty in warranties[:10]: # Limit to first 10 to avoid very long messages + expiry_date = warranty.get('expiration_date', 'Unknown') + if isinstance(expiry_date, str): + try: + # Parse and format date if it's a string + parsed_date = datetime.fromisoformat(expiry_date.replace('Z', '+00:00')) + expiry_date = parsed_date.strftime('%Y-%m-%d') + except: + pass + + body_lines.append(f"• {warranty.get('product_name', 'Unknown Product')} (expires: {expiry_date})") + + if len(warranties) > 10: + body_lines.append(f"... and {len(warranties) - 10} more") + + body_lines.extend([ + "", + "Please review your warranties and take necessary action.", + "", + "Visit your Warracker dashboard to view details and manage your warranties." + ]) + + body = "\n".join(body_lines) + + return self.apprise_obj.notify(title=title, body=body) + + except Exception as e: + logger.error(f"Error sending expiration batch notification: {e}") + return False + + def send_custom_notification(self, title: str, message: str, urls: Optional[List[str]] = None) -> bool: + """Send a custom notification""" + if not APPRISE_AVAILABLE: + logger.error("Cannot send custom notification - Apprise not available") + return False + + try: + if urls: + # Use specific URLs + custom_apprise = apprise.Apprise() + for url in urls: + custom_apprise.add(url) + + if len(custom_apprise) == 0: + logger.error("No valid URLs provided for custom notification") + return False + + full_title = f"{self.title_prefix} {title}" + return custom_apprise.notify(title=full_title, body=message) + + elif self.is_available(): + # Use configured URLs + full_title = f"{self.title_prefix} {title}" + return self.apprise_obj.notify(title=full_title, body=message) + else: + logger.warning("No Apprise configuration available for custom notification") + return False + + except Exception as e: + logger.error(f"Error sending custom notification: {e}") + return False + + def validate_url(self, url: str) -> bool: + """Validate if an Apprise URL is properly formatted""" + if not APPRISE_AVAILABLE: + return False + + try: + test_apprise = apprise.Apprise() + return test_apprise.add(url) + except Exception as e: + logger.error(f"Error validating URL: {e}") + return False + + def get_supported_services(self) -> List[str]: + """Get a list of supported notification services""" + if not APPRISE_AVAILABLE: + return [] + + try: + # This is a simplified list - Apprise supports 80+ services + return [ + "Discord", "Slack", "Microsoft Teams", "Telegram", "Signal", + "Email (SMTP)", "Gmail", "Outlook", "Yahoo Mail", + "Pushover", "Pushbullet", "Gotify", "ntfy", + "AWS SNS", "Twilio", "SMS", "WhatsApp", + "Matrix", "Rocket.Chat", "Mattermost", + "And 60+ more services..." + ] + except Exception: + return [] + +# Global instance +apprise_handler = AppriseNotificationHandler() \ No newline at end of file diff --git a/backend/check_apprise.py b/backend/check_apprise.py new file mode 100644 index 0000000..e274050 --- /dev/null +++ b/backend/check_apprise.py @@ -0,0 +1,134 @@ +#!/usr/bin/env python3 +""" +Diagnostic script to check Apprise installation and functionality +""" + +import sys +import os + +def check_python_environment(): + """Check Python environment details""" + print("=== Python Environment ===") + print(f"Python version: {sys.version}") + print(f"Python executable: {sys.executable}") + print(f"Python path: {sys.path}") + print() + +def check_apprise_import(): + """Test Apprise import""" + print("=== Apprise Import Test ===") + try: + import apprise + print("✅ Apprise imported successfully") + print(f" Apprise version: {getattr(apprise, '__version__', 'Unknown')}") + print(f" Apprise location: {apprise.__file__}") + return True + except ImportError as e: + print(f"❌ Failed to import Apprise: {e}") + return False + except Exception as e: + print(f"❌ Unexpected error importing Apprise: {e}") + return False + +def check_apprise_functionality(): + """Test basic Apprise functionality""" + print("=== Apprise Functionality Test ===") + try: + import apprise + + # Test creating an Apprise object + apobj = apprise.Apprise() + print("✅ Apprise object created successfully") + + # Test adding a fake URL (won't send anything) + fake_url = "json://httpbin.org/post" + result = apobj.add(fake_url) + if result: + print("✅ Apprise URL addition test passed") + else: + print("⚠️ Apprise URL addition test failed (this may be normal)") + + # Test notification (won't actually send) + print("✅ Apprise basic functionality test completed") + return True + + except Exception as e: + print(f"❌ Apprise functionality test failed: {e}") + return False + +def check_requirements_file(): + """Check if apprise is in requirements.txt""" + print("=== Requirements File Check ===") + req_paths = [ + "/app/requirements.txt", + "requirements.txt", + "backend/requirements.txt" + ] + + for req_path in req_paths: + if os.path.exists(req_path): + print(f"Found requirements file: {req_path}") + try: + with open(req_path, 'r') as f: + content = f.read() + if 'apprise' in content.lower(): + print("✅ Apprise found in requirements.txt") + # Show the specific line + for line in content.split('\n'): + if 'apprise' in line.lower(): + print(f" {line.strip()}") + else: + print("❌ Apprise NOT found in requirements.txt") + break + except Exception as e: + print(f"Error reading {req_path}: {e}") + else: + print("❌ No requirements.txt file found") + print() + +def check_installed_packages(): + """Check what packages are installed""" + print("=== Installed Packages Check ===") + try: + import subprocess + result = subprocess.run([sys.executable, "-m", "pip", "list"], + capture_output=True, text=True) + if result.returncode == 0: + packages = result.stdout + if 'apprise' in packages.lower(): + print("✅ Apprise found in installed packages") + for line in packages.split('\n'): + if 'apprise' in line.lower(): + print(f" {line.strip()}") + else: + print("❌ Apprise NOT found in installed packages") + print("Available packages:") + print(packages[:500] + "..." if len(packages) > 500 else packages) + else: + print(f"❌ Failed to list packages: {result.stderr}") + except Exception as e: + print(f"❌ Error checking installed packages: {e}") + print() + +def main(): + """Run all diagnostic checks""" + print("Apprise Diagnostic Script") + print("=" * 50) + + check_python_environment() + check_requirements_file() + check_installed_packages() + + apprise_imported = check_apprise_import() + + if apprise_imported: + check_apprise_functionality() + print("\n=== Summary ===") + print("✅ Apprise is working correctly!") + else: + print("\n=== Summary ===") + print("❌ Apprise is not available or not working") + print(" Try installing it with: pip install apprise==1.9.3") + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/backend/check_warranties.py b/backend/check_warranties.py new file mode 100644 index 0000000..d077cf0 --- /dev/null +++ b/backend/check_warranties.py @@ -0,0 +1,129 @@ +#!/usr/bin/env python3 +""" +Check warranties and their expiration dates +""" + +import sys +import os +from datetime import datetime, timedelta, date + +# Add the backend directory to the path +sys.path.insert(0, '/app') +sys.path.insert(0, '/app/backend') + +print("🔍 Checking Warranties and Expiration Dates") +print("=" * 60) + +# Test imports +try: + from backend.db_handler import get_db_connection, release_db_connection, init_db_pool, get_expiring_warranties + print("✅ Database functions imported successfully") +except Exception as e: + print(f"❌ Failed to import database functions: {e}") + sys.exit(1) + +# Test database connection +try: + init_db_pool() + print("✅ Database pool initialized") +except Exception as e: + print(f"❌ Database connection failed: {e}") + sys.exit(1) + +# Check all warranties +conn = None +try: + conn = get_db_connection() + cursor = conn.cursor() + + print("\n1. Checking all warranties...") + cursor.execute(""" + SELECT + id, product_name, expiration_date, is_lifetime, + purchase_date, user_id + FROM warranties + ORDER BY expiration_date ASC + """) + + warranties = cursor.fetchall() + print(f" Total warranties in database: {len(warranties)}") + + if warranties: + print("\n Sample warranties:") + today = date.today() + + for i, warranty in enumerate(warranties[:10]): # Show first 10 + warranty_id, product_name, exp_date, is_lifetime, purchase_date, user_id = warranty + + if is_lifetime: + days_until_exp = "∞ (Lifetime)" + elif exp_date: + days_until_exp = (exp_date - today).days + if days_until_exp < 0: + days_until_exp = f"{abs(days_until_exp)} days ago (EXPIRED)" + else: + days_until_exp = f"{days_until_exp} days" + else: + days_until_exp = "No expiration date" + + print(f" {i+1}. {product_name[:30]:<30} | Expires: {exp_date} | {days_until_exp}") + + # Check specifically for warranties expiring soon + print("\n2. Checking warranties expiring in next 30 days...") + + today = date.today() + next_30_days = today + timedelta(days=30) + + cursor.execute(""" + SELECT + id, product_name, expiration_date, user_id, + (expiration_date - %s) as days_until_expiry + FROM warranties + WHERE is_lifetime = false + AND expiration_date BETWEEN %s AND %s + ORDER BY expiration_date ASC + """, (today, today, next_30_days)) + + expiring_soon = cursor.fetchall() + print(f" Warranties expiring in next 30 days: {len(expiring_soon)}") + + if expiring_soon: + print("\n Expiring warranties:") + for warranty in expiring_soon: + warranty_id, product_name, exp_date, user_id, days_until = warranty + print(f" • {product_name} (ID: {warranty_id}, User: {user_id}) - Expires: {exp_date} ({days_until.days} days)") + + # Test the get_expiring_warranties function specifically + print("\n3. Testing get_expiring_warranties function...") + + for days in [1, 7, 14, 30, 60, 90]: + try: + expiring = get_expiring_warranties(days) + print(f" Expiring in {days} days: {len(expiring)} warranties") + + if expiring and days <= 30: # Show details for shorter timeframes + for w in expiring[:3]: # Show first 3 + print(f" - {w.get('product_name', 'Unknown')} (expires: {w.get('expiration_date', 'Unknown')})") + except Exception as e: + print(f" ❌ Error checking {days} days: {e}") + + cursor.close() + +except Exception as e: + print(f"❌ Error checking warranties: {e}") + import traceback + traceback.print_exc() +finally: + if conn: + release_db_connection(conn) + +print("\n" + "=" * 60) +print("🎯 Summary:") +print("\nIf you see 0 warranties expiring soon, you have a few options:") +print("1. Add a test warranty with an upcoming expiration date") +print("2. Modify an existing warranty to expire soon") +print("3. Test with longer notification periods (like 60, 90 days)") +print("\nTo add a test warranty:") +print("- Go to your Warracker dashboard") +print("- Add a new warranty with expiration date in the next few days") +print("- Then test the Apprise notifications again") \ No newline at end of file diff --git a/backend/create_test_warranty.py b/backend/create_test_warranty.py new file mode 100644 index 0000000..01a54bf --- /dev/null +++ b/backend/create_test_warranty.py @@ -0,0 +1,156 @@ +#!/usr/bin/env python3 +""" +Create a test warranty for testing Apprise notifications +""" + +import sys +import os +from datetime import datetime, timedelta, date + +# Add the backend directory to the path +sys.path.insert(0, '/app') +sys.path.insert(0, '/app/backend') + +print("🧪 Creating Test Warranty for Apprise Notifications") +print("=" * 60) + +# Test imports +try: + from backend.db_handler import get_db_connection, release_db_connection, init_db_pool + print("✅ Database functions imported successfully") +except Exception as e: + print(f"❌ Failed to import database functions: {e}") + sys.exit(1) + +# Test database connection +try: + init_db_pool() + print("✅ Database pool initialized") +except Exception as e: + print(f"❌ Database connection failed: {e}") + sys.exit(1) + +# Get the first user ID to assign the test warranty to +conn = None +try: + conn = get_db_connection() + cursor = conn.cursor() + + # Get a user ID (preferably admin) + cursor.execute("SELECT id FROM users WHERE is_admin = true LIMIT 1") + admin_user = cursor.fetchone() + + if not admin_user: + cursor.execute("SELECT id FROM users LIMIT 1") + any_user = cursor.fetchone() + if any_user: + user_id = any_user[0] + print(f"📋 Using first available user ID: {user_id}") + else: + print("❌ No users found in database") + sys.exit(1) + else: + user_id = admin_user[0] + print(f"📋 Using admin user ID: {user_id}") + + # Create test warranties with different expiration dates + today = date.today() + test_warranties = [ + { + 'product_name': 'Test Product - Expires in 5 days', + 'expiration_date': today + timedelta(days=5), + 'purchase_date': today - timedelta(days=360), + 'vendor': 'Test Vendor', + 'warranty_type': 'Extended', + 'notes': 'Test warranty for Apprise notification testing' + }, + { + 'product_name': 'Test Product - Expires in 15 days', + 'expiration_date': today + timedelta(days=15), + 'purchase_date': today - timedelta(days=350), + 'vendor': 'Test Vendor', + 'warranty_type': 'Standard', + 'notes': 'Test warranty for Apprise notification testing' + }, + { + 'product_name': 'Test Product - Expires in 25 days', + 'expiration_date': today + timedelta(days=25), + 'purchase_date': today - timedelta(days=340), + 'vendor': 'Test Vendor', + 'warranty_type': 'Manufacturer', + 'notes': 'Test warranty for Apprise notification testing' + } + ] + + print(f"\n📝 Creating {len(test_warranties)} test warranties...") + + created_warranties = [] + + for warranty in test_warranties: + try: + cursor.execute(""" + INSERT INTO warranties ( + product_name, purchase_date, expiration_date, + user_id, vendor, warranty_type, notes, + is_lifetime, warranty_duration_years, warranty_duration_months, warranty_duration_days + ) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, %s, %s, %s) + RETURNING id + """, ( + warranty['product_name'], + warranty['purchase_date'], + warranty['expiration_date'], + user_id, + warranty['vendor'], + warranty['warranty_type'], + warranty['notes'], + False, # not lifetime + 1, # warranty_duration_years + 0, # warranty_duration_months + 0 # warranty_duration_days + )) + + warranty_id = cursor.fetchone()[0] + created_warranties.append({ + 'id': warranty_id, + 'name': warranty['product_name'], + 'expires': warranty['expiration_date'] + }) + + print(f" ✅ Created: {warranty['product_name']} (ID: {warranty_id}, expires: {warranty['expiration_date']})") + + except Exception as e: + print(f" ❌ Failed to create {warranty['product_name']}: {e}") + + # Commit the changes + conn.commit() + cursor.close() + + print(f"\n🎉 Successfully created {len(created_warranties)} test warranties!") + + if created_warranties: + print("\n📅 Test warranty schedule:") + for warranty in created_warranties: + days_until = (warranty['expires'] - today).days + print(f" • {warranty['name']} - {days_until} days from now ({warranty['expires']})") + + print("\n🧪 To test notifications:") + print("1. Go to your Warracker admin settings") + print("2. Click 'Send Expiration Notifications' in the Apprise section") + print("3. Check your Discord/notification service for messages") + print("\n🧹 To clean up test data later:") + print("- Go to your warranty dashboard and delete the test warranties") + print("- Or run a cleanup script") + +except Exception as e: + print(f"❌ Error creating test warranties: {e}") + import traceback + traceback.print_exc() + if conn: + conn.rollback() +finally: + if conn: + release_db_connection(conn) + +print("\n" + "=" * 60) +print("🎯 Test warranties created! Now you can test Apprise notifications.") \ No newline at end of file diff --git a/backend/db_handler.py b/backend/db_handler.py index e6ab5f1..3b59cba 100644 --- a/backend/db_handler.py +++ b/backend/db_handler.py @@ -4,6 +4,8 @@ import psycopg2 from psycopg2 import pool import logging import time +from datetime import datetime, timedelta +from typing import List, Dict, Optional logger = logging.getLogger(__name__) @@ -88,4 +90,160 @@ def release_db_connection(conn): try: conn.close() except Exception as e: - logger.error(f"[DB_HANDLER] Error closing connection directly (pool was None): {e}") \ No newline at end of file + logger.error(f"[DB_HANDLER] Error closing connection directly (pool was None): {e}") + +def get_site_setting(setting_name: str, default_value: str = '') -> str: + """Get a site setting value from the database""" + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute( + "SELECT value FROM site_settings WHERE key = %s", + (setting_name,) + ) + result = cursor.fetchone() + cursor.close() + + if result: + return result[0] if result[0] is not None else default_value + return default_value + + except Exception as e: + logger.error(f"Error getting site setting {setting_name}: {e}") + return default_value + finally: + if conn: + release_db_connection(conn) + +def get_expiring_warranties(days: int) -> List[Dict]: + """Get warranties expiring within the specified number of days""" + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor() + + # Calculate the date range + today = datetime.now().date() + end_date = today + timedelta(days=days) + + # Query for non-lifetime warranties expiring within the specified days + # Include warranties expiring from today up to the target date + cursor.execute(""" + SELECT + id, product_name, expiration_date, user_id, + purchase_date, vendor, warranty_type, notes + FROM warranties + WHERE is_lifetime = false + AND expiration_date BETWEEN %s AND %s + ORDER BY user_id, expiration_date, product_name + """, (today, end_date)) + + results = cursor.fetchall() + cursor.close() + + warranties = [] + for row in results: + warranty = { + 'id': row[0], + 'product_name': row[1], + 'expiration_date': row[2].isoformat() if row[2] else None, + 'user_id': row[3], + 'purchase_date': row[4].isoformat() if row[4] else None, + 'vendor': row[5], + 'warranty_type': row[6], + 'notes': row[7] + } + warranties.append(warranty) + + logger.info(f"Found {len(warranties)} warranties expiring in {days} days") + return warranties + + except Exception as e: + logger.error(f"Error getting expiring warranties for {days} days: {e}") + return [] + finally: + if conn: + release_db_connection(conn) + +def get_all_expiring_warranties(max_days: int = 30) -> Dict[int, List[Dict]]: + """Get all warranties expiring within max_days, grouped by days until expiration""" + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor() + + today = datetime.now().date() + max_date = today + timedelta(days=max_days) + + cursor.execute(""" + SELECT + id, product_name, expiration_date, user_id, + purchase_date, vendor, warranty_type, notes, + (expiration_date - %s) as days_until_expiry + FROM warranties + WHERE is_lifetime = false + AND expiration_date BETWEEN %s AND %s + ORDER BY expiration_date, product_name + """, (today, today, max_date)) + + results = cursor.fetchall() + cursor.close() + + # Group by days until expiry + grouped_warranties = {} + for row in results: + days_until = row[8].days if row[8] else 0 + + if days_until not in grouped_warranties: + grouped_warranties[days_until] = [] + + warranty = { + 'id': row[0], + 'product_name': row[1], + 'expiration_date': row[2].isoformat() if row[2] else None, + 'user_id': row[3], + 'purchase_date': row[4].isoformat() if row[4] else None, + 'vendor': row[5], + 'warranty_type': row[6], + 'notes': row[7], + 'days_until_expiry': days_until + } + grouped_warranties[days_until].append(warranty) + + return grouped_warranties + + except Exception as e: + logger.error(f"Error getting all expiring warranties: {e}") + return {} + finally: + if conn: + release_db_connection(conn) + +def update_site_setting(setting_name: str, setting_value: str) -> bool: + """Update a site setting in the database""" + conn = None + try: + conn = get_db_connection() + cursor = conn.cursor() + + cursor.execute(""" + INSERT INTO site_settings (key, value) + VALUES (%s, %s) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP + """, (setting_name, setting_value)) + + conn.commit() + cursor.close() + return True + + except Exception as e: + logger.error(f"Error updating site setting {setting_name}: {e}") + if conn: + conn.rollback() + return False + finally: + if conn: + release_db_connection(conn) \ No newline at end of file diff --git a/backend/debug_apprise_settings.py b/backend/debug_apprise_settings.py new file mode 100644 index 0000000..34c7bb8 --- /dev/null +++ b/backend/debug_apprise_settings.py @@ -0,0 +1,200 @@ +#!/usr/bin/env python3 +""" +Debug script for Apprise settings saving and loading +""" + +import sys +import os + +# Add the backend directory to the path +sys.path.insert(0, '/app') +sys.path.insert(0, '/app/backend') + +print("🔍 Debugging Apprise Settings") +print("=" * 50) + +# Test imports +print("\n1. Testing imports...") +try: + from backend.db_handler import get_db_connection, release_db_connection, init_db_pool, get_site_setting, update_site_setting + print("✅ Database functions imported successfully") +except Exception as e: + print(f"❌ Failed to import database functions: {e}") + sys.exit(1) + +# Test database connection +print("\n2. Testing database connection...") +try: + init_db_pool() + print("✅ Database pool initialized") +except Exception as e: + print(f"❌ Database connection failed: {e}") + sys.exit(1) + +# Check if site_settings table exists +print("\n3. Checking site_settings table...") +conn = None +try: + conn = get_db_connection() + cursor = conn.cursor() + + # Check if table exists + cursor.execute(""" + SELECT EXISTS ( + SELECT FROM information_schema.tables + WHERE table_name = 'site_settings' + ) + """) + table_exists = cursor.fetchone()[0] + print(f"✅ site_settings table exists: {table_exists}") + + if table_exists: + # Check table structure + cursor.execute(""" + SELECT column_name, data_type, is_nullable + FROM information_schema.columns + WHERE table_name = 'site_settings' + ORDER BY ordinal_position + """) + columns = cursor.fetchall() + print(" Table structure:") + for column in columns: + print(f" {column[0]} ({column[1]}) {'NULL' if column[2] == 'YES' else 'NOT NULL'}") + + # Check existing Apprise settings + cursor.execute("SELECT key, value FROM site_settings WHERE key LIKE 'apprise_%'") + apprise_settings = cursor.fetchall() + print(f"\n Existing Apprise settings ({len(apprise_settings)}):") + for key, value in apprise_settings: + print(f" {key} = {value}") + + cursor.close() + +except Exception as e: + print(f"❌ Error checking table: {e}") + import traceback + traceback.print_exc() +finally: + if conn: + release_db_connection(conn) + +# Test setting and getting values +print("\n4. Testing setting and getting values...") +test_key = "apprise_test_debug" +test_value = "test_value_123" + +try: + # Test setting a value + print(f" Setting {test_key} = {test_value}") + success = update_site_setting(test_key, test_value) + print(f" Update result: {success}") + + if success: + # Test getting the value + retrieved_value = get_site_setting(test_key, "default") + print(f" Retrieved value: {retrieved_value}") + + if retrieved_value == test_value: + print("✅ Setting and getting values works correctly") + else: + print(f"❌ Value mismatch! Expected: {test_value}, Got: {retrieved_value}") + else: + print("❌ Failed to update setting") + +except Exception as e: + print(f"❌ Error testing set/get: {e}") + import traceback + traceback.print_exc() + +# Test specific Apprise settings +print("\n5. Testing Apprise-specific settings...") +apprise_test_settings = { + 'apprise_enabled': 'true', + 'apprise_urls': 'test://url1,test://url2', + 'apprise_expiration_days': '7,30', + 'apprise_notification_time': '09:00', + 'apprise_title_prefix': '[Test Warracker]' +} + +try: + for key, value in apprise_test_settings.items(): + print(f" Testing {key} = {value}") + + # Save the setting + success = update_site_setting(key, value) + if not success: + print(f" ❌ Failed to save {key}") + continue + + # Retrieve the setting + retrieved = get_site_setting(key, "NOT_FOUND") + if retrieved == value: + print(f" ✅ {key} saved and retrieved correctly") + else: + print(f" ❌ {key} mismatch! Expected: {value}, Got: {retrieved}") + +except Exception as e: + print(f"❌ Error testing Apprise settings: {e}") + import traceback + traceback.print_exc() + +# Test database query directly for troubleshooting +print("\n6. Direct database query test...") +conn = None +try: + conn = get_db_connection() + cursor = conn.cursor() + + # Insert test setting directly + test_direct_key = "apprise_direct_test" + test_direct_value = "direct_test_value" + + cursor.execute(""" + INSERT INTO site_settings (key, value, updated_at) + VALUES (%s, %s, CURRENT_TIMESTAMP) + ON CONFLICT (key) + DO UPDATE SET value = EXCLUDED.value, updated_at = CURRENT_TIMESTAMP + """, (test_direct_key, test_direct_value)) + + conn.commit() + print(f" ✅ Direct insert of {test_direct_key} successful") + + # Query it back + cursor.execute("SELECT value FROM site_settings WHERE key = %s", (test_direct_key,)) + result = cursor.fetchone() + + if result and result[0] == test_direct_value: + print(f" ✅ Direct query retrieved correct value: {result[0]}") + else: + print(f" ❌ Direct query failed or wrong value: {result}") + + cursor.close() + +except Exception as e: + print(f"❌ Error with direct database test: {e}") + import traceback + traceback.print_exc() +finally: + if conn: + release_db_connection(conn) + +# Check if API route works by simulating request +print("\n7. Testing API route simulation...") +try: + # Import Flask components + from backend.app import app + + with app.test_client() as client: + # This won't work without proper authentication, but we can check if the route exists + print(" ✅ App imported successfully - API routes should be available") + +except Exception as e: + print(f"❌ Error importing app: {e}") + +print("\n" + "=" * 50) +print("🎯 Debug completed!") +print("\nIf settings are saving but not appearing in frontend:") +print("1. Check browser network tab for API errors") +print("2. Check if frontend is calling the correct API endpoints") +print("3. Verify authentication token is valid") +print("4. Check browser console for JavaScript errors") \ No newline at end of file diff --git a/backend/fix_notification_columns.py b/backend/fix_notification_columns.py new file mode 100644 index 0000000..49845ae --- /dev/null +++ b/backend/fix_notification_columns.py @@ -0,0 +1,93 @@ +#!/usr/bin/env python3 +""" +Fix missing notification columns in user_preferences table. +This script can be run manually to fix the notification column issues. +""" + +import os +import psycopg2 +import logging + +# Set up logging +logging.basicConfig(level=logging.INFO) +logger = logging.getLogger(__name__) + +# Database connection details from environment +DB_HOST = os.environ.get('DB_HOST', 'localhost') +DB_PORT = os.environ.get('DB_PORT', '5432') +DB_NAME = os.environ.get('DB_NAME', 'warranty_db') +DB_USER = os.environ.get('DB_USER', 'warranty_user') +DB_PASSWORD = os.environ.get('DB_PASSWORD', 'warranty_password') + +def fix_notification_columns(): + """Fix missing notification columns in user_preferences table.""" + try: + logger.info("Connecting to database...") + conn = psycopg2.connect( + host=DB_HOST, + port=DB_PORT, + dbname=DB_NAME, + user=DB_USER, + password=DB_PASSWORD + ) + conn.autocommit = False + + with conn.cursor() as cur: + logger.info("Checking existing columns...") + + # Check what columns exist + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name='user_preferences' + AND column_name IN ('notification_channel', 'apprise_notification_time', 'apprise_notification_frequency') + """) + existing_columns = [row[0] for row in cur.fetchall()] + logger.info(f"Existing notification columns: {existing_columns}") + + # Add notification_channel if missing + if 'notification_channel' not in existing_columns: + logger.info("Adding notification_channel column...") + cur.execute(""" + ALTER TABLE user_preferences + ADD COLUMN notification_channel VARCHAR(10) NOT NULL DEFAULT 'email' + """) + logger.info("✓ Added notification_channel column") + else: + logger.info("✓ notification_channel column already exists") + + # Add apprise_notification_time if missing + if 'apprise_notification_time' not in existing_columns: + logger.info("Adding apprise_notification_time column...") + cur.execute(""" + ALTER TABLE user_preferences + ADD COLUMN apprise_notification_time VARCHAR(5) NOT NULL DEFAULT '09:00' + """) + logger.info("✓ Added apprise_notification_time column") + else: + logger.info("✓ apprise_notification_time column already exists") + + # Add apprise_notification_frequency if missing + if 'apprise_notification_frequency' not in existing_columns: + logger.info("Adding apprise_notification_frequency column...") + cur.execute(""" + ALTER TABLE user_preferences + ADD COLUMN apprise_notification_frequency VARCHAR(10) NOT NULL DEFAULT 'daily' + """) + logger.info("✓ Added apprise_notification_frequency column") + else: + logger.info("✓ apprise_notification_frequency column already exists") + + conn.commit() + logger.info("✅ All notification columns are now properly configured!") + + except Exception as e: + logger.error(f"❌ Error fixing notification columns: {e}") + if conn: + conn.rollback() + raise + finally: + if conn: + conn.close() + +if __name__ == "__main__": + fix_notification_columns() \ No newline at end of file diff --git a/backend/fix_permissions.sql b/backend/fix_permissions.sql index 3cb4f1e..7337a66 100644 --- a/backend/fix_permissions.sql +++ b/backend/fix_permissions.sql @@ -1,9 +1,6 @@ -- Script to fix PostgreSQL permissions for db_user --- Grant superuser privileges -ALTER ROLE %(db_user)s WITH SUPERUSER; - --- Grant role management privileges +-- Grant role management privileges (removed SUPERUSER) ALTER ROLE %(db_user)s WITH CREATEROLE; -- Ensure all database objects are accessible diff --git a/backend/migrations/011_ensure_admin_permissions.sql b/backend/migrations/011_ensure_admin_permissions.sql index dd5fded..48bfb18 100644 --- a/backend/migrations/011_ensure_admin_permissions.sql +++ b/backend/migrations/011_ensure_admin_permissions.sql @@ -1,7 +1,7 @@ -- Migration: Ensure Admin Permissions --- Grant superuser privileges to db_user -ALTER ROLE %(db_user)s WITH SUPERUSER; +-- Grant elevated privileges to db_user (removed SUPERUSER) +-- ALTER ROLE %(db_user)s WITH SUPERUSER; -- Ensure all tables are accessible GRANT ALL PRIVILEGES ON DATABASE %(db_name)s TO %(db_user)s; diff --git a/backend/migrations/025_add_warranty_type.sql b/backend/migrations/025_add_warranty_type.sql new file mode 100644 index 0000000..d113bfb --- /dev/null +++ b/backend/migrations/025_add_warranty_type.sql @@ -0,0 +1,5 @@ +-- Migration: Add warranty_type field to warranties table +ALTER TABLE warranties ADD COLUMN IF NOT EXISTS warranty_type VARCHAR(255) DEFAULT NULL; + +-- Add an index for warranty type to improve search performance +CREATE INDEX IF NOT EXISTS idx_warranty_type ON warranties(warranty_type); \ No newline at end of file diff --git a/backend/migrations/026_add_apprise_settings.sql b/backend/migrations/026_add_apprise_settings.sql new file mode 100644 index 0000000..ba89bb8 --- /dev/null +++ b/backend/migrations/026_add_apprise_settings.sql @@ -0,0 +1,14 @@ +-- Migration: Add Apprise notification settings +-- Description: Adds Apprise notification configuration options to site_settings table + +-- Add Apprise notification settings +INSERT INTO site_settings (key, value) VALUES +('apprise_enabled', 'false'), +('apprise_urls', ''), +('apprise_expiration_days', '7,30'), +('apprise_notification_time', '09:00'), +('apprise_title_prefix', '[Warracker]'), +('apprise_test_url', '') +ON CONFLICT (key) DO UPDATE SET + value = EXCLUDED.value, + updated_at = CURRENT_TIMESTAMP; \ No newline at end of file diff --git a/backend/migrations/027_add_notification_channel.sql b/backend/migrations/027_add_notification_channel.sql new file mode 100644 index 0000000..b170039 --- /dev/null +++ b/backend/migrations/027_add_notification_channel.sql @@ -0,0 +1,8 @@ +-- Add notification_channel column to user_preferences table +ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS notification_channel VARCHAR(10) NOT NULL DEFAULT 'email'; + +-- Add apprise_notification_time column to user_preferences table +ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS apprise_notification_time VARCHAR(5) NOT NULL DEFAULT '09:00'; + +-- Add apprise_notification_frequency column to user_preferences table +ALTER TABLE user_preferences ADD COLUMN IF NOT EXISTS apprise_notification_frequency VARCHAR(10) NOT NULL DEFAULT 'daily'; diff --git a/backend/migrations/028_fix_notification_columns.sql b/backend/migrations/028_fix_notification_columns.sql new file mode 100644 index 0000000..958dc9f --- /dev/null +++ b/backend/migrations/028_fix_notification_columns.sql @@ -0,0 +1,26 @@ +-- Migration 028: Fix missing notification columns +-- Description: Ensure all required notification columns exist in user_preferences table + +-- Add notification_channel column if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_preferences' AND column_name='notification_channel') THEN + ALTER TABLE user_preferences ADD COLUMN notification_channel VARCHAR(10) NOT NULL DEFAULT 'email'; + END IF; +END $$; + +-- Add apprise_notification_time column if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_preferences' AND column_name='apprise_notification_time') THEN + ALTER TABLE user_preferences ADD COLUMN apprise_notification_time VARCHAR(5) NOT NULL DEFAULT '09:00'; + END IF; +END $$; + +-- Add apprise_notification_frequency column if it doesn't exist +DO $$ +BEGIN + IF NOT EXISTS (SELECT 1 FROM information_schema.columns WHERE table_name='user_preferences' AND column_name='apprise_notification_frequency') THEN + ALTER TABLE user_preferences ADD COLUMN apprise_notification_frequency VARCHAR(10) NOT NULL DEFAULT 'daily'; + END IF; +END $$; \ No newline at end of file diff --git a/backend/migrations/029_add_apprise_timezone.sql b/backend/migrations/029_add_apprise_timezone.sql new file mode 100644 index 0000000..e39526c --- /dev/null +++ b/backend/migrations/029_add_apprise_timezone.sql @@ -0,0 +1,10 @@ +-- Add the apprise_timezone column to the user_preferences table +ALTER TABLE user_preferences +ADD COLUMN apprise_timezone VARCHAR(50); + +-- Update existing rows to have a default value if the main timezone is set +-- This ensures that users who have already configured a timezone will have it +-- copied to the new Apprise-specific setting. +UPDATE user_preferences +SET apprise_timezone = timezone +WHERE timezone IS NOT NULL; diff --git a/backend/migrations/030_add_product_photo_path.sql b/backend/migrations/030_add_product_photo_path.sql new file mode 100644 index 0000000..b9541b7 --- /dev/null +++ b/backend/migrations/030_add_product_photo_path.sql @@ -0,0 +1,7 @@ +-- Migration: Add product_photo_path column to warranties table +-- This allows storing product photos for warranty cards + +ALTER TABLE warranties ADD COLUMN IF NOT EXISTS product_photo_path VARCHAR(255) DEFAULT NULL; + +-- Add index for better performance when filtering by photo existence +CREATE INDEX IF NOT EXISTS idx_warranties_photo_path ON warranties(product_photo_path) WHERE product_photo_path IS NOT NULL; \ No newline at end of file diff --git a/backend/notifications.py b/backend/notifications.py new file mode 100644 index 0000000..82b32cb --- /dev/null +++ b/backend/notifications.py @@ -0,0 +1,817 @@ +""" +Warranty Expiration Notification System + +This module handles all notification-related functionality for the Warracker application, +including email notifications, scheduling, and Apprise integration. +""" + +import os +import threading +import time +import atexit +import smtplib +import logging +from datetime import datetime, date +from email.mime.multipart import MIMEMultipart +from email.mime.text import MIMEText + +import pytz +from pytz import timezone as pytz_timezone +from apscheduler.schedulers.background import BackgroundScheduler + +# Configure logging +logger = logging.getLogger(__name__) + +# Global variables for notification management +notification_lock = threading.Lock() +last_notification_sent = {} +scheduler = None +scheduler_initialized = False +scheduler_retry_attempted = False + +# Apprise integration (will be set by app.py if available) +APPRISE_AVAILABLE = False +apprise_handler = None + +def set_apprise_handler(handler): + """Set the Apprise handler if available""" + global APPRISE_AVAILABLE, apprise_handler + APPRISE_AVAILABLE = handler is not None + apprise_handler = handler + +def get_expiring_warranties(get_db_connection, release_db_connection): + """Get warranties that are expiring soon for notification purposes""" + conn = None + try: + # Add retry logic for database connections in scheduled context + max_retries = 3 + retry_delay = 2 + + for attempt in range(max_retries): + try: + conn = get_db_connection() + # Test the connection + with conn.cursor() as test_cur: + test_cur.execute("SELECT 1") + test_cur.fetchone() + break # Connection is good, exit retry loop + except Exception as conn_error: + logger.warning(f"Database connection attempt {attempt + 1} failed in get_expiring_warranties: {conn_error}") + if conn: + try: + release_db_connection(conn) + except: + pass + conn = None + + if attempt < max_retries - 1: + logger.info(f"Retrying database connection in {retry_delay} seconds...") + time.sleep(retry_delay) + else: + logger.error("All database connection attempts failed in get_expiring_warranties") + return [] + + today = date.today() + + with conn.cursor() as cur: + cur.execute(""" + SELECT + u.id, -- Select user_id + u.email, + u.first_name, + w.product_name, + w.expiration_date, + COALESCE(up.expiring_soon_days, 30) AS expiring_soon_days + FROM + warranties w + JOIN + users u ON w.user_id = u.id + LEFT JOIN + user_preferences up ON u.id = up.user_id + WHERE + w.is_lifetime = FALSE + AND w.expiration_date > %s + AND w.expiration_date <= (%s::date + (COALESCE(up.expiring_soon_days, 30) || ' days')::interval)::date + AND u.is_active = TRUE + AND COALESCE(up.email_notifications, TRUE) = TRUE; + """, (today, today)) + + expiring_warranties = [] + for row in cur.fetchall(): + user_id, email, first_name, product_name, expiration_date, expiring_soon_days = row + expiration_date_str = expiration_date.strftime('%Y-%m-%d') + expiring_warranties.append({ + 'user_id': user_id, + 'email': email, + 'first_name': first_name or 'User', # Default if first_name is NULL + 'product_name': product_name, + 'expiration_date': expiration_date_str, + }) + + return expiring_warranties + + except Exception as e: + logger.error(f"Error retrieving expiring warranties: {e}") + return [] # Return an empty list on error + finally: + if conn: + release_db_connection(conn) + +def format_expiration_email(user, warranties, get_db_connection, release_db_connection): + """ + Format an email notification for expiring warranties. + Returns a MIMEMultipart email object with both text and HTML versions. + """ + subject = "Warracker: Upcoming Warranty Expirations" + + # Get email base URL from settings + conn = None + email_base_url = 'http://localhost:8080' # Default fallback + try: + conn = get_db_connection() + with conn.cursor() as cur: + cur.execute("SELECT value FROM site_settings WHERE key = 'email_base_url'") + result = cur.fetchone() + if result: + email_base_url = result[0] + else: + logger.warning("email_base_url setting not found, using default.") + except Exception as e: + logger.error(f"Error fetching email_base_url from settings: {e}. Using default.") + finally: + if conn: + release_db_connection(conn) + + # Ensure base URL doesn't end with a slash + email_base_url = email_base_url.rstrip('/') + + # Create both plain text and HTML versions of the email body + text_body = f"Hello {user['first_name']},\\n\\n" + text_body += "The following warranties are expiring soon:\\n\\n" + + html_body = f"""\ + + + +Hello {user['first_name']},
+The following warranties are expiring soon:
+| Product Name | +Expiration Date | +
|---|---|
| {warranty['product_name']} | +{warranty['expiration_date']} | +
Log in to Warracker to view details.
+Manage your notification settings here.
+ + + """ + + # Create a MIMEMultipart object for both text and HTML + msg = MIMEMultipart('alternative') + msg['Subject'] = subject + msg['From'] = os.environ.get('SMTP_USERNAME', 'notifications@warracker.com') + msg['To'] = user['email'] + + part1 = MIMEText(text_body, 'plain') + part2 = MIMEText(html_body, 'html') + + msg.attach(part1) + msg.attach(part2) + + return msg + +def send_expiration_notifications(manual_trigger=False, get_db_connection=None, release_db_connection=None): + """ + Main function to send warranty expiration notifications. + Retrieves expiring warranties, groups them by user, and sends emails. + + Args: + manual_trigger (bool): Whether this function was triggered manually (vs scheduled) + get_db_connection: Database connection function + release_db_connection: Database connection release function + """ + if get_db_connection is None or release_db_connection is None: + logger.error("Database connection functions not provided to send_expiration_notifications") + return + + # Use a lock to prevent concurrent executions + if not notification_lock.acquire(blocking=False): + logger.info("Notification job already running, skipping this execution") + return + + # Add a small delay for manual triggers to prevent collision with scheduled job + if manual_trigger: + time.sleep(0.1) + + try: + logger.info("Starting expiration notification process") + + # If not manually triggered, check if notifications should be sent today based on preferences + if not manual_trigger: + conn = None + try: + # Add retry logic for database connections in scheduled context + max_retries = 3 + retry_delay = 2 + + for attempt in range(max_retries): + try: + conn = get_db_connection() + # Test the connection + with conn.cursor() as test_cur: + test_cur.execute("SELECT 1") + test_cur.fetchone() + break # Connection is good, exit retry loop + except Exception as conn_error: + logger.warning(f"Database connection attempt {attempt + 1} failed: {conn_error}") + if conn: + try: + release_db_connection(conn) + except: + pass + conn = None + + if attempt < max_retries - 1: + logger.info(f"Retrying database connection in {retry_delay} seconds...") + time.sleep(retry_delay) + else: + logger.error("All database connection attempts failed") + return + + with conn.cursor() as cur: + # Get today's date and current time in UTC + utc_now = datetime.utcnow() + + # Get user IDs that should receive notifications today + # First check if the required columns exist + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name='user_preferences' + AND column_name IN ('notification_channel', 'apprise_notification_time', 'apprise_notification_frequency') + """) + existing_columns = [row[0] for row in cur.fetchall()] + + has_notification_channel = 'notification_channel' in existing_columns + has_apprise_notification_time = 'apprise_notification_time' in existing_columns + has_apprise_notification_frequency = 'apprise_notification_frequency' in existing_columns + + # Build query dynamically based on available columns + select_fields = [ + "u.id", + "u.email", + "u.first_name", + "up.notification_time", + "up.timezone", + "up.notification_frequency" + ] + + if has_apprise_notification_time: + select_fields.append("up.apprise_notification_time") + else: + select_fields.append("'09:00' as apprise_notification_time") + + if 'apprise_timezone' in existing_columns: + select_fields.append("up.apprise_timezone") + else: + select_fields.append("up.timezone as apprise_timezone") + + if has_apprise_notification_frequency: + select_fields.append("up.apprise_notification_frequency") + else: + select_fields.append("'daily' as apprise_notification_frequency") + + if has_notification_channel: + select_fields.append("up.notification_channel") + where_clause = "WHERE u.is_active = TRUE AND up.notification_channel != 'none'" + else: + select_fields.append("'email' as notification_channel") + where_clause = "WHERE u.is_active = TRUE" + + eligible_users_query = f""" + SELECT {', '.join(select_fields)} + FROM users u + JOIN user_preferences up ON u.id = up.user_id + {where_clause} + """ + cur.execute(eligible_users_query) + eligible_users = cur.fetchall() + + if not eligible_users: + logger.info("No users are eligible for notifications") + return + + logger.info(f"DEBUG: Found {len(eligible_users)} eligible users for notification checking") + + # Check if we should send notifications based on time and timezone + users_to_notify_email = set() + users_to_notify_apprise = set() + for user in eligible_users: + try: + user_id, email, first_name, notification_time, timezone, frequency, apprise_notification_time, apprise_timezone, apprise_frequency, channel = user + except ValueError as e: + logger.error(f"Column unpacking error for user {user}: {e}. Expected 10 columns, got {len(user)}") + continue + + try: + # Convert UTC time to user's timezone + user_tz = pytz_timezone(timezone or 'UTC') + user_local_time = utc_now.replace(tzinfo=pytz.UTC).astimezone(user_tz) + + # Check email notifications + if channel in ['email', 'both']: + # Check if notification should be sent based on frequency + should_send = False + if frequency == 'daily': + should_send = True + elif frequency == 'weekly' and user_local_time.weekday() == 0: # Monday + should_send = True + elif frequency == 'monthly' and user_local_time.day == 1: + should_send = True + + if should_send: + # Parse notification time + time_hour, time_minute = map(int, notification_time.split(':')) + + # Get current hour and minute in user's timezone + current_hour = user_local_time.hour + current_minute = user_local_time.minute + + # Calculate minutes difference + user_minutes = time_hour * 60 + time_minute + current_minutes = current_hour * 60 + current_minute + + # Calculate time difference (positive = current time is after notification time) + time_diff = current_minutes - user_minutes + + # Handle day rollovers: if current time is much earlier, we crossed midnight + if time_diff < -720: # More than 12 hours behind, probably crossed midnight + time_diff += 1440 + elif time_diff > 720: # More than 12 hours ahead, probably went backward over midnight + time_diff -= 1440 + + # Only send if we're within 2 minutes AFTER the notification time + # This prevents sending before the time and limits duplicates + if 0 <= time_diff <= 2: + # Check if we already sent notification today + current_date = user_local_time.strftime('%Y-%m-%d') + last_sent_key = f"email_{user_id}_{current_date}" + + if last_sent_key not in last_notification_sent: + users_to_notify_email.add(user_id) + last_notification_sent[last_sent_key] = True + logger.info(f"User {email} eligible for email notification at their local time {notification_time} ({timezone}). Time diff: {time_diff} minutes") + else: + logger.debug(f"User {email} already received email notification today ({current_date})") + else: + logger.debug(f"User {email} not in email notification window. Target: {notification_time}, Current: {current_hour:02d}:{current_minute:02d}, Diff: {time_diff} minutes") + + # Check Apprise notifications + if channel in ['apprise', 'both']: + apprise_user_tz = pytz_timezone(apprise_timezone or 'UTC') + apprise_local_time = utc_now.replace(tzinfo=pytz.UTC).astimezone(apprise_user_tz) + + should_send_apprise = False + if apprise_frequency == 'daily': + should_send_apprise = True + elif apprise_frequency == 'weekly' and apprise_local_time.weekday() == 0: + should_send_apprise = True + elif apprise_frequency == 'monthly' and apprise_local_time.day == 1: + should_send_apprise = True + + if should_send_apprise: + time_hour, time_minute = map(int, apprise_notification_time.split(':')) + current_hour = apprise_local_time.hour + current_minute = apprise_local_time.minute + user_minutes = time_hour * 60 + time_minute + current_minutes = current_hour * 60 + current_minute + + # Calculate time difference (positive = current time is after notification time) + time_diff = current_minutes - user_minutes + + # Handle day rollovers: if current time is much earlier, we crossed midnight + if time_diff < -720: # More than 12 hours behind, probably crossed midnight + time_diff += 1440 + elif time_diff > 720: # More than 12 hours ahead, probably went backward over midnight + time_diff -= 1440 + + # Only send if we're within 2 minutes AFTER the notification time + if 0 <= time_diff <= 2: + # Check if we already sent Apprise notification today + current_date = apprise_local_time.strftime('%Y-%m-%d') + last_sent_key = f"apprise_{user_id}_{current_date}" + + if last_sent_key not in last_notification_sent: + users_to_notify_apprise.add(user_id) + last_notification_sent[last_sent_key] = True + logger.info(f"User {email} eligible for Apprise notification at their local time {apprise_notification_time} ({apprise_timezone}). Time diff: {time_diff} minutes") + else: + logger.debug(f"User {email} already received Apprise notification today ({current_date})") + else: + logger.debug(f"User {email} not in Apprise notification window. Target: {apprise_notification_time}, Current: {current_hour:02d}:{current_minute:02d}, Diff: {time_diff} minutes") + + except Exception as e: + logger.error(f"Error processing timezone for user {email}: {e}") + continue + + if not users_to_notify_email and not users_to_notify_apprise: + logger.info("No users are scheduled for notifications at their local time") + logger.info(f"DEBUG: Checked {len(eligible_users)} total users for notification eligibility") + for user in eligible_users[:3]: # Log first 3 users for debugging + try: + user_id, email, first_name, notification_time, timezone, frequency, apprise_notification_time, apprise_timezone, apprise_frequency, channel = user + user_tz = pytz_timezone(timezone or 'UTC') + user_local_time = utc_now.replace(tzinfo=pytz.UTC).astimezone(user_tz) + + # Calculate timing for both email and apprise + email_diff = "N/A" + apprise_diff = "N/A" + + if channel in ['email', 'both']: + try: + time_hour, time_minute = map(int, notification_time.split(':')) + user_minutes = time_hour * 60 + time_minute + current_minutes = user_local_time.hour * 60 + user_local_time.minute + email_diff = current_minutes - user_minutes + if email_diff < -720: email_diff += 1440 + elif email_diff > 720: email_diff -= 1440 + except: pass + + if channel in ['apprise', 'both']: + try: + apprise_tz = pytz_timezone(apprise_timezone or 'UTC') + apprise_local = utc_now.replace(tzinfo=pytz.UTC).astimezone(apprise_tz) + time_hour, time_minute = map(int, apprise_notification_time.split(':')) + user_minutes = time_hour * 60 + time_minute + current_minutes = apprise_local.hour * 60 + apprise_local.minute + apprise_diff = current_minutes - user_minutes + if apprise_diff < -720: apprise_diff += 1440 + elif apprise_diff > 720: apprise_diff -= 1440 + except: pass + + logger.info(f"DEBUG User {email}: channel={channel}, email_time={notification_time}(diff:{email_diff}), apprise_time={apprise_notification_time}(diff:{apprise_diff}), current_local={user_local_time.strftime('%H:%M')}, timezone={timezone}") + except Exception as e: + logger.info(f"DEBUG User {email}: Error processing - {e}") + return + + logger.info(f"Found {len(users_to_notify_email)} users eligible for email notifications, {len(users_to_notify_apprise)} users eligible for Apprise notifications") + except Exception as e: + logger.error(f"Error determining notification eligibility: {e}") + return + finally: + if conn: + release_db_connection(conn) + + expiring_warranties = get_expiring_warranties(get_db_connection, release_db_connection) + if not expiring_warranties: + logger.info("No expiring warranties found.") + return + + # Group warranties by user + users_warranties = {} + for warranty in expiring_warranties: + user_id = warranty['user_id'] + email = warranty['email'] + if email not in users_warranties: + users_warranties[email] = { + 'user_id': user_id, + 'first_name': warranty['first_name'], + 'warranties': [] + } + users_warranties[email]['warranties'].append(warranty) + + # Get SMTP settings from environment variables with fallbacks + smtp_host = os.environ.get('SMTP_HOST', 'localhost') + smtp_port = int(os.environ.get('SMTP_PORT', '1025')) + smtp_username = os.environ.get('SMTP_USERNAME', 'notifications@warracker.com') + smtp_password = os.environ.get('SMTP_PASSWORD', '') + + # Explicit SMTP_USE_TLS from environment, defaulting to true if port is 587 + smtp_use_tls_env = os.environ.get('SMTP_USE_TLS', 'not_set').lower() + + # Connect to SMTP server + try: + logger.info(f"Attempting SMTP connection to {smtp_host}:{smtp_port}") + if smtp_port == 465: + logger.info("Using SMTP_SSL for port 465.") + server = smtplib.SMTP_SSL(smtp_host, smtp_port, timeout=10) + else: + logger.info(f"Using SMTP for port {smtp_port}.") + server = smtplib.SMTP(smtp_host, smtp_port, timeout=10) + + should_use_starttls = False + if smtp_port == 587: + should_use_starttls = (smtp_use_tls_env != 'false') + logger.info(f"Port is 587. SMTP_USE_TLS set to '{smtp_use_tls_env}'. should_use_starttls: {should_use_starttls}") + elif smtp_use_tls_env == 'true': + should_use_starttls = True + logger.info(f"Port is {smtp_port}. SMTP_USE_TLS explicitly 'true'. should_use_starttls: {should_use_starttls}") + else: + logger.info(f"Port is {smtp_port}. SMTP_USE_TLS set to '{smtp_use_tls_env}'. should_use_starttls: {should_use_starttls}") + + if should_use_starttls: + logger.info("Attempting to start TLS (server.starttls()).") + server.starttls() + logger.info("STARTTLS successful.") + else: + logger.info("Not using STARTTLS based on port and SMTP_USE_TLS setting.") + + # Login if credentials are provided + if smtp_username and smtp_password: + logger.info(f"Logging in with username: {smtp_username}") + server.login(smtp_username, smtp_password) + logger.info("SMTP login successful.") + + # Send emails to each user + utc_now = datetime.utcnow() + timestamp = int(utc_now.timestamp()) + + emails_sent = 0 + + # For manual triggers, get users who have email notifications enabled + email_enabled_users = set() + if manual_trigger: + conn_manual = None + try: + conn_manual = get_db_connection() + with conn_manual.cursor() as cur: + # Check if notification_channel column exists + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name='user_preferences' AND column_name='notification_channel' + """) + has_channel_column = bool(cur.fetchone()) + + if has_channel_column: + # Get users who have email or both channels enabled + cur.execute(""" + SELECT DISTINCT u.id + FROM users u + JOIN user_preferences up ON u.id = up.user_id + WHERE u.is_active = TRUE + AND up.notification_channel IN ('email', 'both') + """) + email_enabled_users = set(row[0] for row in cur.fetchall()) + logger.info(f"Manual trigger: Found {len(email_enabled_users)} users with email notifications enabled") + else: + # Fallback for installations without notification_channel column + logger.info("Manual trigger: notification_channel column not found, enabling email for all users (fallback mode)") + email_enabled_users = set(user_data.get('user_id') for user_data in users_warranties.values()) + except Exception as e: + logger.error(f"Error checking email preferences for manual trigger: {e}") + # Fallback to all users in case of error + email_enabled_users = set(user_data.get('user_id') for user_data in users_warranties.values()) + finally: + if conn_manual: + release_db_connection(conn_manual) + + for email, user_data in users_warranties.items(): + user_id_to_check = user_data.get('user_id') + + # Check if user should receive notifications + if not manual_trigger and user_id_to_check not in users_to_notify_email: + logger.debug(f"Skipping email for {email} (user_id: {user_id_to_check}) - not in current email notification window.") + continue + + # For manual triggers, check if user has email notifications enabled + if manual_trigger and user_id_to_check not in email_enabled_users: + logger.debug(f"Manual trigger: Skipping email for {email} (user_id: {user_id_to_check}) - email notifications not enabled for this user.") + continue + + # For manual triggers, check if we've sent recently + if manual_trigger and email in last_notification_sent: + last_sent = last_notification_sent[email] + if timestamp - last_sent < 120: + logger.info(f"Manual trigger: Skipping notification for {email} - already sent within the last 2 minutes") + continue + + msg = format_expiration_email( + {'first_name': user_data['first_name'], 'email': email}, + user_data['warranties'], + get_db_connection, + release_db_connection + ) + try: + server.sendmail(smtp_username, email, msg.as_string()) + last_notification_sent[email] = timestamp + emails_sent += 1 + logger.info(f"Expiration notification email sent to {email} for {len(user_data['warranties'])} warranties at {datetime.fromtimestamp(timestamp).strftime('%Y-%m-%d %H:%M:%S')}") + except Exception as e: + logger.error(f"Error sending email to {email}: {e}") + + logger.info(f"Email notification process completed. Sent {emails_sent} emails out of {len(users_warranties)} eligible users.") + server.quit() + + except Exception as e: + logger.error(f"Error connecting to SMTP server: {e}") + logger.error(f"SMTP details - Host: {smtp_host}, Port: {smtp_port}, Username: {smtp_username}") + + # Send Apprise notifications if available and enabled + if APPRISE_AVAILABLE and apprise_handler is not None: + try: + if manual_trigger: + # For manual triggers, we still need to respect user notification preferences + # Get users who have Apprise notifications enabled + conn = None + try: + conn = get_db_connection() + with conn.cursor() as cur: + # Check if notification_channel column exists + cur.execute(""" + SELECT column_name FROM information_schema.columns + WHERE table_name='user_preferences' AND column_name='notification_channel' + """) + has_channel_column = bool(cur.fetchone()) + + if has_channel_column: + # Get users who have Apprise or both channels enabled + cur.execute(""" + SELECT DISTINCT u.id + FROM users u + JOIN user_preferences up ON u.id = up.user_id + WHERE u.is_active = TRUE + AND up.notification_channel IN ('apprise', 'both') + """) + apprise_enabled_users = [row[0] for row in cur.fetchall()] + + if apprise_enabled_users: + logger.info(f"Manual trigger: Attempting to send Apprise notifications to {len(apprise_enabled_users)} users with Apprise enabled") + apprise_results = apprise_handler.send_expiration_notifications(eligible_user_ids=apprise_enabled_users) + else: + logger.info("Manual trigger: No users have Apprise notifications enabled") + apprise_results = {"sent": 0, "errors": 0, "skipped": "No users with Apprise enabled"} + else: + # Fallback for installations without notification_channel column + logger.info("Manual trigger: notification_channel column not found, sending to all users (fallback mode)") + apprise_results = apprise_handler.send_expiration_notifications() + finally: + if conn: + release_db_connection(conn) + else: + if users_to_notify_apprise: + logger.info(f"Attempting to send Apprise notifications to {len(users_to_notify_apprise)} eligible users") + apprise_results = apprise_handler.send_expiration_notifications(eligible_user_ids=list(users_to_notify_apprise)) + else: + logger.info("No users eligible for Apprise notifications at this time") + apprise_results = {"sent": 0, "errors": 0, "skipped": "No eligible users"} + + logger.info(f"Apprise notification process completed. Results: {apprise_results}") + except Exception as e: + logger.error(f"Error sending Apprise notifications: {e}") + else: + logger.debug("Apprise notifications not available, skipping") + + except Exception as e: + logger.error(f"Error in send_expiration_notifications: {e}") + finally: + notification_lock.release() + +def should_run_scheduler(): + """Check if this is the main process that should run the scheduler""" + worker_id = os.environ.get('GUNICORN_WORKER_ID', '0') + worker_name = os.environ.get('GUNICORN_WORKER_PROCESS_NAME', '') + + # For gunicorn - only run in worker 0 + if worker_name == 'worker-0' or worker_id == '0': + logger.info(f"Scheduler will run in Gunicorn worker (ID: {worker_id}, Name: {worker_name})") + return True + # For development server or single-worker mode + elif __name__ == '__main__': + logger.info("Scheduler will run in development server") + return True + # Check if we're not in a multi-worker environment (fallback) + elif not os.environ.get('GUNICORN_WORKER_CLASS'): + logger.info("Scheduler will run - no multi-worker environment detected") + return True + + logger.info(f"Scheduler will NOT run in this worker (ID: {worker_id}, Name: {worker_name})") + return False + +def init_scheduler(get_db_connection, release_db_connection): + """Initialize the scheduler if this is the appropriate worker""" + global scheduler, scheduler_initialized + + if should_run_scheduler(): + try: + # Initialize scheduler if not already done + if scheduler is None: + scheduler = BackgroundScheduler( + job_defaults={ + 'coalesce': True, + 'max_instances': 1, + 'misfire_grace_time': 300 + } + ) + + # Create a wrapper function that includes the database functions + def notification_wrapper(): + return send_expiration_notifications( + manual_trigger=False, + get_db_connection=get_db_connection, + release_db_connection=release_db_connection + ) + + # Check for scheduled notifications every 2 minutes for more precise timing + scheduler.add_job(func=notification_wrapper, trigger="interval", minutes=2, id='notification_job') + scheduler.start() + logger.info("✅ Email notification scheduler started - checking every 2 minutes") + + # Add a shutdown hook + atexit.register(lambda: scheduler.shutdown()) + scheduler_initialized = True + return True + except Exception as e: + logger.error(f"❌ Failed to start scheduler: {e}") + scheduler_initialized = False + return False + else: + logger.info("ℹ️ Scheduler not started in this worker") + scheduler_initialized = False + return False + +def ensure_scheduler_initialized(get_db_connection, release_db_connection): + """Ensure scheduler is initialized on the first request if it wasn't at startup""" + global scheduler_initialized, scheduler_retry_attempted + if not scheduler_initialized and not scheduler_retry_attempted: + logger.info("Retrying scheduler initialization on first request...") + scheduler_initialized = init_scheduler(get_db_connection, release_db_connection) + scheduler_retry_attempted = True + +def get_scheduler_status(): + """Get current scheduler status for admin endpoints""" + global scheduler_initialized, scheduler_retry_attempted + + worker_id = os.environ.get('GUNICORN_WORKER_ID', 'unknown') + worker_name = os.environ.get('GUNICORN_WORKER_PROCESS_NAME', 'unknown') + worker_class = os.environ.get('GUNICORN_WORKER_CLASS', 'unknown') + + scheduler_jobs = [] + scheduler_running = False + + if scheduler and hasattr(scheduler, 'get_jobs'): + try: + jobs = scheduler.get_jobs() + scheduler_running = scheduler.running + scheduler_jobs = [ + { + 'id': job.id, + 'next_run_time': job.next_run_time.isoformat() if job.next_run_time else None, + 'trigger': str(job.trigger) + } + for job in jobs + ] + except Exception as e: + logger.error(f"Error getting scheduler jobs: {e}") + + return { + 'scheduler_initialized': scheduler_initialized, + 'scheduler_retry_attempted': scheduler_retry_attempted, + 'scheduler_running': scheduler_running, + 'scheduler_jobs': scheduler_jobs, + 'worker_info': { + 'worker_id': worker_id, + 'worker_name': worker_name, + 'worker_class': worker_class, + 'should_run_scheduler': should_run_scheduler() + }, + 'environment_vars': { + key: value for key, value in os.environ.items() + if key.startswith('GUNICORN_') or key in ['WARRACKER_MEMORY_MODE'] + } + } + +def trigger_notifications_manually(get_db_connection, release_db_connection): + """Manually trigger warranty expiration notifications""" + try: + logger.info("Manual notification trigger requested") + send_expiration_notifications( + manual_trigger=True, + get_db_connection=get_db_connection, + release_db_connection=release_db_connection + ) + return {'message': 'Notifications triggered successfully'}, 200 + except Exception as e: + error_msg = f"Error triggering notifications: {str(e)}" + logger.error(error_msg) + return {'message': 'Failed to trigger notifications', 'error': error_msg}, 500 diff --git a/backend/oidc_handler.py b/backend/oidc_handler.py index 3f5fd23..78a7adb 100644 --- a/backend/oidc_handler.py +++ b/backend/oidc_handler.py @@ -5,9 +5,16 @@ from datetime import datetime # Ensure timedelta is imported if used, though not from flask import Blueprint, jsonify, redirect, url_for, current_app, request, session # Import shared extensions and utilities -from backend.extensions import oauth -from backend.db_handler import get_db_connection, release_db_connection -from backend.auth_utils import generate_token +try: + # Try relative imports (when modules are in same directory) + from .extensions import oauth + from .db_handler import get_db_connection, release_db_connection + from .auth_utils import generate_token +except ImportError: + # Fallback to direct imports + from extensions import oauth + from db_handler import get_db_connection, release_db_connection + from auth_utils import generate_token import logging logger = logging.getLogger(__name__) # Or use current_app.logger inside routes diff --git a/backend/requirements.txt b/backend/requirements.txt index 9500baa..e634ae4 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -13,3 +13,4 @@ Authlib==1.3.1 requests==2.32.3 gevent==24.2.1 setuptools<81 +apprise==1.9.3 diff --git a/docker-compose.yml b/docker-compose.yml index a236ef0..928a68b 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -29,6 +29,12 @@ services: # URL settings (Important for redirects and email links) - FRONTEND_URL=${FRONTEND_URL:-http://localhost:8005} # Public URL of the frontend (matching the port mapping) - APP_BASE_URL=${APP_BASE_URL:-http://localhost:8005} # Public base URL of the application for links + # Apprise Notification Configuration + - APPRISE_ENABLED=${APPRISE_ENABLED:-false} # Enable/disable Apprise notifications + - APPRISE_URLS=${APPRISE_URLS:-} # Comma-separated list of notification URLs (e.g., "mailto://user:pass@gmail.com,discord://webhook_id/webhook_token") + - APPRISE_EXPIRATION_DAYS=${APPRISE_EXPIRATION_DAYS:-7,30} # Days before expiration to send notifications (comma-separated) + - APPRISE_NOTIFICATION_TIME=${APPRISE_NOTIFICATION_TIME:-09:00} # Time of day to send notifications (HH:MM format) + - APPRISE_TITLE_PREFIX=${APPRISE_TITLE_PREFIX:-[Warracker]} # Prefix for notification titles - PYTHONUNBUFFERED=1 # Memory optimization settings - WARRACKER_MEMORY_MODE=${WARRACKER_MEMORY_MODE:-optimized} # Options: optimized (default), ultra-light, performance diff --git a/frontend/about.html b/frontend/about.html index 28e06b6..6d7eb1f 100644 --- a/frontend/about.html +++ b/frontend/about.html @@ -90,7 +90,7 @@Version: v0.10.0.0
+Version: v0.10.1.0
Update Status: Checking for updates...
@@ -144,9 +144,21 @@