From 7535e8d1ef3eb921e0328f074a5f6e94ec4ec573 Mon Sep 17 00:00:00 2001 From: sassanix <39465071+sassanix@users.noreply.github.com> Date: Tue, 10 Jun 2025 21:59:17 -0300 Subject: [PATCH] Public Global View, Apprise Integration, Filtering, and Major Fixes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit ### Major Features - **Public Global Warranty View:** All authenticated users can now view global warranties. Admins retain full control; regular users get read-only access to others’ warranties. - **Global View Admin Controls:** Admins can now toggle global view availability and limit it to admins only via site settings. - **Global Status Dashboard View:** Extended global view to warranty statistics and dashboards with full permissions enforcement. - **Apprise Push Notifications:** Integrated Apprise for multi-platform warranty alerts with extensive backend and frontend support (80+ services). - **Warranty Type Filtering/Sorting:** Introduced dynamic, case-insensitive filtering and sorting by warranty type on the main page. - **Admin Global Warranty View:** Dedicated admin tools and UI for viewing all warranties with enhanced styling and user info. ### UX/UI Enhancements - **Product Photo Thumbnails:** Added interactive, responsive photo previews on warranty cards across all views. - **Updated Footer Links:** All "Powered by Warracker" footers now link to the official website (`https://warracker.com`). ### Fixes and Stability Improvements - **Status Dashboard Chart Fixes:** Resolved canvas reuse errors and chart switching issues. - **CSS Cache Busting:** Ensured consistent styling across domain/IP access by versioning CSS/JS and updating service worker. - **Settings Access Fixes:** Regular users can now access the settings page without triggering admin-only API calls. - **Settings Persistence Fixes:** Addressed major frontend/backend issues preventing correct saving/loading of user preferences. - **Notification Timing Overhaul:** Rewrote logic for precise notification delivery and implemented duplicate prevention. ### Security and Technical Enhancements - Global view maintains secure ownership enforcement. - Improved permission checks, graceful degradation, and responsive design across all new features. --- CHANGELOG.md | 210 +++ Docker/.env.example | 4 +- Dockerfile | 34 +- backend/app.py | 1465 +++++++++++------ backend/apprise_handler.py | 385 +++++ backend/check_apprise.py | 134 ++ backend/check_warranties.py | 129 ++ backend/create_test_warranty.py | 156 ++ backend/db_handler.py | 160 +- backend/debug_apprise_settings.py | 200 +++ backend/fix_notification_columns.py | 93 ++ backend/fix_permissions.sql | 5 +- .../011_ensure_admin_permissions.sql | 4 +- backend/migrations/025_add_warranty_type.sql | 5 + .../migrations/026_add_apprise_settings.sql | 14 + .../027_add_notification_channel.sql | 8 + .../028_fix_notification_columns.sql | 26 + .../migrations/029_add_apprise_timezone.sql | 10 + .../migrations/030_add_product_photo_path.sql | 7 + backend/notifications.py | 817 +++++++++ backend/oidc_handler.py | 13 +- backend/requirements.txt | 1 + docker-compose.yml | 6 + frontend/about.html | 25 +- frontend/auth-redirect.html | 10 +- frontend/footer-content.js | 131 ++ frontend/footer-fix.js | 103 ++ frontend/index.html | 119 +- frontend/login.html | 42 +- frontend/register.html | 31 +- frontend/reset-password-request.html | 10 +- frontend/reset-password.html | 11 +- frontend/script.js | 727 ++++++-- frontend/settings-new.html | 338 +++- frontend/settings-new.js | 1020 ++++++++++-- frontend/settings-styles.css | 276 +++- frontend/status.html | 157 +- frontend/status.js | 539 ++++-- frontend/style.css | 194 +++ frontend/sw.js | 13 +- frontend/temp-toast-debug.js | 28 + frontend/version-checker.js | 2 +- 42 files changed, 6562 insertions(+), 1100 deletions(-) create mode 100644 backend/apprise_handler.py create mode 100644 backend/check_apprise.py create mode 100644 backend/check_warranties.py create mode 100644 backend/create_test_warranty.py create mode 100644 backend/debug_apprise_settings.py create mode 100644 backend/fix_notification_columns.py create mode 100644 backend/migrations/025_add_warranty_type.sql create mode 100644 backend/migrations/026_add_apprise_settings.sql create mode 100644 backend/migrations/027_add_notification_channel.sql create mode 100644 backend/migrations/028_fix_notification_columns.sql create mode 100644 backend/migrations/029_add_apprise_timezone.sql create mode 100644 backend/migrations/030_add_product_photo_path.sql create mode 100644 backend/notifications.py create mode 100644 frontend/footer-content.js create mode 100644 frontend/footer-fix.js create mode 100644 frontend/temp-toast-debug.js 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:

- - - - - - - - - """ - - for warranty in warranties: - text_body += f"- {warranty['product_name']} (expires on {warranty['expiration_date']})\\n" - html_body += f"""\ - - - - - """ - - text_body += "\\nLog in to Warracker to view details:\\n" - text_body += f"{email_base_url}\\n\\n" # Use configurable base URL - text_body += "Manage your notification settings:\\n" - text_body += f"{email_base_url}/settings-new.html\\n" # Use configurable base URL - - html_body += f"""\ - -
Product NameExpiration 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:

+ + + + + + + + + """ + + for warranty in warranties: + text_body += f"- {warranty['product_name']} (expires on {warranty['expiration_date']})\\n" + html_body += f"""\ + + + + + """ + + text_body += "\\nLog in to Warracker to view details:\\n" + text_body += f"{email_base_url}\\n\\n" + text_body += "Manage your notification settings:\\n" + text_body += f"{email_base_url}/settings-new.html\\n" + + html_body += f"""\ + +
Product NameExpiration 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 @@

About Warracker

-

Version: v0.10.0.0

+

Version: v0.10.1.0

Update Status: Checking for updates...

@@ -144,9 +144,21 @@
- - - + + + + + + + + + + + + + + + + + + + + + - + - + - + + + + + + + + + + + + + + + + + + - - - - - diff --git a/frontend/register.html b/frontend/register.html index 9dd539b..3af1800 100644 --- a/frontend/register.html +++ b/frontend/register.html @@ -604,32 +604,15 @@ document.addEventListener('DOMContentLoaded', initializeTheme); + + + + + + - - diff --git a/frontend/reset-password-request.html b/frontend/reset-password-request.html index b1a5776..7a0742a 100644 --- a/frontend/reset-password-request.html +++ b/frontend/reset-password-request.html @@ -245,9 +245,17 @@ document.addEventListener('DOMContentLoaded', initializeTheme); + + + + + + + + + + + + + + + + + - + - + @@ -143,11 +143,12 @@

Settings

-
-
+
+

Account Settings

+
-
+
-
-
+
+

Preferences

+
-
+
@@ -261,62 +263,68 @@
- -
-
-

Email Settings

+ +
+
+

Notification Settings

+
-
- +
+
- -

Receive email alerts for warranty expirations

+ +

Choose how you want to receive notifications.

- -
-
- -
-
-
- -

How often to receive email notifications

-
- + + + +
-
-
-
- -

Time of day to receive notifications (in 24-hour format)

+ -
-
-
- -

Your local timezone for notifications

+
+
+
+ +

Time of day to receive notifications (in 24-hour format)

