Public Global View, Apprise Integration, Filtering, and Major Fixes

### 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.
This commit is contained in:
sassanix
2025-06-10 21:59:17 -03:00
parent 1dc69e16d5
commit 7535e8d1ef
42 changed files with 6562 additions and 1100 deletions
+210
View File
@@ -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
+2 -2
View File
@@ -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
+7 -27
View File
@@ -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\
+967 -498
View File
File diff suppressed because it is too large Load Diff
+385
View File
@@ -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()
+134
View File
@@ -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()
+129
View File
@@ -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")
+156
View File
@@ -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.")
+159 -1
View File
@@ -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}")
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)
+200
View File
@@ -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")
+93
View File
@@ -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()
+1 -4
View File
@@ -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
@@ -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;
@@ -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);
@@ -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;
@@ -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';
@@ -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 $$;
@@ -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;
@@ -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;
+817
View File
@@ -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"""\
<html>
<head></head>
<body>
<p>Hello {user['first_name']},</p>
<p>The following warranties are expiring soon:</p>
<table border="1" style="border-collapse: collapse;">
<thead>
<tr>
<th style="padding: 8px; text-align: left;">Product Name</th>
<th style="padding: 8px; text-align: left;">Expiration Date</th>
</tr>
</thead>
<tbody>
"""
for warranty in warranties:
text_body += f"- {warranty['product_name']} (expires on {warranty['expiration_date']})\\n"
html_body += f"""\
<tr>
<td style="padding: 8px;">{warranty['product_name']}</td>
<td style="padding: 8px;">{warranty['expiration_date']}</td>
</tr>
"""
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"""\
</tbody>
</table>
<p>Log in to <a href="{email_base_url}">Warracker</a> to view details.</p>
<p>Manage your notification settings <a href="{email_base_url}/settings-new.html">here</a>.</p>
</body>
</html>
"""
# 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
+10 -3
View File
@@ -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
+1
View File
@@ -13,3 +13,4 @@ Authlib==1.3.1
requests==2.32.3
gevent==24.2.1
setuptools<81
apprise==1.9.3
+6
View File
@@ -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
+16 -9
View File
@@ -90,7 +90,7 @@
<div class="about-container" style="background-color: var(--card-bg); color: var(--text-color); padding: 20px; border-radius: 8px; margin-top: 20px; box-shadow: 0 2px 5px rgba(0,0,0,0.1); width: 100%; box-sizing: border-box;">
<h1 style="color: var(--primary-color); margin-bottom: 20px;">About Warracker</h1>
<p><strong>Version:</strong> v0.10.0.0</p>
<p><strong>Version:</strong> v0.10.1.0</p>
<div id="versionTracker" style="margin-bottom: 20px;">
<p><strong>Update Status:</strong> <span id="updateStatus">Checking for updates...</span></p>
@@ -144,9 +144,21 @@
</div>
<!-- Scripts loaded at the end of body -->
<script src="auth.js?v=2"></script> <!-- Added for user menu and other auth interactions -->
<script src="script.js"></script> <!-- Include main site script, might contain helpers -->
<script src="version-checker.js"></script> <!-- Version checker script -->
<script src="auth.js"></script>
<!-- Version Checker -->
<script src="version-checker.js"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<!-- Explicitly call setupUIEventListeners for this page -->
<script>
@@ -335,11 +347,6 @@
<div class="loading-spinner"></div>
</div>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
+9 -1
View File
@@ -158,9 +158,17 @@
});
</script>
<script src="auth-redirect.js"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
+131
View File
@@ -0,0 +1,131 @@
/**
* Footer Content Manager - Universal
* Manages the "Powered by" footer content across all pages
* Easy to customize footer text, links, and branding from one place
*/
(function() {
'use strict';
// === FOOTER CONFIGURATION ===
// Edit these values to change footer content across all pages
const FOOTER_CONFIG = {
// Main footer text
text: 'Powered by',
// Link configuration
link: {
text: 'Warracker',
url: 'https://warracker.com',
target: '_blank',
rel: 'noopener noreferrer'
},
// Optional: Additional links or text
// Uncomment and customize as needed
/*
additionalContent: [
{ type: 'text', content: ' | ' },
{ type: 'link', text: 'Privacy Policy', url: '/privacy.html' },
{ type: 'text', content: ' | ' },
{ type: 'link', text: 'Terms of Service', url: '/terms.html' }
]
*/
};
// === FOOTER CONTENT GENERATION ===
function generateFooterContent() {
let footerHTML = `${FOOTER_CONFIG.text} `;
// Add main link
footerHTML += `<a href="${FOOTER_CONFIG.link.url}" target="${FOOTER_CONFIG.link.target}" rel="${FOOTER_CONFIG.link.rel}" id="warrackerFooterLink">${FOOTER_CONFIG.link.text}</a>`;
// Add additional content if configured
if (FOOTER_CONFIG.additionalContent) {
FOOTER_CONFIG.additionalContent.forEach(item => {
if (item.type === 'text') {
footerHTML += item.content;
} else if (item.type === 'link') {
const target = item.target || '_self';
const rel = item.rel || '';
footerHTML += `<a href="${item.url}" target="${target}" rel="${rel}">${item.text}</a>`;
}
});
}
return footerHTML;
}
// === FOOTER INITIALIZATION ===
function initFooterContent() {
const footer = document.getElementById('warrackerFooter');
if (!footer) {
console.warn('Footer element with ID "warrackerFooter" not found');
return;
}
// Generate and inject footer content
const footerContent = generateFooterContent();
footer.innerHTML = `<p>${footerContent}</p>`;
console.log('Footer content initialized successfully');
}
// === DYNAMIC FOOTER CREATION ===
// If no footer exists, create one
function createFooterIfMissing() {
let footer = document.getElementById('warrackerFooter');
if (!footer) {
footer = document.createElement('footer');
footer.className = 'warracker-footer';
footer.id = 'warrackerFooter';
// Insert before closing body tag
document.body.appendChild(footer);
console.log('Footer element created dynamically');
}
return footer;
}
// === PUBLIC API ===
// Expose functions for manual updates if needed
window.FooterContent = {
// Update footer content dynamically
update: function(newConfig) {
Object.assign(FOOTER_CONFIG, newConfig);
initFooterContent();
},
// Get current configuration
getConfig: function() {
return { ...FOOTER_CONFIG };
},
// Reinitialize footer
reinit: function() {
initFooterContent();
}
};
// === AUTO-INITIALIZATION ===
function init() {
// Ensure footer element exists
createFooterIfMissing();
// Initialize footer content
initFooterContent();
}
// Initialize when DOM is ready
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', init);
} else {
// DOM is already loaded
init();
}
console.log('Footer Content Manager loaded');
})();
+103
View File
@@ -0,0 +1,103 @@
/**
* Footer Width Fix - Universal
* Ensures the Warracker footer spans full width across all pages
* Handles both light and dark themes automatically
*/
(function() {
'use strict';
// Apply footer styles based on theme
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
if (!footer) return; // Exit if footer doesn't exist on this page
// Detect dark mode
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' ||
document.documentElement.classList.contains('dark-mode') ||
document.body.classList.contains('dark-mode');
if (isDarkMode) {
// Dark mode styles with full width override
footer.style.cssText = `
width: 100vw !important;
margin-top: 50px !important;
margin-left: calc(-50vw + 50%) !important;
margin-right: calc(-50vw + 50%) !important;
padding: 20px !important;
text-align: center !important;
border-top: 1px solid #444 !important;
background-color: #2d2d2d !important;
color: #e0e0e0 !important;
font-size: 0.9rem !important;
position: relative !important;
left: 0 !important;
right: 0 !important;
max-width: none !important;
box-sizing: border-box !important;
`;
if (link) {
link.style.cssText = 'color: #4dabf7 !important; text-decoration: none !important; font-weight: 500 !important;';
}
} else {
// Light mode styles with full width override
footer.style.cssText = `
width: 100vw !important;
margin-top: 50px !important;
margin-left: calc(-50vw + 50%) !important;
margin-right: calc(-50vw + 50%) !important;
padding: 20px !important;
text-align: center !important;
border-top: 1px solid #e0e0e0 !important;
background-color: #ffffff !important;
color: #333333 !important;
font-size: 0.9rem !important;
position: relative !important;
left: 0 !important;
right: 0 !important;
max-width: none !important;
box-sizing: border-box !important;
`;
if (link) {
link.style.cssText = 'color: #3498db !important; text-decoration: none !important; font-weight: 500 !important;';
}
}
}
// Initialize footer styles when DOM is ready
function initFooterFix() {
applyFooterStyles();
// Watch for theme changes on document.documentElement
const observer = new MutationObserver(applyFooterStyles);
observer.observe(document.documentElement, {
attributes: true,
attributeFilter: ['data-theme', 'class']
});
// Also watch body for theme changes (fallback)
observer.observe(document.body, {
attributes: true,
attributeFilter: ['class']
});
// Listen for custom theme change events if they exist
document.addEventListener('themeChanged', applyFooterStyles);
window.addEventListener('themeChanged', applyFooterStyles);
}
// Auto-initialize when DOM is loaded
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initFooterFix);
} else {
// DOM is already loaded
initFooterFix();
}
// Expose function globally in case manual calls are needed
window.applyFooterFix = applyFooterStyles;
console.log('Footer Fix loaded - Footer will span full width on all pages');
})();
+113 -6
View File
@@ -22,17 +22,17 @@
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png?v=2">
<link rel="apple-touch-icon" sizes="180x180" href="img/favicon-512x512.png">
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css?v=20250529005">
<script src="theme-loader.js"></script> <!-- Apply theme early -->
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Load header fix styles to ensure consistent header styling -->
<link rel="stylesheet" href="header-fix.css">
<link rel="stylesheet" href="header-fix.css?v=20250529005">
<!-- Load fix for auth buttons -->
<script src="fix-auth-buttons-loader.js"></script>
<!-- Mobile Header specific styles -->
<link rel="stylesheet" href="mobile-header.css">
<link rel="stylesheet" href="mobile-header.css?v=20250529005">
<!-- Immediate authentication check script -->
<script>
@@ -151,7 +151,7 @@
<div class="warranties-panel">
<!-- Panel Header -->
<div class="panel-header">
<h2>Your Warranties</h2>
<h2 id="warrantiesPanelTitle">Your Warranties</h2>
<!-- ADD BUTTON CONTAINER AND REFRESH BUTTON WRAPPER HERE -->
<div class="panel-header-actions">
<div class="add-warranty-button-container">
@@ -218,6 +218,14 @@
</select>
</div>
<div class="filter-group">
<label class="filter-label">Warranty Type</label>
<select id="warrantyTypeFilter" class="filter-select">
<option value="all">All Types</option>
<!-- Warranty type options will be populated by JavaScript -->
</select>
</div>
<div class="filter-group">
<label class="filter-label">Sort By</label>
<select id="sortBy" class="filter-select">
@@ -225,6 +233,7 @@
<option value="purchase">Purchase Date</option>
<option value="name">Product Name</option>
<option value="vendor">Vendor</option>
<option value="warranty_type">Warranty Type</option>
</select>
</div>
@@ -242,6 +251,19 @@
</button>
</div>
</div>
<!-- Scope View Toggle -->
<div class="admin-view-switcher" id="adminViewSwitcher" style="display: none;">
<label class="filter-label">Scope</label>
<div class="view-buttons">
<button id="personalViewBtn" class="view-btn active" title="View your warranties only">
<i class="fas fa-user"></i>
</button>
<button id="globalViewBtn" class="view-btn" title="View all users' warranties (read-only for others)">
<i class="fas fa-globe"></i>
</button>
</div>
</div>
</div>
</div>
@@ -378,6 +400,26 @@
</div>
</div>
<div class="form-group">
<label for="warrantyType">Warranty Type (Optional)</label>
<select id="warrantyType" name="warranty_type" class="form-control">
<option value="">Select warranty type...</option>
<option value="Standard">Standard</option>
<option value="Extended">Extended</option>
<option value="Manufacturer">Manufacturer</option>
<option value="Third Party">Third Party</option>
<option value="Store">Store</option>
<option value="Premium">Premium</option>
<option value="Limited">Limited</option>
<option value="Full">Full</option>
<option value="Parts Only">Parts Only</option>
<option value="Labor Only">Labor Only</option>
<option value="International">International</option>
<option value="Accidental Damage">Accidental Damage</option>
<option value="other">Other (Custom)</option>
</select>
<input type="text" id="warrantyTypeCustom" name="warranty_type_custom" class="form-control" style="display: none; margin-top: 8px;" placeholder="Enter custom warranty type">
</div>
<div class="form-group">
<label for="purchasePrice">Purchase Price (Optional)</label>
<div class="price-input-wrapper">
@@ -392,6 +434,20 @@
</div>
<!-- Documents Tab -->
<div class="tab-content" id="documents">
<div class="form-group">
<label>Product Photo (Optional)</label>
<div class="file-input-wrapper">
<label for="productPhoto" class="file-input-label">
<i class="fas fa-camera"></i> Choose Photo
</label>
<input type="file" id="productPhoto" name="product_photo" class="file-input" accept=".png,.jpg,.jpeg,.webp">
</div>
<div id="productPhotoFileName" class="file-name"></div>
<div id="productPhotoPreview" class="photo-preview" style="display: none;">
<img id="productPhotoImg" src="" alt="Product Photo Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px; margin-top: 10px;">
</div>
</div>
<div class="form-group">
<label>Invoice/Receipt</label>
<div class="file-input-wrapper">
@@ -482,6 +538,10 @@
<span class="summary-label">Warranty Period:</span>
<span id="summary-warranty-duration" class="summary-value">-</span>
</div>
<div class="summary-item">
<span class="summary-label">Warranty Type:</span>
<span id="summary-warranty-type" class="summary-value">-</span>
</div>
<div class="summary-item">
<span class="summary-label">Purchase Price:</span>
<span id="summary-purchase-price" class="summary-value">-</span>
@@ -489,6 +549,10 @@
</div>
<div class="summary-section">
<h4><i class="fas fa-file-alt"></i> Documents</h4>
<div class="summary-item">
<span class="summary-label">Product Photo:</span>
<span id="summary-product-photo" class="summary-value">-</span>
</div>
<div class="summary-item">
<span class="summary-label">Invoice/Receipt:</span>
<span id="summary-invoice" class="summary-value">-</span>
@@ -644,6 +708,26 @@
</div>
</div>
<div class="form-group">
<label for="editWarrantyType">Warranty Type (Optional)</label>
<select id="editWarrantyType" name="warranty_type" class="form-control">
<option value="">Select warranty type...</option>
<option value="Standard">Standard</option>
<option value="Extended">Extended</option>
<option value="Manufacturer">Manufacturer</option>
<option value="Third Party">Third Party</option>
<option value="Store">Store</option>
<option value="Premium">Premium</option>
<option value="Limited">Limited</option>
<option value="Full">Full</option>
<option value="Parts Only">Parts Only</option>
<option value="Labor Only">Labor Only</option>
<option value="International">International</option>
<option value="Accidental Damage">Accidental Damage</option>
<option value="other">Other (Custom)</option>
</select>
<input type="text" id="editWarrantyTypeCustom" name="warranty_type_custom" class="form-control" style="display: none; margin-top: 8px;" placeholder="Enter custom warranty type">
</div>
<div class="form-group">
<label for="editPurchasePrice">Purchase Price (Optional)</label>
<div class="price-input-wrapper">
@@ -659,6 +743,22 @@
<!-- Documents Tab -->
<div class="edit-tab-content" id="edit-documents">
<div class="form-group">
<label>Product Photo (Optional)</label>
<div class="file-input-wrapper">
<label for="editProductPhoto" class="file-input-label">
<i class="fas fa-camera"></i> Choose Photo
</label>
<input type="file" id="editProductPhoto" name="product_photo" class="file-input" accept=".png,.jpg,.jpeg,.webp">
</div>
<div id="editProductPhotoFileName" class="file-name"></div>
<div id="editProductPhotoPreview" class="photo-preview" style="display: none;">
<img id="editProductPhotoImg" src="" alt="Product Photo Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px; margin-top: 10px;">
</div>
<div id="currentProductPhoto" class="mt-10"></div>
<button type="button" id="deleteProductPhotoBtn" class="btn btn-danger btn-sm mt-2" style="display:none;"><i class="fas fa-trash"></i> Delete Photo</button>
</div>
<div class="form-group">
<label>Invoice/Receipt</label>
<div class="file-input-wrapper">
@@ -671,6 +771,7 @@
<div id="currentInvoice" class="mt-10"></div>
<button type="button" id="deleteInvoiceBtn" class="btn btn-danger btn-sm mt-2" style="display:none;"><i class="fas fa-trash"></i> Delete Invoice</button>
</div>
<div class="form-group">
<label>Product Manual (Optional)</label>
<div class="file-input-wrapper">
@@ -784,11 +885,17 @@
</div>
<script src="auth.js"></script>
<script src="script.js?v=20250529004"></script>
<script src="script.js?v=20250529005"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
+16 -26
View File
@@ -301,7 +301,19 @@
</div>
<!-- Script for authentication -->
<script src="auth.js?v=20250529002"></script>
<script src="auth.js"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
document.addEventListener('DOMContentLoaded', function() {
// Check for OIDC errors in URL parameters and display them
@@ -536,34 +548,12 @@
authMessage.style.display = 'block';
}
});
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
// Footer styling (this will be replaced by footer-fix.js but kept for compatibility)
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
// Footer-fix.js will handle this, but this is a fallback
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>
+7 -24
View File
@@ -604,32 +604,15 @@
document.addEventListener('DOMContentLoaded', initializeTheme);
</script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
</body>
</html>
+9 -1
View File
@@ -245,9 +245,17 @@
document.addEventListener('DOMContentLoaded', initializeTheme);
</script>
<script src="auth.js"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
+10 -1
View File
@@ -411,9 +411,18 @@
document.addEventListener('DOMContentLoaded', initializeTheme);
</script>
<!-- Scripts -->
<script src="auth.js"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
+607 -120
View File
File diff suppressed because it is too large Load Diff
+251 -87
View File
@@ -17,17 +17,17 @@
<link rel="apple-touch-icon" sizes="180x180" href="img/favicon-512x512.png">
<link rel="manifest" href="manifest.json">
<!-- Load the main site styles first -->
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css?v=20250529005">
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Then load settings-specific styles -->
<link rel="stylesheet" href="settings-styles.css">
<link rel="stylesheet" href="settings-styles.css?v=20250529005">
<!-- Apply theme immediately -->
<script src="theme-loader.js"></script>
<!-- Load header fix styles last to override any conflicting styles -->
<link rel="stylesheet" href="header-fix.css">
<link rel="stylesheet" href="header-fix.css?v=20250529005">
<!-- Mobile Header specific styles -->
<link rel="stylesheet" href="mobile-header.css">
<link rel="stylesheet" href="mobile-header.css?v=20250529005">
<!-- Load fix for auth buttons -->
<script src="fix-auth-buttons-loader.js"></script>
@@ -143,11 +143,12 @@
<h2>Settings</h2>
<!-- Account Settings -->
<div class="card">
<div class="card-header">
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="accountSettingsContent">
<h3>Account Settings</h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body">
<div class="card-body collapsible-content" id="accountSettingsContent">
<div id="currentUserInfoDisplay" class="current-user-info" style="margin-bottom: 20px; padding-bottom: 15px; border-bottom: 1px solid var(--border-color);">
<p style="margin: 0; font-size: 1.1rem;"><strong>Editing Profile for:</strong> <span id="currentUserNameDisplay" style="font-weight: normal;">Loading...</span></p>
<p style="margin: 5px 0 0 0; font-size: 0.9rem; color: var(--text-muted);">Your email: <span id="currentUserEmailDisplay">Loading...</span></p>
@@ -174,11 +175,12 @@
</div>
<!-- Preferences -->
<div class="card">
<div class="card-header">
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="preferencesContent">
<h3>Preferences</h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body">
<div class="card-body collapsible-content" id="preferencesContent">
<form id="preferencesForm">
<div class="form-group">
<div class="preference-item">
@@ -261,62 +263,68 @@
</div>
</div>
<!-- Email Settings -->
<div class="card">
<div class="card-header">
<h3>Email Settings</h3>
<!-- Notification Settings -->
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="notificationSettingsContent">
<h3>Notification Settings</h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body">
<form id="emailPreferencesForm"> <!-- Consider if a separate form/save button is needed, or if settings.js handles this -->
<div class="card-body collapsible-content" id="notificationSettingsContent">
<form id="notificationPreferencesForm">
<div class="form-group">
<div class="preference-item">
<div>
<label>Email Notifications</label>
<p class="text-muted">Receive email alerts for warranty expirations</p>
<label>Notification Channel</label>
<p class="text-muted">Choose how you want to receive notifications.</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="emailNotifications">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div class="form-group">
<div class="preference-item">
<div>
<label>Notification Frequency</label>
<p class="text-muted">How often to receive email notifications</p>
</div>
<select id="notificationFrequency" class="form-control">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
<select id="notificationChannel" class="form-control">
<option value="none">Disabled</option>
<option value="email">Email</option>
<option value="apprise">Apprise</option>
<option value="both">Both</option>
</select>
</div>
</div>
<div class="form-group">
<div class="preference-item">
<div>
<label>Notification Time</label>
<p class="text-muted">Time of day to receive notifications (in 24-hour format)</p>
<div id="emailSettingsContainer" style="display: none;">
<h4>Email Settings</h4>
<div class="form-group">
<div class="preference-item">
<div>
<label>Notification Frequency</label>
<p class="text-muted">How often to receive email notifications</p>
</div>
<select id="notificationFrequency" class="form-control">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
<input type="time" id="notificationTime" class="form-control" value="09:00">
</div>
</div>
<div class="form-group">
<div class="preference-item">
<div>
<label>Timezone</label>
<p class="text-muted">Your local timezone for notifications</p>
<div class="form-group">
<div class="preference-item">
<div>
<label>Notification Time</label>
<p class="text-muted">Time of day to receive notifications (in 24-hour format)</p>
</div>
<input type="time" id="notificationTime" class="form-control" value="09:00">
</div>
</div>
<div class="form-group">
<div class="preference-item">
<div>
<label>Timezone</label>
<p class="text-muted">Your local timezone for notifications</p>
</div>
<select id="timezone" class="form-control">
<option value="">Loading timezones...</option>
</select>
</div>
<select id="timezone" class="form-control">
<option value="">Loading timezones...</option>
</select>
</div>
</div>
<button type="button" id="saveEmailSettingsBtn" class="btn btn-primary">Save Email Settings</button>
<button type="button" id="saveNotificationSettingsBtn" class="btn btn-primary">Save Notification Settings</button>
</form>
</div>
</div>
@@ -406,16 +414,21 @@
<i class="fas fa-envelope"></i>
<span>Send Warranty Notifications</span>
</button>
<button id="schedulerStatusBtn" class="admin-action-btn">
<i class="fas fa-clock"></i>
<span>Check Scheduler Status</span>
</button>
</div>
</div>
</div>
<!-- Site Settings -->
<div class="card">
<div class="card-header">
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="siteSettingsContent">
<h3>Site Settings</h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body">
<div class="card-body collapsible-content" id="siteSettingsContent">
<form id="siteSettingsForm">
<div class="form-group">
<div class="preference-item">
@@ -440,6 +453,34 @@
<input type="text" id="emailBaseUrl" class="form-control" placeholder="http://localhost:8080">
</div>
</div>
<!-- Global View Setting -->
<div class="form-group">
<div class="preference-item">
<div>
<label>Global View Enabled</label>
<p class="text-muted">Allow users to view all warranties from all users (read-only for non-owners)</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="globalViewEnabled">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<!-- Global View Admin Only Setting -->
<div class="form-group">
<div class="preference-item">
<div>
<label>Global View Admin Only</label>
<p class="text-muted">Restrict global view access to administrators only (requires Global View to be enabled)</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="globalViewAdminOnly">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<button type="button" id="saveSiteSettingsBtn" class="btn btn-primary">Save Site Settings</button>
</form>
@@ -447,11 +488,12 @@
</div>
<!-- OIDC SSO Configuration Card -->
<div class="card">
<div class="card-header">
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="oidcSettingsContent">
<h3>OIDC SSO Configuration</h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body">
<div class="card-body collapsible-content" id="oidcSettingsContent">
<form id="oidcSettingsForm">
<div class="form-group">
<div class="preference-item">
@@ -500,6 +542,145 @@
</form>
</div>
</div>
<!-- Apprise Notifications Configuration Card -->
<div class="card collapsible-card">
<div class="card-header collapsible-header" data-target="appriseNotificationsContent">
<h3>Apprise Notifications <span class="badge" id="appriseStatusBadge">Loading...</span></h3>
<i class="fas fa-chevron-down collapse-icon"></i>
</div>
<div class="card-body collapsible-content" id="appriseNotificationsContent">
<div id="appriseNotAvailable" class="alert alert-warning" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<strong>Apprise Not Available:</strong> The Apprise notification library is not installed or available. Notification features are disabled.
</div>
<form id="appriseSettingsForm">
<div class="form-group">
<div class="preference-item">
<div>
<label>Enable Apprise Notifications</label>
<p class="text-muted">Enable or disable Apprise notifications system-wide</p>
</div>
<label class="toggle-switch">
<input type="checkbox" id="appriseEnabled">
<span class="toggle-slider"></span>
</label>
</div>
</div>
<div id="appriseSettingsContainer" style="display: none;">
<h4>Apprise Settings</h4>
<div class="form-group">
<label for="appriseUrls">Notification URLs</label>
<textarea id="appriseUrls" class="form-control" rows="4" placeholder="Enter Apprise notification URLs, one per line or comma-separated:&#10;&#10;mailto://user:password@gmail.com&#10;discord://webhook_id/webhook_token&#10;slack://TokenA/TokenB/TokenC/Channel&#10;telegram://BotToken/ChatID"></textarea>
<small class="text-muted">
Configure notification services using Apprise URL format.
<a href="https://github.com/caronc/apprise/wiki" target="_blank" rel="noopener">View supported services and URL formats</a>
</small>
</div>
<div class="form-group">
<label for="appriseExpirationDays">Notification Days</label>
<input type="text" id="appriseExpirationDays" class="form-control" placeholder="7,30" value="7,30">
<small class="text-muted">Days before expiration to send notifications (comma-separated, e.g., "7,30" for 7 days and 30 days before)</small>
</div>
<div class="form-group">
<label for="appriseNotificationTime">Notification Time</label>
<input type="time" id="appriseNotificationTime" class="form-control" value="09:00">
<small class="text-muted">
Time of day to send notifications (24-hour format)
</small>
</div>
<div class="form-group">
<div class="preference-item">
<div>
<label>Timezone</label>
<p class="text-muted">Your local timezone for Apprise notifications</p>
</div>
<select id="appriseTimezone" class="form-control">
<option value="">Loading timezones...</option>
</select>
</div>
</div>
<div class="form-group">
<div class="preference-item">
<div>
<label>Notification Frequency</label>
<p class="text-muted">How often to receive email notifications</p>
</div>
<select id="appriseNotificationFrequency" class="form-control">
<option value="daily">Daily</option>
<option value="weekly">Weekly</option>
<option value="monthly">Monthly</option>
</select>
</div>
</div>
<div class="form-group">
<label for="appriseTitlePrefix">Message Title Prefix</label>
<input type="text" id="appriseTitlePrefix" class="form-control" placeholder="[Warracker]" value="[Warracker]">
<small class="text-muted">Prefix added to notification titles (e.g., "[Warracker]" results in "[Warracker] Warranties Expiring Soon")</small>
</div>
<div class="form-group">
<label for="appriseTestUrl">Test URL (Optional)</label>
<input type="text" id="appriseTestUrl" class="form-control" placeholder="mailto://test@example.com or discord://webhook_id/webhook_token">
<small class="text-muted">Optional: URL for sending test notifications only (won't be saved to main configuration)</small>
</div>
<div class="apprise-actions" style="margin-top: 20px;">
<button type="button" id="saveAppriseSettingsBtn" class="btn btn-primary">
<i class="fas fa-save"></i> Save Apprise Settings
</button>
<button type="button" id="testAppriseBtn" class="btn btn-secondary">
<i class="fas fa-paper-plane"></i> Send Test Notification
</button>
<button type="button" id="validateAppriseUrlBtn" class="btn btn-info">
<i class="fas fa-check-circle"></i> Validate URLs
</button>
<button type="button" id="triggerAppriseNotificationsBtn" class="btn btn-warning">
<i class="fas fa-bell"></i> Send Expiration Notifications Now
</button>
</div>
<div id="appriseCurrentStatus" class="mt-3" style="margin-top: 15px;">
<h4>Current Status</h4>
<div class="status-grid">
<div class="status-item">
<span class="status-label">URLs Configured:</span>
<span id="appriseUrlsCount" class="status-value">0</span>
</div>
<div class="status-item">
<span class="status-label">Notification Days:</span>
<span id="currentAppriseExpirationDays" class="status-value">-</span>
</div>
<div class="status-item">
<span class="status-label">Notification Time:</span>
<span id="currentAppriseNotificationTime" class="status-value">-</span>
</div>
</div>
</div>
<div id="appriseSupportedServices" class="mt-3" style="margin-top: 15px;">
<h4>Supported Services</h4>
<div class="services-info">
<p class="text-muted">
Apprise supports 80+ notification services including:
Discord, Slack, Telegram, Email (Gmail, Outlook), Webhooks, Microsoft Teams,
Matrix, Pushover, Ntfy, Gotify, and many more.
</p>
<button type="button" id="viewSupportedServicesBtn" class="btn btn-link">
<i class="fas fa-external-link-alt"></i> View Full List of Supported Services
</button>
</div>
</div>
</form>
</div>
</div>
</div>
</div>
</div>
@@ -662,7 +843,7 @@
</div>
</div>
</div>
<!-- Loading Container -->
<div id="loadingContainer" class="loading-container">
<div class="spinner"></div>
@@ -672,36 +853,19 @@
<div id="toastContainer" class="toast-container"></div>
<!-- Scripts -->
<script src="auth.js?v=4"></script>
<script src="settings-new.js?v=20250529001"></script>
<script src="auth.js?v=20250529005"></script>
<script src="settings-new.js?v=20250529005"></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
</footer>
<script>
function applyFooterStyles() {
const footer = document.getElementById('warrackerFooter');
const link = document.getElementById('warrackerFooterLink');
const isDarkMode = document.documentElement.getAttribute('data-theme') === 'dark' || document.documentElement.classList.contains('dark-mode') || document.body.classList.contains('dark-mode');
if (footer) {
if (isDarkMode) {
// Dark mode styles - using same background as header (#2d2d2d)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #444; background-color: #2d2d2d; color: #e0e0e0; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #4dabf7; text-decoration: none; font-weight: 500;';
} else {
// Light mode styles - using same background as header (#ffffff)
footer.style.cssText = 'margin-top: 50px; padding: 20px; text-align: center; border-top: 1px solid #e0e0e0; background-color: #ffffff; color: #333333; font-size: 0.9rem;';
if (link) link.style.cssText = 'color: #3498db; text-decoration: none; font-weight: 500;';
}
}
}
document.addEventListener('DOMContentLoaded', applyFooterStyles);
const obs = new MutationObserver(applyFooterStyles);
obs.observe(document.documentElement, { attributes: true, attributeFilter: ['data-theme', 'class'] });
obs.observe(document.body, { attributes: true, attributeFilter: ['class'] });
</script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
</body>
</html>
+883 -137
View File
File diff suppressed because it is too large Load Diff
+275 -1
View File
@@ -950,4 +950,278 @@ input:checked + .toggle-slider:before {
:root[data-theme="dark"] .current-user-info strong {
color: var(--primary-color);
}
}
/* 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;
}
}
+147 -10
View File
@@ -19,17 +19,18 @@
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png?v=2">
<link rel="apple-touch-icon" sizes="180x180" href="img/favicon-512x512.png">
<link rel="manifest" href="manifest.json">
<link rel="stylesheet" href="style.css">
<link rel="stylesheet" href="style.css?v=20250529005">
<script src="theme-loader.js"></script> <!-- Apply theme early -->
<!-- Font Awesome for icons -->
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
<!-- Load header fix styles to ensure consistent header styling -->
<link rel="stylesheet" href="header-fix.css">
<link rel="stylesheet" href="header-fix.css?v=20250529005">
<!-- Chart.js for visualizations -->
<script src="chart.js"></script>
<!-- Load fix for auth buttons -->
<script src="fix-auth-buttons-loader.js"></script>
<script src="script.js" defer></script> <!-- Added script.js -->
<script src="script.js?v=20250529005" defer></script> <!-- Added script.js -->
<script src="status.js" defer></script> <!-- Status page specific functionality -->
<style>
.user-menu {
position: relative;
@@ -160,6 +161,87 @@
display: none;
}
}
/* Dashboard header styles */
.dashboard-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 2rem;
flex-wrap: wrap;
}
.dashboard-controls {
display: flex;
align-items: center;
gap: 1rem;
}
/* View switcher styles */
.view-switcher {
display: flex;
align-items: center;
}
.toggle-group {
display: flex;
background-color: var(--card-bg);
border: 1px solid var(--border-color);
border-radius: 8px;
overflow: hidden;
box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}
.toggle-btn {
background: none;
border: none;
padding: 8px 16px;
cursor: pointer;
font-size: 0.9rem;
transition: all 0.3s ease;
display: flex;
align-items: center;
gap: 6px;
color: var(--text-color);
min-width: 100px;
justify-content: center;
}
.toggle-btn:hover {
background-color: var(--hover-bg);
}
.toggle-btn.active {
background-color: var(--primary-color);
color: white;
}
.toggle-btn.active:hover {
background-color: var(--primary-dark);
}
.toggle-btn i {
font-size: 0.8rem;
}
@media (max-width: 768px) {
.dashboard-header {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.dashboard-controls {
width: 100%;
justify-content: space-between;
}
.toggle-btn {
min-width: 80px;
padding: 6px 12px;
font-size: 0.8rem;
}
}
</style>
<!-- Immediate authentication check script -->
@@ -272,10 +354,23 @@
<div class="container">
<div class="status-content">
<div class="dashboard-header">
<h2>Warranty Status Dashboard</h2>
<button id="refreshDashboardBtn" class="refresh-btn" title="Refresh dashboard">
<i class="fas fa-sync-alt"></i>
</button>
<h2 id="dashboardTitle">Warranty Status Dashboard</h2>
<div class="dashboard-controls">
<!-- Global View Controls (hidden by default, shown for eligible users) -->
<div id="viewSwitcher" class="view-switcher" style="display: none;">
<div class="toggle-group">
<button id="personalViewBtn" class="toggle-btn active">
<i class="fas fa-user"></i>
</button>
<button id="globalViewBtn" class="toggle-btn">
<i class="fas fa-globe"></i>
</button>
</div>
</div>
<button id="refreshDashboardBtn" class="refresh-btn" title="Refresh dashboard">
<i class="fas fa-sync-alt"></i>
</button>
</div>
</div>
<!-- Loading Indicator -->
@@ -378,11 +473,12 @@
<div class="table-responsive">
<table id="recentExpirationsTable">
<thead>
<tr>
<tr id="tableHeader">
<th class="sortable" data-sort="product">Product <i class="fas fa-sort"></i></th>
<th class="sortable" data-sort="purchase">Purchase Date <i class="fas fa-sort"></i></th>
<th class="sortable" data-sort="expiration">Expiration Date <i class="fas fa-sort"></i></th>
<th class="sortable" data-sort="status">Status <i class="fas fa-sort"></i></th>
<th id="ownerHeader" class="sortable" data-sort="owner" style="display: none;">Owner <i class="fas fa-sort"></i></th>
</tr>
</thead>
<tbody id="recentExpirationsBody">
@@ -507,6 +603,26 @@
</div>
</div>
<div class="form-group">
<label for="editWarrantyType">Warranty Type (Optional)</label>
<select id="editWarrantyType" name="warranty_type" class="form-control">
<option value="">Select warranty type...</option>
<option value="Standard">Standard</option>
<option value="Extended">Extended</option>
<option value="Manufacturer">Manufacturer</option>
<option value="Third Party">Third Party</option>
<option value="Store">Store</option>
<option value="Premium">Premium</option>
<option value="Limited">Limited</option>
<option value="Full">Full</option>
<option value="Parts Only">Parts Only</option>
<option value="Labor Only">Labor Only</option>
<option value="International">International</option>
<option value="Accidental Damage">Accidental Damage</option>
<option value="other">Other (Custom)</option>
</select>
<input type="text" id="editWarrantyTypeCustom" name="warranty_type_custom" class="form-control" style="display: none; margin-top: 8px;" placeholder="Enter custom warranty type">
</div>
<div class="form-group">
<label for="editPurchasePrice">Purchase Price (Optional)</label>
<div class="price-input-wrapper">
@@ -522,6 +638,21 @@
<!-- Documents Tab -->
<div class="edit-tab-content" id="edit-documents">
<div class="form-group">
<label>Product Photo (Optional)</label>
<div class="file-input-wrapper">
<label for="editProductPhoto" class="file-input-label">
<i class="fas fa-upload"></i> Choose Photo
</label>
<input type="file" id="editProductPhoto" name="product_photo" class="file-input" accept=".png,.jpg,.jpeg,.webp">
</div>
<div id="editProductPhotoFileName" class="file-name"></div>
<div id="editProductPhotoPreview" class="photo-preview" style="display: none;">
<img id="editProductPhotoImg" src="" alt="Product Photo Preview" style="max-width: 200px; max-height: 200px; border-radius: 8px; margin-top: 10px;">
</div>
<div id="currentProductPhoto" class="mt-10"></div>
<button type="button" id="deleteProductPhotoBtn" class="btn btn-danger btn-sm mt-2" style="display:none;"><i class="fas fa-trash"></i> Delete Photo</button>
</div>
<div class="form-group">
<label>Invoice/Receipt</label>
<div class="file-input-wrapper">
@@ -643,12 +774,18 @@
<div class="loading-spinner"></div>
</div>
<script src="auth.js"></script>
<script src="auth.js?v=20250529005"></script>
<script src="status.js" defer></script>
<!-- Footer Width Fix -->
<script src="footer-fix.js"></script>
<!-- Footer Content Manager -->
<script src="footer-content.js"></script>
<!-- Powered by Warracker Footer -->
<footer class="warracker-footer" id="warrackerFooter">
<p>Powered by <a href="https://warracker.com" target="_blank" rel="noopener noreferrer" id="warrackerFooterLink">Warracker</a></p>
<!-- Content will be dynamically generated by footer-content.js -->
</footer>
<script>
+403 -136
View File
@@ -15,7 +15,9 @@
// Configuration
const STATUS_PAGE_API_BASE_URL = '/api/statistics';
const GLOBAL_STATUS_PAGE_API_BASE_URL = '/api/statistics/global';
const STATISTICS_API_URL = window.location.origin + STATUS_PAGE_API_BASE_URL;
const GLOBAL_STATISTICS_API_URL = window.location.origin + GLOBAL_STATUS_PAGE_API_BASE_URL;
let EXPIRING_SOON_DAYS = 30;
// IIFE-local variables
@@ -26,6 +28,11 @@
let currentStatusData = null;
let currentTimelineData = null;
let userCurrencySymbol = '$'; // Default currency symbol
let isGlobalView = false; // Track current view mode
let isViewControlsInitialized = false;
let isDashboardInitialized = false; // Prevent multiple initializations
let isDOMHandlerAttached = false; // Prevent multiple DOM handlers
let initDashboardPromise = null; // Track ongoing initialization
function setTheme(isDark) {
const theme = isDark ? 'dark' : 'light';
@@ -47,19 +54,25 @@
function redrawChartsWithNewTheme() {
console.log("Theme changed, redrawing charts from status.js IIFE...");
if (statusChart && typeof statusChart.destroy === 'function') {
statusChart.destroy();
statusChart = null;
}
if (timelineChart && typeof timelineChart.destroy === 'function') {
timelineChart.destroy();
timelineChart = null;
}
if (currentStatusData) {
createStatusChart(currentStatusData);
}
if (currentTimelineData) {
createTimelineChart(currentTimelineData);
try {
if (statusChart && typeof statusChart.destroy === 'function') {
statusChart.destroy();
statusChart = null;
console.log('Destroyed status chart for theme change');
}
if (timelineChart && typeof timelineChart.destroy === 'function') {
timelineChart.destroy();
timelineChart = null;
console.log('Destroyed timeline chart for theme change');
}
if (currentStatusData) {
createStatusChart(currentStatusData);
}
if (currentTimelineData) {
createTimelineChart(currentTimelineData);
}
} catch (e) {
console.error('Error redrawing charts with new theme:', e);
}
}
@@ -99,8 +112,12 @@
method: 'GET',
headers: { 'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'}
};
console.log('Fetching statistics from:', STATISTICS_API_URL);
const response = await fetch(STATISTICS_API_URL, options);
// Choose API endpoint based on current view
const apiUrl = isGlobalView ? GLOBAL_STATISTICS_API_URL : STATISTICS_API_URL;
console.log('Fetching statistics from:', apiUrl, '(Global view:', isGlobalView, ')');
const response = await fetch(apiUrl, options);
if (!response.ok) {
const errorText = await response.text();
throw new Error(`Failed to fetch statistics: ${response.status} ${errorText}`);
@@ -123,6 +140,150 @@
setTimeout(() => { if (toast.parentElement) toast.remove(); }, 3000);
}
// Global view functions
async function initViewControls() {
if (isViewControlsInitialized) return;
try {
// Check if global view is enabled for this user
const token = window.auth.getToken();
if (!token) return;
const response = await fetch('/api/settings/global-view-status', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const result = await response.json();
if (result.enabled) {
showViewSwitcher();
setupViewSwitcherListeners();
isViewControlsInitialized = true;
}
}
} catch (error) {
console.error('Error checking global view status:', error);
// Default to showing view switcher if error occurs (for admins)
if (getUserType() === 'admin') {
showViewSwitcher();
setupViewSwitcherListeners();
isViewControlsInitialized = true;
}
}
}
function getUserType() {
try {
const userInfo = JSON.parse(localStorage.getItem('user_info') || '{}');
return userInfo.is_admin ? 'admin' : 'user';
} catch {
return 'user';
}
}
function showViewSwitcher() {
const viewSwitcher = document.getElementById('viewSwitcher');
if (viewSwitcher) {
viewSwitcher.style.display = 'flex';
}
}
function hideViewSwitcher() {
const viewSwitcher = document.getElementById('viewSwitcher');
if (viewSwitcher) {
viewSwitcher.style.display = 'none';
}
}
function setupViewSwitcherListeners() {
const personalViewBtn = document.getElementById('personalViewBtn');
const globalViewBtn = document.getElementById('globalViewBtn');
if (personalViewBtn) {
personalViewBtn.addEventListener('click', () => switchToPersonalView());
}
if (globalViewBtn) {
globalViewBtn.addEventListener('click', () => switchToGlobalView());
}
}
async function switchToPersonalView() {
if (!isGlobalView) return; // Already in personal view
isGlobalView = false;
updateViewButtons();
updateDashboardTitle();
updateTableColumns();
isDashboardInitialized = false; // Reset to allow refresh with new view
await initDashboard();
}
async function switchToGlobalView() {
if (isGlobalView) return; // Already in global view
try {
// Check if global view is still available
const token = window.auth.getToken();
const response = await fetch('/api/settings/global-view-status', {
method: 'GET',
headers: { 'Authorization': `Bearer ${token}` }
});
if (response.ok) {
const result = await response.json();
if (result.enabled) {
isGlobalView = true;
updateViewButtons();
updateDashboardTitle();
updateTableColumns();
isDashboardInitialized = false; // Reset to allow refresh with new view
await initDashboard();
} else {
showToast('Global view is not available', 'error');
// Switch back to personal view if global is disabled
await switchToPersonalView();
}
} else {
throw new Error('Failed to check global view status');
}
} catch (error) {
console.error('Error switching to global view:', error);
showToast('Unable to switch to global view', 'error');
// Switch back to personal view on error
await switchToPersonalView();
}
}
function updateViewButtons() {
const personalViewBtn = document.getElementById('personalViewBtn');
const globalViewBtn = document.getElementById('globalViewBtn');
if (personalViewBtn && globalViewBtn) {
if (isGlobalView) {
personalViewBtn.classList.remove('active');
globalViewBtn.classList.add('active');
} else {
personalViewBtn.classList.add('active');
globalViewBtn.classList.remove('active');
}
}
}
function updateDashboardTitle() {
const dashboardTitle = document.getElementById('dashboardTitle');
if (dashboardTitle) {
dashboardTitle.textContent = isGlobalView ? 'Global Warranty Status Dashboard' : 'Warranty Status Dashboard';
}
}
function updateTableColumns() {
const ownerHeader = document.getElementById('ownerHeader');
if (ownerHeader) {
ownerHeader.style.display = isGlobalView ? 'table-cell' : 'none';
}
}
function updateSummaryCounts(statusData) {
const totalEl = document.getElementById('totalCount');
const activeEl = document.getElementById('activeCount');
@@ -137,26 +298,71 @@
function createStatusChart(stats) {
const ctxEl = document.getElementById('statusChart');
if (!ctxEl) { console.warn("statusChart canvas not found"); return; }
// Properly destroy existing chart
if (statusChart && typeof statusChart.destroy === 'function') {
try {
statusChart.destroy();
statusChart = null;
console.log('Destroyed existing status chart');
} catch (e) {
console.warn('Error destroying status chart:', e);
statusChart = null;
}
}
// Additional check: Clear any Chart.js instances on this canvas
const chartInstance = Chart.getChart(ctxEl);
if (chartInstance) {
console.log('Found existing Chart.js instance on statusChart canvas, destroying it');
chartInstance.destroy();
}
const ctx = ctxEl.getContext('2d');
if (!stats || typeof stats !== 'object') stats = { active: 0, expiring_soon: 0, expired: 0, total: 0 }; // Added total for safety
const active = stats.active || 0;
const expiringSoon = stats.expiring_soon || 0;
const expired = stats.expired || 0;
const trulyActive = Math.max(0, active - expiringSoon);
if (statusChart && typeof statusChart.destroy === 'function') statusChart.destroy();
statusChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Active', 'Expiring Soon', 'Expired'],
datasets: [{ data: [trulyActive, expiringSoon, expired], backgroundColor: ['#4CAF50', '#FF9800', '#F44336'], borderWidth: 1 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } }
});
try {
statusChart = new Chart(ctx, {
type: 'doughnut',
data: {
labels: ['Active', 'Expiring Soon', 'Expired'],
datasets: [{ data: [trulyActive, expiringSoon, expired], backgroundColor: ['#4CAF50', '#FF9800', '#F44336'], borderWidth: 1 }]
},
options: { responsive: true, maintainAspectRatio: false, plugins: { legend: { position: 'bottom' } } }
});
console.log('Created new status chart');
} catch (e) {
console.error('Error creating status chart:', e);
}
}
function createTimelineChart(timelineData) {
const ctxEl = document.getElementById('timelineChart');
if (!ctxEl) { console.warn("timelineChart canvas not found"); return; }
// Properly destroy existing chart
if (timelineChart && typeof timelineChart.destroy === 'function') {
try {
timelineChart.destroy();
timelineChart = null;
console.log('Destroyed existing timeline chart');
} catch (e) {
console.warn('Error destroying timeline chart:', e);
timelineChart = null;
}
}
// Additional check: Clear any Chart.js instances on this canvas
const chartInstance = Chart.getChart(ctxEl);
if (chartInstance) {
console.log('Found existing Chart.js instance on timelineChart canvas, destroying it');
chartInstance.destroy();
}
const ctx = ctxEl.getContext('2d');
let labels = [];
let counts = [];
@@ -189,15 +395,19 @@
counts = timelineData.map(item => item.count || 0);
}
if (timelineChart && typeof timelineChart.destroy === 'function') timelineChart.destroy();
timelineChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{ label: 'Warranties Expiring', data: counts, backgroundColor: '#3498db', borderWidth: 1 }]
},
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } }
});
try {
timelineChart = new Chart(ctx, {
type: 'bar',
data: {
labels: labels,
datasets: [{ label: 'Warranties Expiring', data: counts, backgroundColor: '#3498db', borderWidth: 1 }]
},
options: { responsive: true, maintainAspectRatio: false, scales: { y: { beginAtZero: true, ticks: { precision: 0 } } } }
});
console.log('Created new timeline chart');
} catch (e) {
console.error('Error creating timeline chart:', e);
}
}
function updateRecentExpirations(recentWarrantiesData) {
@@ -218,7 +428,12 @@
serial_numbers: warranty.serial_numbers || [],
notes: warranty.notes,
manual_path: warranty.manual_path || null,
other_document_path: warranty.other_document_path || null
other_document_path: warranty.other_document_path || null,
// Add user fields for global view
user_display_name: warranty.user_display_name,
username: warranty.username,
first_name: warranty.first_name,
last_name: warranty.last_name
}));
}
filterAndSortWarranties(); // This will render the table
@@ -234,7 +449,8 @@
tableBody.innerHTML = '';
if (!allWarranties || allWarranties.length === 0) {
tableBody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 20px;">No recently expired or expiring warranties.</td></tr>';
const colspan = isGlobalView ? 5 : 4;
tableBody.innerHTML = `<tr><td colspan="${colspan}" style="text-align:center; padding: 20px;">No recently expired or expiring warranties.</td></tr>`;
return;
}
@@ -296,7 +512,8 @@
});
if (displayWarranties.length === 0) {
tableBody.innerHTML = '<tr><td colspan="4" style="text-align:center; padding: 20px;">No warranties match your search criteria.</td></tr>';
const colspan = isGlobalView ? 5 : 4;
tableBody.innerHTML = `<tr><td colspan="${colspan}" style="text-align:center; padding: 20px;">No warranties match your search criteria.</td></tr>`;
return;
}
@@ -324,12 +541,21 @@
}
row.className = statusClass;
row.innerHTML = `
// Build row HTML based on current view mode
let rowHTML = `
<td title="${escapeHTML(warranty.product_name)}">${escapeHTML(warranty.product_name)}</td>
<td>${warranty.purchase_date ? formatDateYYYYMMDD(new Date(warranty.purchase_date)) : 'N/A'}</td>
<td>${warranty.is_lifetime ? 'Lifetime' : (warranty.expiration_date ? formatDateYYYYMMDD(new Date(warranty.expiration_date)) : 'N/A')}</td>
<td><span class="${statusClass}">${statusText}</span></td>
`; // Make sure escapeHTML is used on all string data from warranty
`;
// Add owner column if in global view
if (isGlobalView) {
const ownerDisplay = warranty.user_display_name || warranty.username || 'Unknown User';
rowHTML += `<td title="${escapeHTML(ownerDisplay)}">${escapeHTML(ownerDisplay)}</td>`;
}
row.innerHTML = rowHTML; // Make sure escapeHTML is used on all string data from warranty
row.addEventListener('click', () => toggleWarrantyDetails(warranty.id, row));
});
}
@@ -424,7 +650,8 @@
dHtml += '<div style="flex: 1 1 300px;"><h4>Core Information</h4>';
dHtml += `<p><strong>Product URL:</strong> ${warrantyDetails.product_url ? `<a href="${warrantyDetails.product_url}" target="_blank" rel="noopener noreferrer">${escapeHTML(warrantyDetails.product_url)}</a>` : 'N/A'}</p>`;
dHtml += `<p><strong>Purchase Price:</strong> ${warrantyDetails.purchase_price !== null && warrantyDetails.purchase_price !== undefined ? escapeHTML(userCurrencySymbol) + parseFloat(warrantyDetails.purchase_price).toFixed(2) : 'N/A'}</p>`;
dHtml += `<p><strong>Vendor:</strong> ${escapeHTML(warrantyDetails.vendor || '') || 'N/A'}</p></div>`;
dHtml += `<p><strong>Vendor:</strong> ${escapeHTML(warrantyDetails.vendor || '') || 'N/A'}</p>`;
dHtml += `<p><strong>Warranty Type:</strong> ${escapeHTML(warrantyDetails.warranty_type || '') || 'N/A'}</p></div>`;
dHtml += '<div style="flex: 1 1 300px;"><h4>Documents & Files</h4>';
if(warrantyDetails.invoice_path) dHtml += `<p><strong>Invoice:</strong> <a href="#" onclick="window.openSecureFile('${escapeHTML(warrantyDetails.invoice_path)}'); return false;">View Invoice</a></p>`; else dHtml += '<p><strong>Invoice:</strong> N/A</p>';
@@ -468,6 +695,8 @@
function refreshDashboard() {
console.log("Refreshing dashboard from status.js IIFE...");
isDashboardInitialized = false; // Reset flag to allow refresh
initDashboardPromise = null; // Clear any existing promise
if (refreshDashboardBtn) refreshDashboardBtn.classList.add('loading');
initDashboard().finally(() => {
if (refreshDashboardBtn) refreshDashboardBtn.classList.remove('loading');
@@ -475,115 +704,139 @@
}
async function initDashboard() {
// If already initialized and not being refreshed, skip
if (isDashboardInitialized && !initDashboardPromise) {
console.log('Dashboard already initialized, skipping...');
return;
}
// If currently initializing, wait for the existing promise
if (initDashboardPromise) {
console.log('Dashboard initialization already in progress, waiting...');
return initDashboardPromise;
}
console.log('Initializing dashboard (status.js IIFE)...');
try {
showLoading();
await loadUserPreferences();
const data = await fetchStatistics();
hideError();
// 1. Use data.all_warranties for the main table content
updateRecentExpirations(data.all_warranties || []);
// 2. Construct the status_distribution object for charts and summary
const statusDistributionData = {
active: data.active || 0,
expiring_soon: data.expiring_soon || 0,
expired: data.expired || 0,
total: data.total || 0
};
if (Object.keys(statusDistributionData).length > 0 && statusDistributionData.total > 0) {
currentStatusData = statusDistributionData;
updateSummaryCounts(statusDistributionData);
createStatusChart(statusDistributionData);
} else {
console.warn("Status distribution data from API is incomplete or zero. Displaying empty/default chart/summary.");
updateSummaryCounts({});
createStatusChart({});
}
// --- BEGIN: Expiration Timeline Chart Fix ---
// Instead of using only API timeline, generate a comprehensive timeline from all warranties
let allWarrantiesForTimeline = [];
// Create promise to track initialization
initDashboardPromise = (async () => {
try {
// Try to fetch all warranties for a complete timeline
const token = window.auth && window.auth.getToken ? window.auth.getToken() : null;
if (token) {
const allWarrantiesResponse = await fetch('/api/warranties', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
showLoading();
await loadUserPreferences();
await initViewControls(); // Initialize view controls before fetching data
const data = await fetchStatistics();
hideError();
// 1. Use data.all_warranties for the main table content
updateRecentExpirations(data.all_warranties || []);
// 2. Construct the status_distribution object for charts and summary
const statusDistributionData = {
active: data.active || 0,
expiring_soon: data.expiring_soon || 0,
expired: data.expired || 0,
total: data.total || 0
};
if (Object.keys(statusDistributionData).length > 0 && statusDistributionData.total > 0) {
currentStatusData = statusDistributionData;
updateSummaryCounts(statusDistributionData);
createStatusChart(statusDistributionData);
} else {
console.warn("Status distribution data from API is incomplete or zero. Displaying empty/default chart/summary.");
updateSummaryCounts({});
createStatusChart({});
}
// --- BEGIN: Expiration Timeline Chart Fix ---
// Instead of using only API timeline, generate a comprehensive timeline from all warranties
let allWarrantiesForTimeline = [];
try {
// Try to fetch all warranties for a complete timeline
const token = window.auth && window.auth.getToken ? window.auth.getToken() : null;
if (token) {
const allWarrantiesResponse = await fetch('/api/warranties', {
headers: {
'Authorization': `Bearer ${token}`,
'Content-Type': 'application/json'
}
});
if (allWarrantiesResponse.ok) {
allWarrantiesForTimeline = await allWarrantiesResponse.json();
} else {
console.warn('Could not fetch all warranties for timeline, falling back to data.all_warranties');
allWarrantiesForTimeline = data.all_warranties || [];
}
});
if (allWarrantiesResponse.ok) {
allWarrantiesForTimeline = await allWarrantiesResponse.json();
} else {
console.warn('Could not fetch all warranties for timeline, falling back to data.all_warranties');
allWarrantiesForTimeline = data.all_warranties || [];
}
} else {
} catch (err) {
console.error('Error fetching all warranties for timeline:', err);
allWarrantiesForTimeline = data.all_warranties || [];
}
} catch (err) {
console.error('Error fetching all warranties for timeline:', err);
allWarrantiesForTimeline = data.all_warranties || [];
}
// Helper: Extract timeline data from all warranties
function extractTimelineData(warranties) {
// Map: { 'YYYY-MM': count }
const timelineMap = {};
warranties.forEach(w => {
if (w.is_lifetime) return; // Skip lifetime warranties
if (!w.expiration_date) return;
let expDate = w.expiration_date;
if (typeof expDate === 'string') {
// Accept both 'YYYY-MM-DD' and ISO
expDate = expDate.split('T')[0];
const [year, month] = expDate.split('-');
if (year && month) {
// Helper: Extract timeline data from all warranties
function extractTimelineData(warranties) {
// Map: { 'YYYY-MM': count }
const timelineMap = {};
warranties.forEach(w => {
if (w.is_lifetime) return; // Skip lifetime warranties
if (!w.expiration_date) return;
let expDate = w.expiration_date;
if (typeof expDate === 'string') {
// Accept both 'YYYY-MM-DD' and ISO
expDate = expDate.split('T')[0];
const [year, month] = expDate.split('-');
if (year && month) {
const key = `${year}-${month}`;
timelineMap[key] = (timelineMap[key] || 0) + 1;
}
} else if (expDate instanceof Date && !isNaN(expDate.getTime())) {
const year = expDate.getFullYear();
const month = (expDate.getMonth() + 1).toString().padStart(2, '0');
const key = `${year}-${month}`;
timelineMap[key] = (timelineMap[key] || 0) + 1;
}
} else if (expDate instanceof Date && !isNaN(expDate.getTime())) {
const year = expDate.getFullYear();
const month = (expDate.getMonth() + 1).toString().padStart(2, '0');
const key = `${year}-${month}`;
timelineMap[key] = (timelineMap[key] || 0) + 1;
}
});
// Convert to array sorted by date ascending
const timelineArr = Object.entries(timelineMap)
.map(([key, count]) => {
const [year, month] = key.split('-');
return { year: parseInt(year), month: parseInt(month), count };
})
.sort((a, b) => (a.year !== b.year) ? a.year - b.year : a.month - b.month);
return timelineArr;
}
});
// Convert to array sorted by date ascending
const timelineArr = Object.entries(timelineMap)
.map(([key, count]) => {
const [year, month] = key.split('-');
return { year: parseInt(year), month: parseInt(month), count };
})
.sort((a, b) => (a.year !== b.year) ? a.year - b.year : a.month - b.month);
return timelineArr;
}
let timelineData = [];
if (Array.isArray(allWarrantiesForTimeline) && allWarrantiesForTimeline.length > 0) {
timelineData = extractTimelineData(allWarrantiesForTimeline);
let timelineData = [];
if (Array.isArray(allWarrantiesForTimeline) && allWarrantiesForTimeline.length > 0) {
timelineData = extractTimelineData(allWarrantiesForTimeline);
}
if (timelineData.length > 0) {
currentTimelineData = timelineData;
createTimelineChart(timelineData);
} else if (data.timeline && Array.isArray(data.timeline)) {
// Fallback to API timeline if extraction fails
currentTimelineData = data.timeline;
createTimelineChart(data.timeline);
} else {
createTimelineChart([]);
}
// --- END: Expiration Timeline Chart Fix ---
isDashboardInitialized = true;
} catch (error) {
console.error('Failed to initialize dashboard (status.js IIFE):', error);
showError('Failed to load dashboard data.', error.message);
isDashboardInitialized = false; // Allow retry on error
} finally {
hideLoading();
initDashboardPromise = null; // Clear the promise
}
if (timelineData.length > 0) {
currentTimelineData = timelineData;
createTimelineChart(timelineData);
} else if (data.timeline && Array.isArray(data.timeline)) {
// Fallback to API timeline if extraction fails
currentTimelineData = data.timeline;
createTimelineChart(data.timeline);
} else {
createTimelineChart([]);
}
// --- END: Expiration Timeline Chart Fix ---
} catch (error) {
console.error('Failed to initialize dashboard (status.js IIFE):', error);
showError('Failed to load dashboard data.', error.message);
} finally {
hideLoading();
}
})();
return initDashboardPromise;
}
async function loadUserPreferences() {
@@ -845,7 +1098,13 @@
}
// DOMContentLoaded listener to initialize the dashboard and event listeners
document.addEventListener('DOMContentLoaded', () => {
function initStatusPage() {
if (isDOMHandlerAttached) {
console.log('Status page DOM handler already attached, skipping...');
return;
}
isDOMHandlerAttached = true;
console.log('Status page DOM loaded (status.js IIFE). Initializing dashboard and listeners.');
const headerDarkModeToggle = document.getElementById('darkModeToggle');
@@ -889,5 +1148,13 @@
console.warn(`(STATUS.JS) DOMContentLoaded/setTimeout: window.saveWarranty found, but NOT WRAPPED (flag: ${window.saveWarrantyObserverAttachedByStatus}). Observer NOT attached.`);
}
}, 500); // Delay by 500ms
});
}
// Add event listener with protection against multiple calls
if (document.readyState === 'loading') {
document.addEventListener('DOMContentLoaded', initStatusPage, { once: true });
} else {
// DOM is already loaded
setTimeout(initStatusPage, 0);
}
})();
+194
View File
@@ -655,6 +655,7 @@ input.invalid {
padding: 15px;
background-color: rgba(0, 0, 0, 0.03);
border-bottom: 1px solid var(--border-color);
position: relative;
}
.warranty-title {
@@ -2278,6 +2279,102 @@ input.invalid {
color: white;
}
/* Admin View Switcher - reuse the same styling pattern */
.admin-view-switcher {
display: flex;
flex-direction: column;
flex-grow: 1;
min-width: 120px;
margin-left: 15px; /* Add some spacing from the regular view switcher */
}
.admin-view-switcher .view-buttons {
display: flex;
border: 1px solid var(--primary-color);
border-radius: var(--border-radius);
overflow: hidden;
}
.admin-view-switcher .view-btn {
background-color: var(--bg-color);
border: none;
color: var(--primary-color);
padding: 8px 12px;
cursor: pointer;
transition: var(--transition);
flex: 1;
display: flex;
align-items: center;
justify-content: center;
font-weight: 500;
}
.admin-view-switcher .view-btn:not(:last-child) {
border-right: 1px solid var(--primary-color);
}
.admin-view-switcher .view-btn:hover {
background-color: rgba(var(--primary-rgb), 0.1);
color: var(--primary-dark);
}
.admin-view-switcher .view-btn.active {
background-color: var(--primary-color);
color: white;
}
/* View-only placeholder for non-owned warranties in global view */
.action-btn-placeholder {
display: inline-flex;
align-items: center;
justify-content: center;
width: 32px;
height: 32px;
border: 1px solid #ddd;
border-radius: 4px;
background-color: transparent;
color: #666;
cursor: default;
opacity: 0.5;
}
.action-btn-placeholder:hover {
background-color: rgba(0, 0, 0, 0.05);
}
[data-theme="dark"] .action-btn-placeholder {
border-color: #555;
color: #999;
}
[data-theme="dark"] .action-btn-placeholder:hover {
background-color: rgba(255, 255, 255, 0.05);
}
/* User info styling for global view */
.warranty-info div:has(strong:contains("Owner")) {
background-color: rgba(var(--primary-rgb), 0.1);
border-left: 3px solid var(--primary-color);
padding: 4px 8px;
margin-bottom: 8px;
border-radius: 4px;
font-weight: 500;
}
/* Alternative approach for better browser support */
.warranty-info div strong:contains("Owner") {
color: var(--primary-color);
}
.warranty-info div:first-child:has(strong) {
background-color: rgba(var(--primary-rgb), 0.1);
border-left: 3px solid var(--primary-color);
padding: 4px 8px;
margin-bottom: 8px;
border-radius: 4px;
font-weight: 500;
}
/* Grid View (Default) */
.warranties-list.grid-view {
display: grid;
@@ -2525,6 +2622,12 @@ input.invalid {
width: 100%;
}
.admin-view-switcher {
margin-left: 0;
margin-top: 10px;
width: 100%;
}
/* Convert table view to card-like view on mobile */
.warranties-list.table-view {
display: block;
@@ -2698,6 +2801,7 @@ input.invalid {
display: flex;
padding: 15px;
flex: 1;
position: relative;
}
.warranty-info {
@@ -3789,3 +3893,93 @@ html.dark-mode .warracker-footer a:hover {
font-size: 0.85rem;
}
}
/* ===== PRODUCT PHOTO STYLES ===== */
.product-photo-thumbnail {
position: absolute;
top: 8px;
right: 8px;
display: flex;
align-items: center;
justify-content: center;
z-index: 10;
}
.product-photo-thumbnail img {
border: 2px solid var(--border-color);
border-radius: 8px;
object-fit: cover;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.product-photo-thumbnail img:hover {
transform: scale(1.05);
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
}
.product-photo-thumbnail a {
display: block;
text-decoration: none;
}
.product-photo-thumbnail a:hover {
text-decoration: none;
}
.photo-preview {
margin-top: 10px;
text-align: center;
}
.photo-preview img {
border: 2px solid var(--border-color);
border-radius: 8px;
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}
/* Grid view photo positioning */
.grid-view .product-photo-thumbnail img {
width: 60px;
height: 60px;
}
/* List view photo positioning */
.list-view .product-photo-thumbnail img {
width: 50px;
height: 50px;
}
/* Table view photo positioning */
.table-view .product-photo-thumbnail img {
width: 40px;
height: 40px;
}
/* Dark mode adjustments */
:root[data-theme="dark"] .product-photo-thumbnail img {
border-color: var(--border-color);
}
:root[data-theme="dark"] .photo-preview img {
border-color: var(--border-color);
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.3);
}
/* Mobile responsiveness */
@media (max-width: 768px) {
.grid-view .product-photo-thumbnail img,
.list-view .product-photo-thumbnail img {
width: 45px;
height: 45px;
}
.table-view .product-photo-thumbnail img {
width: 35px;
height: 35px;
}
.product-photo-thumbnail {
top: 6px;
right: 6px;
}
}
+10 -3
View File
@@ -1,9 +1,16 @@
const CACHE_NAME = 'warracker-cache-v1';
const CACHE_NAME = 'warracker-cache-v2';
const urlsToCache = [
'./',
'./index.html',
'./style.css',
'./script.js',
'./settings-new.html',
'./status.html',
'./style.css?v=20250529005',
'./settings-styles.css?v=20250529005',
'./header-fix.css?v=20250529005',
'./mobile-header.css?v=20250529005',
'./script.js?v=20250529005',
'./auth.js?v=20250529005',
'./settings-new.js?v=20250529005',
'./manifest.json',
'./img/favicon-16x16.png',
'./img/favicon-32x32.png',
+28
View File
@@ -0,0 +1,28 @@
// Temporary toast notification enhancement for debugging
// Add this to browser console to make success messages more visible
(function() {
console.log('🍞 Toast Debug Enhancement Loaded');
// Override showToast to make it more visible
const originalShowToast = window.showToast;
window.showToast = function(message, type = 'info', duration = 5000) {
console.log(`🍞 TOAST NOTIFICATION: ${message} (Type: ${type})`);
// Call original function
if (originalShowToast) {
originalShowToast.call(this, message, type, duration);
}
// Also show an alert for debugging (remove this in production)
if (type === 'success') {
alert(`SUCCESS: ${message}`);
}
// Add a visible console message
console.log(`%c${message}`, `color: ${type === 'success' ? 'green' : type === 'error' ? 'red' : 'blue'}; font-weight: bold; font-size: 16px;`);
};
console.log('Toast notifications will now be more visible with alerts');
})();
+1 -1
View File
@@ -1,6 +1,6 @@
// Version checker for Warracker
document.addEventListener('DOMContentLoaded', () => {
const currentVersion = '0.9.9.9'; // Current version of the application
const currentVersion = '0.10.1.0'; // Current version of the application
const updateStatus = document.getElementById('updateStatus');
const updateLink = document.getElementById('updateLink');