+
+ +
+
+ +
+
+
+ +

Your local timezone for notifications

+
+
-
- +
@@ -406,16 +414,21 @@ Send Warranty Notifications +
-
-
+
+

Site Settings

+
-
+
@@ -440,6 +453,34 @@
+ + +
+
+
+ +

Allow users to view all warranties from all users (read-only for non-owners)

+
+ +
+
+ + +
+
+
+ +

Restrict global view access to administrators only (requires Global View to be enabled)

+
+ +
+
@@ -447,11 +488,12 @@
-
-
+
+

OIDC SSO Configuration

+
-
+
@@ -500,6 +542,145 @@
+ + +
+
+

Apprise Notifications Loading...

+ +
+
+ + +
+
+
+
+ +

Enable or disable Apprise notifications system-wide

+
+ +
+
+ +
+
@@ -662,7 +843,7 @@
- +
@@ -672,36 +853,19 @@
- - + + + + + + + + - - - - + +
+ +
diff --git a/frontend/settings-new.js b/frontend/settings-new.js index 59fd735..c719715 100644 --- a/frontend/settings-new.js +++ b/frontend/settings-new.js @@ -2,15 +2,17 @@ const darkModeToggle = document.getElementById('darkModeToggle'); const darkModeToggleSetting = document.getElementById('darkModeToggleSetting'); const defaultViewSelect = document.getElementById('defaultView'); -const emailNotificationsToggle = document.getElementById('emailNotifications'); const expiringSoonDaysInput = document.getElementById('expiringSoonDays'); +const notificationChannel = document.getElementById('notificationChannel'); const notificationFrequencySelect = document.getElementById('notificationFrequency'); const notificationTimeInput = document.getElementById('notificationTime'); const timezoneSelect = document.getElementById('timezone'); const saveProfileBtn = document.getElementById('saveProfileBtn'); const savePreferencesBtn = document.getElementById('savePreferencesBtn'); -const saveEmailSettingsBtn = document.getElementById('saveEmailSettingsBtn'); +const saveNotificationSettingsBtn = document.getElementById('saveNotificationSettingsBtn'); const changePasswordBtn = document.getElementById('changePasswordBtn'); +const emailSettingsContainer = document.getElementById('emailSettingsContainer'); +const appriseSettingsContainer = document.getElementById('appriseSettingsContainer'); const passwordChangeForm = document.getElementById('passwordChangeForm'); const savePasswordBtn = document.getElementById('savePasswordBtn'); const cancelPasswordBtn = document.getElementById('cancelPasswordBtn'); @@ -40,6 +42,7 @@ const checkAdminBtn = document.getElementById('checkAdminBtn'); const showUsersBtn = document.getElementById('showUsersBtn'); const testApiBtn = document.getElementById('testApiBtn'); const triggerNotificationsBtn = document.getElementById('triggerNotificationsBtn'); +const schedulerStatusBtn = document.getElementById('schedulerStatusBtn'); const registrationEnabled = document.getElementById('registrationEnabled'); const saveSiteSettingsBtn = document.getElementById('saveSiteSettingsBtn'); const emailBaseUrlInput = document.getElementById('emailBaseUrl'); // Added for email base URL @@ -54,6 +57,26 @@ const oidcScopeInput = document.getElementById('oidcScope'); const saveOidcSettingsBtn = document.getElementById('saveOidcSettingsBtn'); const oidcRestartMessage = document.getElementById('oidcRestartMessage'); +// Apprise Settings DOM Elements +const appriseEnabledToggle = document.getElementById('appriseEnabled'); +const appriseUrlsTextarea = document.getElementById('appriseUrls'); +const appriseExpirationDaysInput = document.getElementById('appriseExpirationDays'); +const appriseNotificationTimeInput = document.getElementById('appriseNotificationTime'); +const appriseTimezoneSelect = document.getElementById('appriseTimezone'); +const appriseNotificationFrequency = document.getElementById('appriseNotificationFrequency'); +const appriseTitlePrefixInput = document.getElementById('appriseTitlePrefix'); +const appriseTestUrlInput = document.getElementById('appriseTestUrl'); +const saveAppriseSettingsBtn = document.getElementById('saveAppriseSettingsBtn'); +const testAppriseBtn = document.getElementById('testAppriseBtn'); +const validateAppriseUrlBtn = document.getElementById('validateAppriseUrlBtn'); +const triggerAppriseNotificationsBtn = document.getElementById('triggerAppriseNotificationsBtn'); +const appriseStatusBadge = document.getElementById('appriseStatusBadge'); +const appriseUrlsCount = document.getElementById('appriseUrlsCount'); +const currentAppriseExpirationDays = document.getElementById('currentAppriseExpirationDays'); +const currentAppriseNotificationTime = document.getElementById('currentAppriseNotificationTime'); +const viewSupportedServicesBtn = document.getElementById('viewSupportedServicesBtn'); +const appriseNotAvailable = document.getElementById('appriseNotAvailable'); + const currencySymbolInput = document.getElementById('currencySymbol'); const currencySymbolSelect = document.getElementById('currencySymbolSelect'); const currencySymbolCustom = document.getElementById('currencySymbolCustom'); @@ -181,6 +204,32 @@ document.addEventListener('DOMContentLoaded', function() { // --- ADD THIS LINE TO INITIALIZE MODALS --- initModals(); + + // Initialize collapsible cards + initCollapsibleCards(); + + // Load admin-only settings if user is admin + // Note: These will also be loaded later in loadUserData() with proper checks + // This is a redundant call that should be conditional + const currentUser = window.auth && window.auth.getCurrentUser ? window.auth.getCurrentUser() : null; + if (currentUser && currentUser.is_admin) { + // Load site settings (for admins) - includes OIDC settings + loadSiteSettings(); + + // Load Apprise settings + loadAppriseSettings(); + + // Load Apprise site settings (also loads overall Apprise settings) + loadAppriseSiteSettings(); + } else { + console.log('User is not admin, skipping admin-only settings load during initialization'); + } + + // Setup Apprise event listeners + setupAppriseEventListeners(); + + // Initialize delete button handling + setupDeleteButton(); }); /** @@ -481,17 +530,27 @@ function getPreferenceKeyPrefix() { return getUserType() === 'admin' ? 'admin_' : 'user_'; } +// Prevent multiple simultaneous preference loads +let isLoadingPreferences = false; + /** * Load user preferences */ async function loadPreferences() { + // Prevent multiple simultaneous loads + if (isLoadingPreferences) { + console.log('Preferences already loading, skipping duplicate call'); + return; + } + + isLoadingPreferences = true; console.log('Loading preferences...'); const prefix = getPreferenceKeyPrefix(); console.log('Loading preferences with prefix:', prefix); - let darkModeFromAPI = null; let apiPrefs = null; - // Try to load preferences from backend if authenticated + + // FIXED: Load all preferences from API first, then apply to UI if (window.auth && window.auth.isAuthenticated && window.auth.isAuthenticated()) { try { const response = await fetch('/api/auth/preferences', { @@ -501,19 +560,41 @@ async function loadPreferences() { }); if (response.ok) { apiPrefs = await response.json(); + console.log('API preferences loaded:', apiPrefs); + + // Apply theme from API immediately (highest priority) if (apiPrefs && apiPrefs.theme) { - darkModeFromAPI = apiPrefs.theme === 'dark'; - setTheme(darkModeFromAPI); - // Sync localStorage - localStorage.setItem('darkMode', darkModeFromAPI); + const isDark = apiPrefs.theme === 'dark'; + console.log('Applying theme from API:', apiPrefs.theme, 'isDark:', isDark); + setTheme(isDark); + // Sync localStorage to match API + localStorage.setItem('darkMode', isDark); + // Ensure the dark mode toggle reflects the API setting + if (darkModeToggleSetting) { + darkModeToggleSetting.checked = isDark; + console.log('Synced dark mode toggle to API value:', isDark); + } + } else { + console.log('No theme in API preferences, using localStorage fallback'); + const storedDarkMode = localStorage.getItem('darkMode') === 'true'; + setTheme(storedDarkMode); + if (darkModeToggleSetting) { + darkModeToggleSetting.checked = storedDarkMode; + } } + } else { + console.warn('API preferences request failed, using localStorage'); + const storedDarkMode = localStorage.getItem('darkMode') === 'true'; + setTheme(storedDarkMode); } } catch (e) { console.warn('Failed to load preferences from backend:', e); + // Fallback to localStorage + const storedDarkMode = localStorage.getItem('darkMode') === 'true'; + setTheme(storedDarkMode); } - } - // Fallback: use localStorage if not authenticated or API fails - if (darkModeFromAPI === null) { + } else { + console.log('Not authenticated, using localStorage for theme'); const storedDarkMode = localStorage.getItem('darkMode') === 'true'; setTheme(storedDarkMode); } @@ -574,19 +655,9 @@ async function loadPreferences() { console.log(`${prefix}expiringSoonDays not found, defaulting to 30`); } - // Now, try fetching preferences from API to override/confirm - if (window.auth && window.auth.isAuthenticated()) { - try { - const token = window.auth.getToken(); - const response = await fetch('/api/auth/preferences', { - headers: { - 'Authorization': `Bearer ${token}` - } - }); - - if (response.ok) { - const apiPrefs = await response.json(); - console.log('Preferences loaded from API:', apiPrefs); + // Apply API preferences to form elements (apiPrefs already loaded above) + if (apiPrefs) { + console.log('Applying API preferences to form elements:', apiPrefs); // Update UI elements with API data where available if (apiPrefs.default_view && defaultViewSelect) { @@ -641,8 +712,11 @@ async function loadPreferences() { // --- End Date Format Check --- // Update Email Settings from API - if (emailNotificationsToggle) { - emailNotificationsToggle.checked = apiPrefs.email_notifications !== false; // Default true if null/undefined + if (notificationChannel) { + const channelValue = apiPrefs.notification_channel || 'email'; // Default to email if not present + notificationChannel.value = channelValue; + toggleNotificationSettings(channelValue); + console.log('Set notification channel to:', channelValue); } if (notificationFrequencySelect && apiPrefs.notification_frequency) { notificationFrequencySelect.value = apiPrefs.notification_frequency; @@ -650,6 +724,22 @@ async function loadPreferences() { if (notificationTimeInput && apiPrefs.notification_time) { notificationTimeInput.value = apiPrefs.notification_time.substring(0, 5); // HH:MM format } + if (appriseNotificationTimeInput && apiPrefs.apprise_notification_time) { + appriseNotificationTimeInput.value = apiPrefs.apprise_notification_time.substring(0, 5); + } + if (appriseTimezoneSelect && apiPrefs.apprise_timezone) { + if (Array.from(appriseTimezoneSelect.options).some(option => option.value === apiPrefs.apprise_timezone)) { + appriseTimezoneSelect.value = apiPrefs.apprise_timezone; + } else { + console.warn(`Apprise timezone '${apiPrefs.apprise_timezone}' from API not found in dropdown.`); + } + } + + // Update Apprise timezone display + updateAppriseTimezoneDisplay(apiPrefs.timezone); + if (appriseNotificationFrequency && apiPrefs.apprise_notification_frequency) { + appriseNotificationFrequency.value = apiPrefs.apprise_notification_frequency; + } // Load and set timezone from API if (timezoneSelect && apiPrefs.timezone) { console.log('API provided timezone:', apiPrefs.timezone); @@ -657,19 +747,34 @@ async function loadPreferences() { if (Array.from(timezoneSelect.options).some(option => option.value === apiPrefs.timezone)) { timezoneSelect.value = apiPrefs.timezone; console.log('Applied timezone from API:', timezoneSelect.value, 'Current select value:', timezoneSelect.value); + + // Update Apprise timezone display when timezone is loaded + updateAppriseTimezoneDisplay(apiPrefs.timezone); } else { console.warn(`Timezone '${apiPrefs.timezone}' from API not found in dropdown.`); } } else { console.log('No timezone preference found in API or timezone select element missing.'); } + } + + // Reset the loading flag + isLoadingPreferences = false; + console.log('Preferences loading completed'); +} - } else { - const errorData = await response.json().catch(() => ({})); - console.warn(`Failed to load preferences from API: ${response.status}`, errorData.message || ''); - } - } catch (error) { - console.error('Error fetching preferences from API:', error); +/** + * Update the Apprise timezone display to show which timezone will be used + */ +function updateAppriseTimezoneDisplay(timezone) { + const appriseTimezoneDisplay = document.getElementById('appriseTimezoneDisplay'); + if (appriseTimezoneDisplay) { + if (timezone) { + appriseTimezoneDisplay.textContent = `(using timezone: ${timezone})`; + appriseTimezoneDisplay.style.display = 'inline'; + } else { + appriseTimezoneDisplay.textContent = '(timezone not set)'; + appriseTimezoneDisplay.style.display = 'inline'; } } } @@ -815,6 +920,12 @@ function setupEventListeners() { }); } + if (schedulerStatusBtn) { + schedulerStatusBtn.addEventListener('click', function() { + checkSchedulerStatus(); + }); + } + // Site settings save button if (saveSiteSettingsBtn) { saveSiteSettingsBtn.addEventListener('click', function() { @@ -830,8 +941,26 @@ function setupEventListeners() { } // Save email settings button - if (saveEmailSettingsBtn) { - saveEmailSettingsBtn.addEventListener('click', saveEmailSettings); + if (saveNotificationSettingsBtn) { + saveNotificationSettingsBtn.addEventListener('click', saveNotificationSettings); + } + + // Add timezone change listener to update Apprise timezone display + const timezoneSelect = document.getElementById('timezone'); + if (timezoneSelect) { + timezoneSelect.addEventListener('change', function() { + updateAppriseTimezoneDisplay(this.value); + }); + } + + if (appriseTimezoneSelect) { + loadTimezonesIntoSelect(appriseTimezoneSelect); + } + + if (notificationChannel) { + notificationChannel.addEventListener('change', (e) => { + toggleNotificationSettings(e.target.value); + }); } console.log('Event listeners setup complete'); @@ -889,6 +1018,48 @@ function initModals() { } } +/** + * Initialize collapsible cards functionality + */ +function initCollapsibleCards() { + console.log('Initializing collapsible cards...'); + + // Get all collapsible headers + const collapsibleHeaders = document.querySelectorAll('.collapsible-header'); + + // Retrieve saved states from localStorage + const savedStates = JSON.parse(localStorage.getItem('collapsibleStates') || '{}'); + + collapsibleHeaders.forEach(header => { + const targetId = header.getAttribute('data-target'); + const card = header.closest('.collapsible-card'); + + // Apply saved state or default to expanded + const isCollapsed = savedStates[targetId] === true; + if (isCollapsed) { + card.classList.add('collapsed'); + } + + // Add click event listener + header.addEventListener('click', function() { + const targetId = this.getAttribute('data-target'); + const card = this.closest('.collapsible-card'); + + // Toggle collapsed state + card.classList.toggle('collapsed'); + + // Save state to localStorage + const currentStates = JSON.parse(localStorage.getItem('collapsibleStates') || '{}'); + currentStates[targetId] = card.classList.contains('collapsed'); + localStorage.setItem('collapsibleStates', JSON.stringify(currentStates)); + + console.log(`Toggled ${targetId}: ${card.classList.contains('collapsed') ? 'collapsed' : 'expanded'}`); + }); + }); + + console.log('Collapsible cards initialized'); +} + /** * Open a modal * @param {HTMLElement} modal - The modal to open @@ -1019,12 +1190,16 @@ async function savePreferences() { console.log('Saving preferences...'); const prefix = getPreferenceKeyPrefix(); + // Get current UI state FIRST (before building preferencesToSave) + const isDark = darkModeToggleSetting ? darkModeToggleSetting.checked : false; + console.log(`Current dark mode UI state: ${isDark}`); + // --- Prepare data to save --- Add dateFormat and dark mode const preferencesToSave = { default_view: defaultViewSelect ? defaultViewSelect.value : 'grid', expiring_soon_days: expiringSoonDaysInput ? parseInt(expiringSoonDaysInput.value) : 30, date_format: dateFormatSelect ? dateFormatSelect.value : 'MDY', - theme: (localStorage.getItem('darkMode') === 'true') ? 'dark' : 'light', + theme: isDark ? 'dark' : 'light', // Use current UI state, not old localStorage }; // Handle currency symbol (standard or custom) @@ -1043,10 +1218,10 @@ async function savePreferences() { console.log(`[SavePrefs Debug] Currency Select Value: ${currencySymbolSelect ? currencySymbolSelect.value : 'N/A'}`); console.log(`[SavePrefs Debug] Custom Input Value: ${currencySymbolCustom ? currencySymbolCustom.value : 'N/A'}`); console.log(`[SavePrefs Debug] Final currencySymbol value determined: ${currencySymbol}`); + console.log(`[SavePrefs Debug] Theme being saved: ${preferencesToSave.theme} (from isDark: ${isDark})`); // +++ END DEBUG LOGGING +++ - // Save Dark Mode separately (using the single source of truth) - const isDark = darkModeToggleSetting ? darkModeToggleSetting.checked : false; + // Apply the theme to the UI (this updates localStorage too) setTheme(isDark); console.log(`Saved dark mode: ${isDark}`); @@ -1060,10 +1235,11 @@ async function savePreferences() { console.log(`Value of dateFormat in localStorage: ${localStorage.getItem('dateFormat')}`); // Try saving to API - if (window.auth && window.auth.isAuthenticated()) { + if (window.auth && window.auth.isAuthenticated()) { try { showLoading(); const token = window.auth.getToken(); + console.log('Saving preferences with token:', token ? 'present' : 'missing'); const response = await fetch('/api/auth/preferences', { method: 'PUT', headers: { @@ -1078,7 +1254,13 @@ async function savePreferences() { showToast('Preferences saved successfully.', 'success'); console.log('Preferences successfully saved to API.'); } else { - const errorData = await response.json().catch(() => ({})); + console.error('Preferences save failed. Response status:', response.status); + console.error('Response headers:', Object.fromEntries(response.headers.entries())); + const errorData = await response.json().catch((e) => { + console.error('Failed to parse error response as JSON:', e); + return {}; + }); + console.error('Error response data:', errorData); throw new Error(errorData.message || `Failed to save preferences to API: ${response.status}`); } } catch (error) { @@ -1161,42 +1343,50 @@ async function deleteAccount() { showLoading(); try { - try { - const response = await fetch('/api/auth/account', { - method: 'DELETE', - headers: { - 'Authorization': `Bearer ${window.auth.getToken()}` - } - }); - - if (response.ok) { - // Clear auth data - if (window.auth.logout) { - window.auth.logout(); - } else { - localStorage.removeItem('auth_token'); - localStorage.removeItem('user_info'); - } - - // Show success message - showToast('Account deleted successfully', 'success'); - - // Redirect to home page after a short delay - setTimeout(() => { - window.location.href = 'index.html'; - }, 2000); - } else { - // Handle error - const errorData = await response.json(); - throw new Error(errorData.message || 'Failed to delete account'); + const response = await fetch('/api/auth/account', { + method: 'DELETE', + headers: { + 'Authorization': `Bearer ${window.auth.getToken()}` } - } catch (apiError) { - console.warn('API error, showing offline message:', apiError); - showToast('Account cannot be deleted in offline mode', 'warning'); + }); + + if (response.ok) { + // Clear auth data + if (window.auth.logout) { + window.auth.logout(); + } else { + localStorage.removeItem('auth_token'); + localStorage.removeItem('user_info'); + } + + // Show success message + showToast('Account deleted successfully', 'success'); + + // Redirect to home page after a short delay + setTimeout(() => { + window.location.href = 'index.html'; + }, 2000); + } else { + // Handle error - show the actual error message from the API + let errorMessage = 'Failed to delete account'; + try { + const errorData = await response.json(); + errorMessage = errorData.message || errorMessage; + } catch (parseError) { + console.warn('Could not parse error response:', parseError); + } + + console.error('API error response:', errorMessage); + showToast(errorMessage, 'error'); } } catch (error) { - console.error('Error deleting account:', error); - showToast('Failed to delete account. Please try again.', 'error'); + console.error('Network error deleting account:', error); + // Only show offline message for actual network errors + if (error.name === 'TypeError' && error.message.includes('fetch')) { + showToast('Account cannot be deleted in offline mode', 'warning'); + } else { + showToast('Failed to delete account. Please try again.', 'error'); + } } finally { hideLoading(); @@ -2400,6 +2590,8 @@ async function loadSiteSettings() { // Query elements locally within this function scope for population const registrationToggleElem = document.getElementById('registrationEnabled'); const emailBaseUrlFieldElem = document.getElementById('emailBaseUrl'); + const globalViewToggleElem = document.getElementById('globalViewEnabled'); + const globalViewAdminOnlyToggleElem = document.getElementById('globalViewAdminOnly'); const oidcEnabledToggleElem = document.getElementById('oidcEnabled'); const oidcProviderNameInputElem = document.getElementById('oidcProviderName'); const oidcClientIdInputElem = document.getElementById('oidcClientId'); @@ -2418,6 +2610,16 @@ async function loadSiteSettings() { } }); + if (response.status === 403) { + // User is not admin, hide admin settings sections + console.log('User is not admin, hiding admin settings sections'); + const adminSection = document.getElementById('adminSection'); + if (adminSection) { + adminSection.style.display = 'none'; + } + return; + } + if (!response.ok) { throw new Error(`Failed to load site settings: ${response.status} ${response.statusText}`); } @@ -2437,6 +2639,18 @@ async function loadSiteSettings() { console.error('[SiteSettings] emailBaseUrl element NOT FOUND locally.'); } + if (globalViewToggleElem) { + globalViewToggleElem.checked = settings.global_view_enabled === 'true'; + } else { + console.error('[SiteSettings] globalViewEnabled element NOT FOUND locally.'); + } + + if (globalViewAdminOnlyToggleElem) { + globalViewAdminOnlyToggleElem.checked = settings.global_view_admin_only === 'true'; + } else { + console.error('[SiteSettings] globalViewAdminOnly element NOT FOUND locally.'); + } + // Populate OIDC settings using locally-scoped element variables if (oidcEnabledToggleElem) { console.log('[OIDC Settings] Found oidcEnabledToggleElem. Setting checked to:', settings.oidc_enabled === 'true'); @@ -2497,7 +2711,9 @@ async function loadSiteSettings() { async function saveSiteSettings() { console.log('Saving site settings (non-OIDC)...'); const registrationToggle = document.getElementById('registrationEnabled'); - const emailBaseUrlField = document.getElementById('emailBaseUrl'); + const emailBaseUrlField = document.getElementById('emailBaseUrl'); + const globalViewToggle = document.getElementById('globalViewEnabled'); + const globalViewAdminOnlyToggle = document.getElementById('globalViewAdminOnly'); const settingsToSave = {}; @@ -2505,6 +2721,14 @@ async function saveSiteSettings() { settingsToSave.registration_enabled = registrationToggle.checked; } + if (globalViewToggle) { + settingsToSave.global_view_enabled = globalViewToggle.checked; + } + + if (globalViewAdminOnlyToggle) { + settingsToSave.global_view_admin_only = globalViewAdminOnlyToggle.checked; + } + if (emailBaseUrlField) { let baseUrl = emailBaseUrlField.value.trim(); if (baseUrl && (baseUrl.startsWith('http://') || baseUrl.startsWith('https://'))) { @@ -2528,10 +2752,13 @@ async function saveSiteSettings() { try { showLoading(); + const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token'); + console.log('Saving site settings with token:', token ? 'present' : 'missing'); + const response = await fetch('/api/admin/settings', { method: 'PUT', headers: { - 'Authorization': `Bearer ${window.auth.getToken()}`, + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(settingsToSave) @@ -2572,10 +2799,13 @@ async function saveOidcSettings() { try { showLoading(); + const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token'); + console.log('Saving OIDC settings with token:', token ? 'present' : 'missing'); + const response = await fetch('/api/admin/settings', { method: 'PUT', headers: { - 'Authorization': `Bearer ${window.auth.getToken()}`, + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(oidcSettingsPayload) @@ -2895,12 +3125,91 @@ async function triggerWarrantyNotifications() { } } +/** + * Check scheduler status (admin only) + */ +async function checkSchedulerStatus() { + console.log('Checking scheduler status...'); + + try { + showLoading(); + + const response = await fetch('/api/admin/scheduler-status', { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + } + }); + + if (!response.ok) { + const errorData = await response.json().catch(() => ({ message: 'Unknown error occurred' })); + throw new Error(errorData.message || `HTTP ${response.status}: ${response.statusText}`); + } + + const status = await response.json(); + console.log('Scheduler status:', status); + + // Format the status information for display + let message = 'πŸ“Š Scheduler Status Report\n\n'; + message += `πŸš€ Initialized: ${status.scheduler_initialized ? 'βœ… Yes' : '❌ No'}\n`; + message += `πŸ”„ Running: ${status.scheduler_running ? 'βœ… Yes' : '❌ No'}\n`; + message += `πŸ“‹ Active Jobs: ${status.scheduler_jobs.length}\n`; + + if (status.scheduler_jobs.length > 0) { + message += '\nπŸ“… Scheduled Jobs:\n'; + status.scheduler_jobs.forEach(job => { + const nextRun = job.next_run_time ? + new Date(job.next_run_time).toLocaleString() : + 'Not scheduled'; + message += `β€’ ${job.id}: ${nextRun}\n`; + message += ` Trigger: ${job.trigger}\n`; + }); + } + + message += `\nπŸ”§ Worker Information:\n`; + message += `β€’ Worker ID: ${status.worker_info.worker_id}\n`; + message += `β€’ Worker Name: ${status.worker_info.worker_name}\n`; + message += `β€’ Worker Class: ${status.worker_info.worker_class}\n`; + message += `β€’ Should Run Scheduler: ${status.worker_info.should_run_scheduler ? 'βœ… Yes' : '❌ No'}\n`; + + if (status.environment_vars && Object.keys(status.environment_vars).length > 0) { + message += `\n🌍 Environment Variables:\n`; + Object.entries(status.environment_vars).forEach(([key, value]) => { + message += `β€’ ${key}: ${value}\n`; + }); + } + + // Show in alert dialog + alert(message); + + // Also show a toast with a summary + const summary = status.scheduler_running ? + 'βœ… Scheduler is running normally' : + '⚠️ Scheduler is not running - notifications may not be sent automatically'; + showToast(summary, status.scheduler_running ? 'success' : 'warning', 8000); + + } catch (error) { + console.error('Error checking scheduler status:', error); + showToast(`Error checking scheduler status: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + /** * Load available timezones from the API * @returns {Promise} A promise that resolves when timezones are loaded */ function loadTimezones() { - console.log('Loading timezones...'); + return loadTimezonesIntoSelect(timezoneSelect); +} + +function loadTimezonesIntoSelect(selectElement) { + if (!selectElement) { + return Promise.reject('Select element not provided to loadTimezonesIntoSelect'); + } + console.log(`Loading timezones into ${selectElement.id}...`); return new Promise((resolve, reject) => { fetch('/api/timezones', { method: 'GET', @@ -2917,7 +3226,7 @@ function loadTimezones() { }) .then(timezoneGroups => { // Clear loading option - timezoneSelect.innerHTML = ''; + selectElement.innerHTML = ''; // Add timezone groups and their options timezoneGroups.forEach(group => { @@ -2931,7 +3240,7 @@ function loadTimezones() { optgroup.appendChild(option); }); - timezoneSelect.appendChild(optgroup); + selectElement.appendChild(optgroup); }); // Set the current timezone from preferences @@ -2946,8 +3255,8 @@ function loadTimezones() { }); if (savedPreferences.timezone) { - timezoneSelect.value = savedPreferences.timezone; - console.log('Set timezone select to:', savedPreferences.timezone, 'Current value:', timezoneSelect.value); + selectElement.value = savedPreferences.timezone; + console.log(`Set ${selectElement.id} to:`, savedPreferences.timezone, 'Current value:', selectElement.value); resolve(); } else { // If no timezone preference found in localStorage, load from API as backup @@ -2964,8 +3273,8 @@ function loadTimezones() { // API returns preferences directly, not nested if (data && data.timezone) { console.log('Received timezone from API:', data.timezone); - timezoneSelect.value = data.timezone; - console.log('Set timezone select to:', data.timezone, 'Current value:', timezoneSelect.value); + selectElement.value = data.timezone; + console.log(`Set ${selectElement.id} to:`, data.timezone, 'Current value:', selectElement.value); } resolve(); }) @@ -2977,7 +3286,7 @@ function loadTimezones() { }) .catch(error => { console.error('Error loading timezones:', error); - timezoneSelect.innerHTML = ''; + selectElement.innerHTML = ''; reject(error); }); }); @@ -2986,73 +3295,51 @@ function loadTimezones() { /** * Save email settings */ -function saveEmailSettings() { +function toggleNotificationSettings(channel) { + if (emailSettingsContainer) { + emailSettingsContainer.style.display = (channel === 'email' || channel === 'both') ? 'block' : 'none'; + } + if (appriseSettingsContainer) { + appriseSettingsContainer.style.display = (channel === 'apprise' || channel === 'both') ? 'block' : 'none'; + } +} + +async function saveNotificationSettings() { showLoading(); try { - // Get values - const emailNotifications = emailNotificationsToggle.checked; - const notificationFrequency = notificationFrequencySelect.value; - const notificationTime = notificationTimeInput.value; - const timezone = timezoneSelect.value; - - // Validate inputs - if (!timezone) { - showToast('Please select a timezone', 'error'); - hideLoading(); - return; - } - - // Create preferences object const preferences = { - email_notifications: emailNotifications, - notification_frequency: notificationFrequency, - notification_time: notificationTime, - timezone: timezone + notification_channel: notificationChannel.value, + notification_frequency: notificationFrequencySelect.value, + notification_time: notificationTimeInput.value, + apprise_notification_time: appriseNotificationTimeInput.value, + apprise_notification_frequency: appriseNotificationFrequency.value, + timezone: timezoneSelect.value, + apprise_timezone: appriseTimezoneSelect.value }; + + const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token'); + console.log('Saving notification settings with token:', token ? 'present' : 'missing'); - // Save to API - fetch('/api/auth/preferences', { + const response = await fetch('/api/auth/preferences', { method: 'PUT', headers: { - 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json' }, body: JSON.stringify(preferences) - }) - .then(response => { - if (!response.ok) { - throw new Error('Failed to save email settings'); - } - return response.json(); - }) - .then(data => { - // Save to localStorage - const prefix = getPreferenceKeyPrefix(); - localStorage.setItem(`${prefix}emailNotifications`, emailNotifications); - localStorage.setItem(`${prefix}notificationFrequency`, notificationFrequency); - localStorage.setItem(`${prefix}notificationTime`, notificationTime); - localStorage.setItem(`${prefix}timezone`, timezone); - - showToast('Email settings saved successfully', 'success'); - }) - .catch(error => { - console.error('Error saving email settings:', error); - showToast('Error saving email settings', 'error'); - - // Save to localStorage as fallback - const prefix = getPreferenceKeyPrefix(); - localStorage.setItem(`${prefix}emailNotifications`, emailNotifications); - localStorage.setItem(`${prefix}notificationFrequency`, notificationFrequency); - localStorage.setItem(`${prefix}notificationTime`, notificationTime); - localStorage.setItem(`${prefix}timezone`, timezone); - }) - .finally(() => { - hideLoading(); }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to save notification settings'); + } + + showToast('Notification settings saved successfully', 'success'); } catch (error) { - console.error('Error in saveEmailSettings:', error); - showToast('Error saving email settings', 'error'); + console.error('Error saving notification settings:', error); + showToast(`Error saving notification settings: ${error.message}`, 'error'); + } finally { hideLoading(); } } @@ -3148,3 +3435,462 @@ if (currencySymbolSelect && currencySymbolCustom) { } }); } + +// ===================== +// APPRISE NOTIFICATIONS FUNCTIONALITY +// ===================== + +/** + * Load Apprise settings and status + */ +async function loadAppriseSettings() { + try { + // Get current Apprise status + const statusResponse = await fetch('/api/admin/apprise/status', { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + } + }); + + if (statusResponse.status === 403) { + // User is not admin, hide Apprise section + const appriseCard = document.querySelector('.card:has(#appriseStatusBadge)'); + if (appriseCard) { + appriseCard.style.display = 'none'; + } + return; + } + + if (statusResponse.status === 503) { + // Apprise not available + if (appriseNotAvailable) appriseNotAvailable.style.display = 'block'; + if (appriseStatusBadge) appriseStatusBadge.textContent = 'Not Available'; + if (appriseStatusBadge) appriseStatusBadge.className = 'badge badge-danger'; + return; + } + + const statusData = await statusResponse.json(); + + // Update status badge + if (appriseStatusBadge) { + if (statusData.available && statusData.enabled) { + appriseStatusBadge.textContent = 'Active'; + appriseStatusBadge.className = 'badge badge-success'; + } else if (statusData.available) { + appriseStatusBadge.textContent = 'Disabled'; + appriseStatusBadge.className = 'badge badge-warning'; + } else { + appriseStatusBadge.textContent = 'Not Available'; + appriseStatusBadge.className = 'badge badge-danger'; + } + } + + // Update status display + if (appriseUrlsCount) appriseUrlsCount.textContent = statusData.urls_configured || 0; + if (currentAppriseExpirationDays) currentAppriseExpirationDays.textContent = statusData.expiration_days ? statusData.expiration_days.join(', ') : '-'; + if (currentAppriseNotificationTime) currentAppriseNotificationTime.textContent = statusData.notification_time || '-'; + + // Load settings from site settings + await loadAppriseSiteSettings(); + + } catch (error) { + console.error('Error loading Apprise settings:', error); + if (appriseStatusBadge) { + appriseStatusBadge.textContent = 'Error'; + appriseStatusBadge.className = 'badge badge-danger'; + } + } +} + +/** + * Load Apprise site settings + */ +async function loadAppriseSiteSettings() { + try { + console.log('πŸ“₯ Loading Apprise site settings...'); + const response = await fetch('/api/admin/settings', { + method: 'GET', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + } + }); + + console.log('πŸ“₯ Load response status:', response.status); + + if (!response.ok) { + console.warn('⚠️ Load response not OK, skipping settings load'); + return; + } + + const data = await response.json(); + console.log('πŸ“₯ Loaded settings data:', data); + + // Check if Apprise settings exist in the data + const appriseKeys = Object.keys(data).filter(key => key.startsWith('apprise_')); + console.log('πŸ“₯ Found Apprise keys:', appriseKeys); + + // Update form fields + if (appriseEnabledToggle && data.apprise_enabled !== undefined) { + appriseEnabledToggle.checked = data.apprise_enabled === 'true'; + console.log('βœ… Set appriseEnabled:', data.apprise_enabled); + + // Show/hide settings container based on enabled state + const settingsContainer = document.getElementById('appriseSettingsContainer'); + if (settingsContainer) { + settingsContainer.style.display = data.apprise_enabled === 'true' ? 'block' : 'none'; + } + } else { + console.log('⚠️ appriseEnabled element not found or apprise_enabled data missing'); + } + + if (appriseUrlsTextarea && data.apprise_urls) { + appriseUrlsTextarea.value = data.apprise_urls.replace(/,/g, '\n'); + console.log('βœ… Set appriseUrls:', data.apprise_urls); + } else { + console.log('⚠️ appriseUrlsTextarea element not found or apprise_urls data missing'); + } + + if (appriseExpirationDaysInput && data.apprise_expiration_days) { + appriseExpirationDaysInput.value = data.apprise_expiration_days; + console.log('βœ… Set appriseExpirationDays:', data.apprise_expiration_days); + } else { + console.log('⚠️ appriseExpirationDaysInput element not found or data missing'); + } + + if (appriseNotificationTimeInput && data.apprise_notification_time) { + appriseNotificationTimeInput.value = data.apprise_notification_time; + console.log('βœ… Set appriseNotificationTime:', data.apprise_notification_time); + } else { + console.log('⚠️ appriseNotificationTimeInput element not found or data missing'); + } + + if (appriseTitlePrefixInput && data.apprise_title_prefix) { + appriseTitlePrefixInput.value = data.apprise_title_prefix; + console.log('βœ… Set appriseTitlePrefix:', data.apprise_title_prefix); + } else { + console.log('⚠️ appriseTitlePrefixInput element not found or data missing'); + } + + } catch (error) { + console.error('❌ Error loading Apprise site settings:', error); + } +} + +/** + * Save Apprise settings + */ +async function saveAppriseSettings() { + try { + console.log('πŸ” Starting saveAppriseSettings...'); + showLoading(); + + // Process URLs - convert newlines to commas and clean up + const urlsText = appriseUrlsTextarea ? appriseUrlsTextarea.value : ''; + const urls = urlsText.split(/[\n,]/) + .map(url => url.trim()) + .filter(url => url.length > 0) + .join(','); + + const settings = { + apprise_enabled: appriseEnabledToggle ? appriseEnabledToggle.checked.toString() : 'false', + apprise_urls: urls, + apprise_expiration_days: appriseExpirationDaysInput ? appriseExpirationDaysInput.value : '7,30', + apprise_notification_time: appriseNotificationTimeInput ? appriseNotificationTimeInput.value : '09:00', + apprise_title_prefix: appriseTitlePrefixInput ? appriseTitlePrefixInput.value : '[Warracker]' + }; + + console.log('πŸ“‹ Settings to save:', settings); + + const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token'); + console.log('πŸ“‘ Saving Apprise settings with token:', token ? 'present' : 'missing'); + + const response = await fetch('/api/admin/settings', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify(settings) + }); + + console.log('πŸ“‘ Response status:', response.status); + + if (!response.ok) { + const errorData = await response.json(); + console.error('❌ Response error:', errorData); + throw new Error(errorData.message || 'Failed to save Apprise settings'); + } + + const responseData = await response.json(); + console.log('βœ… Save response:', responseData); + + // Reload configuration + console.log('πŸ”„ Reloading Apprise configuration...'); + const reloadResponse = await fetch('/api/admin/apprise/reload-config', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + } + }); + + console.log('πŸ”„ Reload response status:', reloadResponse.status); + + showToast('Apprise settings saved successfully', 'success'); + + // Reload status + console.log('πŸ“± Reloading Apprise settings...'); + await loadAppriseSettings(); + + } catch (error) { + console.error('❌ Error saving Apprise settings:', error); + showToast(`Error saving Apprise settings: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +/** + * Send test Apprise notification + */ +async function sendTestAppriseNotification() { + try { + showLoading(); + + const testUrl = appriseTestUrlInput ? appriseTestUrlInput.value.trim() : null; + + const payload = testUrl ? { test_url: testUrl } : {}; + + const response = await fetch('/api/admin/apprise/test', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + }, + body: JSON.stringify(payload) + }); + + const data = await response.json(); + + if (data.success) { + showToast('Test notification sent successfully', 'success'); + } else { + showToast(`Failed to send test notification: ${data.message}`, 'error'); + } + + } catch (error) { + console.error('Error sending test notification:', error); + showToast(`Error sending test notification: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +/** + * Validate Apprise URLs + */ +async function validateAppriseUrls() { + try { + showLoading(); + + const urlsText = appriseUrlsTextarea ? appriseUrlsTextarea.value : ''; + const urls = urlsText.split(/[\n,]/) + .map(url => url.trim()) + .filter(url => url.length > 0); + + if (urls.length === 0) { + showToast('No URLs to validate', 'warning'); + hideLoading(); + return; + } + + let validCount = 0; + let invalidUrls = []; + + for (const url of urls) { + try { + const response = await fetch('/api/admin/apprise/validate-url', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ url: url }) + }); + + const data = await response.json(); + + if (data.valid) { + validCount++; + } else { + invalidUrls.push(url); + } + } catch (error) { + invalidUrls.push(url); + } + } + + let message = `Validation complete: ${validCount}/${urls.length} URLs are valid`; + if (invalidUrls.length > 0) { + message += `\nInvalid URLs: ${invalidUrls.join(', ')}`; + showToast(message, 'warning'); + } else { + showToast(message, 'success'); + } + + } catch (error) { + console.error('Error validating URLs:', error); + showToast(`Error validating URLs: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +/** + * Trigger Apprise expiration notifications + */ +async function triggerAppriseExpirationNotifications() { + try { + showLoading(); + + const response = await fetch('/api/admin/apprise/send-expiration', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + } + }); + + const data = await response.json(); + + if (data.success) { + showToast(data.message, 'success'); + } else { + showToast(`Failed to trigger notifications: ${data.message}`, 'error'); + } + + } catch (error) { + console.error('Error triggering Apprise notifications:', error); + showToast(`Error triggering notifications: ${error.message}`, 'error'); + } finally { + hideLoading(); + } +} + +/** + * View supported services + */ +function viewSupportedAppriseServices() { + window.open('https://github.com/caronc/apprise/wiki', '_blank', 'noopener,noreferrer'); +} + +/** + * Save just the Apprise enabled/disabled state + */ +async function saveAppriseEnabledState(enabled) { + try { + const token = window.auth ? window.auth.getToken() : localStorage.getItem('auth_token'); + + const response = await fetch('/api/admin/settings', { + method: 'PUT', + headers: { + 'Authorization': `Bearer ${token}`, + 'Content-Type': 'application/json' + }, + body: JSON.stringify({ + apprise_enabled: enabled.toString() + }) + }); + + if (!response.ok) { + const errorData = await response.json(); + throw new Error(errorData.message || 'Failed to save Apprise enabled state'); + } + + // Reload configuration + const reloadResponse = await fetch('/api/admin/apprise/reload-config', { + method: 'POST', + headers: { + 'Authorization': 'Bearer ' + localStorage.getItem('auth_token'), + 'Content-Type': 'application/json' + } + }); + + console.log('βœ… Apprise enabled state saved:', enabled); + + // Update status badge + await loadAppriseSettings(); + + } catch (error) { + console.error('❌ Error saving Apprise enabled state:', error); + showToast(`Error updating Apprise setting: ${error.message}`, 'error'); + + // Revert the toggle if save failed + if (appriseEnabledToggle) { + appriseEnabledToggle.checked = !enabled; + } + } +} + +/** + * Setup Apprise event listeners + */ +function setupAppriseEventListeners() { + // Enable/disable toggle + if (appriseEnabledToggle) { + appriseEnabledToggle.addEventListener('change', async function() { + const settingsContainer = document.getElementById('appriseSettingsContainer'); + if (settingsContainer) { + settingsContainer.style.display = this.checked ? 'block' : 'none'; + } + + // Auto-save the enabled state + await saveAppriseEnabledState(this.checked); + }); + } + + // Save settings + if (saveAppriseSettingsBtn) { + saveAppriseSettingsBtn.addEventListener('click', saveAppriseSettings); + } + + // Test notification + if (testAppriseBtn) { + testAppriseBtn.addEventListener('click', sendTestAppriseNotification); + } + + // Validate URLs + if (validateAppriseUrlBtn) { + validateAppriseUrlBtn.addEventListener('click', validateAppriseUrls); + } + + // Trigger expiration notifications + if (triggerAppriseNotificationsBtn) { + triggerAppriseNotificationsBtn.addEventListener('click', triggerAppriseExpirationNotifications); + } + + // View supported services + if (viewSupportedServicesBtn) { + viewSupportedServicesBtn.addEventListener('click', viewSupportedAppriseServices); + } +} + +// Initialize Apprise functionality +document.addEventListener('DOMContentLoaded', function() { + setupAppriseEventListeners(); + + // Load Apprise settings after auth is ready (admin only) + setTimeout(() => { + if (window.auth && window.auth.isAuthenticated && window.auth.isAuthenticated()) { + const currentUser = window.auth.getCurrentUser(); + if (currentUser && currentUser.is_admin) { + loadAppriseSettings(); + } else { + console.log('User is not admin, skipping Apprise settings load in deferred initialization'); + } + } + }, 1000); +}); diff --git a/frontend/settings-styles.css b/frontend/settings-styles.css index 6adf2c9..3c1a642 100644 --- a/frontend/settings-styles.css +++ b/frontend/settings-styles.css @@ -950,4 +950,278 @@ input:checked + .toggle-slider:before { :root[data-theme="dark"] .current-user-info strong { color: var(--primary-color); -} \ No newline at end of file +} + +/* Apprise Notifications Styles */ +.badge { + display: inline-block; + padding: 2px 8px; + font-size: 0.75rem; + font-weight: 600; + border-radius: 12px; + text-transform: uppercase; + letter-spacing: 0.5px; +} + +.badge-success { + background-color: #28a745; + color: white; +} + +.badge-warning { + background-color: #ffc107; + color: #212529; +} + +.badge-danger { + background-color: #dc3545; + color: white; +} + +.alert { + padding: 12px 16px; + margin-bottom: 20px; + border: 1px solid transparent; + border-radius: 6px; + display: flex; + align-items: center; + gap: 10px; +} + +.alert-warning { + color: #856404; + background-color: #fff3cd; + border-color: #ffeaa7; +} + +.dark-mode .alert-warning { + color: #ffc107; + background-color: rgba(255, 193, 7, 0.1); + border-color: rgba(255, 193, 7, 0.3); +} + +.apprise-actions { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 10px; + margin-top: 20px; +} + +.apprise-actions .btn { + display: flex; + align-items: center; + justify-content: center; + gap: 8px; + padding: 10px 16px; + font-size: 0.9rem; +} + +.status-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(200px, 1fr)); + gap: 15px; + margin-top: 10px; +} + +.status-item { + display: flex; + justify-content: space-between; + align-items: center; + padding: 12px 16px; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 6px; + border-left: 4px solid var(--primary-color); +} + +.dark-mode .status-item { + background-color: rgba(255, 255, 255, 0.05); +} + +.status-label { + font-weight: 600; + color: var(--text-color); +} + +.status-value { + color: var(--primary-color); + font-weight: 500; +} + +.services-info { + padding: 15px; + background-color: rgba(0, 0, 0, 0.02); + border-radius: 6px; + border-left: 4px solid var(--primary-color); +} + +.dark-mode .services-info { + background-color: rgba(255, 255, 255, 0.05); +} + +.services-info p { + margin: 0 0 10px 0; + line-height: 1.5; +} + +.btn-link { + color: var(--primary-color); + text-decoration: none; + background: none; + border: none; + padding: 0; + cursor: pointer; + display: inline-flex; + align-items: center; + gap: 5px; + font-size: 0.9rem; +} + +.btn-link:hover { + text-decoration: underline; +} + +.btn-info { + background-color: #17a2b8; + color: white; + border: 1px solid #17a2b8; +} + +.btn-info:hover { + background-color: #138496; + border-color: #117a8b; +} + +textarea.form-control { + resize: vertical; + min-height: 100px; +} + +@media (max-width: 768px) { + .apprise-actions { + grid-template-columns: 1fr; + } + + .status-grid { + grid-template-columns: 1fr; + } + + .status-item { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } +} + +/* Dark mode link styling for text-muted elements */ +.dark-mode .text-muted a { + color: var(--primary-color); + text-decoration: underline; +} + +.dark-mode .text-muted a:hover { + color: #6db4ff; + text-decoration: underline; +} + +/* General link styling for better dark mode visibility */ +:root[data-theme="dark"] .text-muted a { + color: var(--primary-color); + text-decoration: underline; +} + +:root[data-theme="dark"] .text-muted a:hover { + color: #6db4ff; + text-decoration: underline; +} + +/* Collapsible Cards */ +.collapsible-card { + transition: all 0.3s ease; +} + +.collapsible-header { + cursor: pointer; + display: flex; + justify-content: space-between; + align-items: center; + user-select: none; + transition: background-color 0.3s ease; +} + +.collapsible-header:hover { + background-color: rgba(0, 0, 0, 0.05); +} + +.dark-mode .collapsible-header:hover { + background-color: rgba(255, 255, 255, 0.05); +} + +.collapse-icon { + transition: transform 0.3s ease; + color: var(--text-color); + opacity: 0.7; +} + +.collapsible-header:hover .collapse-icon { + opacity: 1; +} + +.collapsible-card.collapsed .collapse-icon { + transform: rotate(-90deg); +} + +.collapsible-content { + overflow: hidden; + transition: max-height 0.3s ease, opacity 0.3s ease, padding 0.3s ease; + max-height: 2000px; /* Large enough to show all content */ + opacity: 1; +} + +.collapsible-card.collapsed .collapsible-content { + max-height: 0; + opacity: 0; + padding-top: 0; + padding-bottom: 0; +} + +/* Ensure smooth transitions for nested elements */ +.collapsible-content * { + transition: none; /* Prevent nested transitions from interfering */ +} + +@media (max-width: 768px) { + .apprise-actions { + grid-template-columns: 1fr; + } + + .status-grid { + grid-template-columns: 1fr; + } + + .status-item { + flex-direction: column; + align-items: flex-start; + gap: 5px; + } +} + +/* Footer Overrides - Ensure footer spans full width */ +.warracker-footer { + width: 100vw !important; + margin-left: calc(-50vw + 50%) !important; + margin-right: calc(-50vw + 50%) !important; + max-width: none !important; + position: relative !important; + left: 0 !important; + right: 0 !important; +} + +/* Additional mobile responsive styles if needed */ +@media (max-width: 768px) { + .warracker-footer { + width: 100vw !important; + margin-left: calc(-50vw + 50%) !important; + margin-right: calc(-50vw + 50%) !important; + } +} + diff --git a/frontend/status.html b/frontend/status.html index 2186821..3061768 100644 --- a/frontend/status.html +++ b/frontend/status.html @@ -19,17 +19,18 @@ - + - + - + + @@ -272,10 +354,23 @@
-

Warranty Status Dashboard

- +

Warranty Status Dashboard

+
+ + + +
@@ -378,11 +473,12 @@
- + + @@ -507,6 +603,26 @@ +
+ + + +
@@ -522,6 +638,21 @@
+
+ +
+ + +
+
+ +
+ +
@@ -643,12 +774,18 @@
- + + + + + + +
Product Purchase Date Expiration Date Status