mirror of
https://github.com/sassanix/Warracker.git
synced 2025-12-31 02:30:01 -06:00
Vendor, Date Format, Added security
Refer to changelogs
This commit is contained in:
110
CHANGELOG.md
110
CHANGELOG.md
@@ -1,5 +1,115 @@
|
||||
# Changelog
|
||||
|
||||
## [0.9.9.4] - 2025-05-04
|
||||
|
||||
### Fixed
|
||||
- **Theme Persistence & Consistency:**
|
||||
- Refactored dark mode logic to use only a single `localStorage` key (`darkMode`) as the source of truth for theme preference across all pages.
|
||||
- Removed all legacy and user-prefixed theme keys (e.g., `user_darkMode`, `admin_darkMode`, `${prefix}darkMode`).
|
||||
- Ensured all theme toggles and settings update only the `darkMode` key, and all pages read only this key for theme initialization.
|
||||
- Verified that `theme-loader.js` is included early in every HTML file to prevent flashes of incorrect theme.
|
||||
- Cleaned up redundant or conflicting theme logic in all frontend scripts and HTML files.
|
||||
- Theme preference now persists reliably across logins, logouts, and browser sessions (except in incognito/private mode, where localStorage is temporary by design).
|
||||
_Files: `frontend/script.js`, `frontend/settings-new.js`, `frontend/status.js`, `frontend/register.html`, `frontend/reset-password.html`, `frontend/reset-password-request.html`, `frontend/theme-loader.js`, all main HTML files._
|
||||
|
||||
### Added
|
||||
- **Admin/User Tag Separation:**
|
||||
- Implemented distinct tags for Admins and regular Users.
|
||||
- Tags created by an Admin are only visible to other Admins.
|
||||
- Tags created by a User are only visible to other Users.
|
||||
- Added `is_admin_tag` boolean column to the `tags` table via migration (`backend/migrations/009_add_admin_flag_to_tags.sql`).
|
||||
- Backend API endpoints (`/api/tags`, `/api/warranties`, `/api/warranties/<id>/tags`) updated to filter tags based on the logged-in user's role (`is_admin`).
|
||||
- Tag creation (`POST /api/tags`) now automatically sets the `is_admin_tag` flag based on the creator's role.
|
||||
- Tag update (`PUT /api/tags/<id>`) and deletion (`DELETE /api/tags/<id>`) endpoints now prevent users/admins from modifying tags belonging to the other role.
|
||||
_Files: `backend/app.py`, `backend/migrations/009_add_admin_flag_to_tags.sql`_
|
||||
|
||||
- **Version Check:**
|
||||
|
||||
- Added a version checker to the About page (`frontend/about.html`) that compares the current version with the latest GitHub release and displays the update status.
|
||||
_Files: `frontend/about.html`, `frontend/version-checker.js`_
|
||||
|
||||
- **Mobile Home Screen Icon Support:**
|
||||
- Added support for mobile devices to display the app icon when added to the home screen.
|
||||
- Included `<link rel="apple-touch-icon" sizes="180x180">` for iOS devices, referencing a new 512x512 PNG icon.
|
||||
- Added a web app manifest (`manifest.json`) referencing the 512x512 icon for Android/Chrome home screen support.
|
||||
- Updated all main HTML files to include these tags for consistent experience across devices.
|
||||
_Files: `frontend/index.html`, `frontend/login.html`, `frontend/register.html`, `frontend/about.html`, `frontend/reset-password.html`, `frontend/reset-password-request.html`, `frontend/status.html`, `frontend/settings-new.html`, `frontend/manifest.json`, `frontend/img/favicon-512x512.png`_
|
||||
|
||||
- **Optional Vendor Field for Warranties:**
|
||||
- Users can now specify the vendor (e.g., Amazon, Best Buy) where a product was purchased, as an optional informational field for each warranty.
|
||||
- The field is available when adding a new warranty and when editing an existing warranty.
|
||||
- The vendor is displayed on warranty cards and in the summary tab of the add warranty wizard.
|
||||
- The vendor field is now searchable alongside product name, notes, and tags.
|
||||
- Backend API and database updated to support this field, including a migration to add the column to the warranties table.
|
||||
- Editing a warranty now correctly updates the vendor field.
|
||||
_Files: `backend/app.py`, `backend/migrations/017_add_vendor_to_warranties.sql`, `frontend/index.html`, `frontend/script.js`_
|
||||
|
||||
- **Serial Number Search:**
|
||||
- Enhanced search functionality to include serial numbers.
|
||||
- Updated search input placeholder text to reflect serial number search capability.
|
||||
_Files: `frontend/script.js`, `frontend/index.html`_
|
||||
|
||||
- **CSV Import Vendor Field:**
|
||||
- Added support for importing the `Vendor` field via CSV file upload.
|
||||
- The CSV header should be `Vendor`.
|
||||
_Files: `backend/app.py`, `frontend/script.js`_
|
||||
|
||||
- **Date Format Customization:**
|
||||
- Users can now choose their preferred date display format in Settings > Preferences.
|
||||
- Available formats include:
|
||||
- Month/Day/Year (e.g., 12/31/2024)
|
||||
- Day/Month/Year (e.g., 31/12/2024)
|
||||
- Year-Month-Day (e.g., 2024-12-31)
|
||||
- Mon Day, Year (e.g., Dec 31, 2024)
|
||||
- Day Mon Year (e.g., 31 Dec 2024)
|
||||
- Year Mon Day (e.g., 2024 Dec 31)
|
||||
- The selected format is applied to purchase and expiration dates on warranty cards.
|
||||
- The setting persists across sessions and is synchronized between open tabs.
|
||||
_Files: `frontend/settings-new.html`, `frontend/settings-new.js`, `frontend/script.js`_
|
||||
|
||||
- **IPv6 Support:** Added `listen [::]:80;` directive to the Nginx configuration (`nginx.conf`) to enable listening on IPv6 interfaces alongside IPv4.
|
||||
_Files: `nginx.conf`_
|
||||
|
||||
- **Cloudflare Compatibility:** Added `<script data-cfasync="false" src="/javascript.js">` to the `<head>` of the status page (`frontend/status.html`) to ensure proper loading when behind Cloudflare.
|
||||
_Files: `frontend/status.html`_
|
||||
|
||||
### Changed
|
||||
- **Migration System Overhaul:** Refactored the database migration system for improved reliability and consistency.
|
||||
- **House cleaning**: Removed redundant files such as migrations, .env, and uploads folder.
|
||||
- **Warranty Listing:** Admins now only see their own warranties on the main warranty list (`/api/warranties`).
|
||||
- **Warranty Visibility:** Fixed issue where admins could see all users' warranties. Now both admins and regular users only see their own warranties on all pages.
|
||||
_Files: `backend/app.py`_
|
||||
|
||||
**Bug Fixes:**
|
||||
|
||||
* **Date Handling:** Fixed issues causing warranty purchase and expiration dates to display incorrectly (off by one day) due to timezone differences:
|
||||
* **Backend:** Corrected expiration date calculation in `backend/app.py` by removing the inaccurate `timedelta` fallback logic and ensuring the `python-dateutil` library (using `relativedelta`) is consistently used for accurate year addition.
|
||||
* **Backend:** Added `python-dateutil` to `backend/requirements.txt` dependencies.
|
||||
* **Frontend:** Updated date parsing in `frontend/script.js` (`processWarrantyData`) to use `Date.UTC()` when creating `Date` objects from `YYYY-MM-DD` strings, preventing local timezone interpretation.
|
||||
* **Frontend:** Simplified and corrected date formatting in `frontend/script.js` (`formatDate`) to always use UTC date components (`getUTCFullYear`, etc.) for display, bypassing potential `toLocaleDateString` timezone issues.
|
||||
* **Frontend:** Fixed purchase date display in the "Add Warranty" summary tab (`updateSummary` in `frontend/script.js`) by applying the same UTC-based parsing and formatting used elsewhere, resolving the off-by-one-day error during summary view.
|
||||
* **Fractional Warranty Years & Date Accuracy:** Corrected the backend expiration date calculation (`backend/app.py`) to accurately handle fractional warranty years (e.g., 1.5 years) and prevent off-by-one-day errors.
|
||||
* The initial fix for fractional years used an approximation (`timedelta`) which sometimes resulted in dates being a day early.
|
||||
* The calculation now uses `dateutil.relativedelta` after decomposing the fractional years into integer years, months, and days, ensuring correct handling of leap years and month lengths.
|
||||
* **UI:** Fixed toasts overlapping header menu due to z-index conflict (`style.css`).
|
||||
* **API:** Fixed bug preventing updates to user preferences if only the timezone was changed (`app.py`).
|
||||
* **Settings:** Resolved issue where opening the settings page in multiple tabs could cause both tabs to refresh continuously and potentially log the user out. This involved:
|
||||
* Preventing `settings-new.js` from unnecessarily updating `user_info` in `localStorage` on load.
|
||||
* Changing the `storage` event listener in `include-auth-new.js` to update the UI directly instead of reloading the page when auth data changes.
|
||||
* Removing redundant checks (`setInterval`) and `storage` listener from `fix-auth-buttons.js` to prevent conflicts.
|
||||
|
||||
**New Features & Enhancements:**
|
||||
|
||||
* **API:** Added `/api/timezones` endpoint to provide a structured list of timezones grouped by region (`app.py`).
|
||||
|
||||
### Fixed
|
||||
- **Tag Visibility:** Fixed an issue where tag visibility was incorrectly reversed between Admins and Users (Users saw Admin tags and vice-versa). Adjusted backend logic to correctly display tags based on role.
|
||||
_Files: `backend/app.py`_
|
||||
- Fixed currency symbol not persisting after browser restart/re-login:
|
||||
- Prevented main warranty list from rendering before user authentication and preferences are fully loaded.
|
||||
- Ensured preferences (currency, date format, etc.) are fetched from API and saved to localStorage immediately after login.
|
||||
- Corrected inconsistent user type prefix (`user_` vs `admin_`) determination during initial page load.
|
||||
|
||||
## [0.9.9.3] - 2025-04-27
|
||||
|
||||
### Added
|
||||
|
||||
@@ -1,9 +0,0 @@
|
||||
# Database configuration
|
||||
DB_PASSWORD=warranty_password
|
||||
DB_ADMIN_PASSWORD=change_this_password_in_production
|
||||
|
||||
# Email configuration
|
||||
SMTP_HOST=localhost
|
||||
SMTP_PORT=1025
|
||||
SMTP_USERNAME=youremail@email.com
|
||||
SMTP_PASSWORD=your_smtp_password
|
||||
@@ -40,6 +40,7 @@ Warracker is an open-source warranty tracker application designed to help you ef
|
||||
- **Data Export and Import:** Export warranty data to CSV, or import warranties from CSV files.
|
||||
- **Email Notifications:** Receive timely email reminders about upcoming expirations — configurable as daily, weekly, or monthly.
|
||||
- **Customizable Currency Symbols:** Display prices using your preferred currency symbol ($, €, £, ¥, ₹, or a custom symbol).
|
||||
- **Customizable Dates:** Display dates based on your own region.
|
||||
- **Tagging:** Organize warranties with flexible, multi-tag support.
|
||||
- **Password Reset:** Easily recover accounts through a secure, token-based password reset flow.
|
||||
|
||||
@@ -189,6 +190,7 @@ To get the docker compose file please go [here](https://github.com/sassanix/Warr
|
||||
| **PurchasePrice** | Number (`199.99`, `50`) | ❌ No (Optional) | Cannot be negative if provided. |
|
||||
| **SerialNumber** | Text (`SN123`, `SN123,SN456`) | ❌ No (Optional) | For multiple values, separate with commas. |
|
||||
| **ProductURL** | Text (URL format) | ❌ No (Optional) | Full URL to product page (optional field). https://producturl.com |
|
||||
| **Vendor** | Text | ❌ No (Optional) | Name of the vendor or seller where the product was purchased. |
|
||||
| **Tags** | Text (`tag1,tag2`) | ❌ No (Optional) | Use comma-separated values for multiple tags. |
|
||||
|
||||
|
||||
|
||||
695
backend/app.py
695
backend/app.py
File diff suppressed because it is too large
Load Diff
@@ -1,7 +0,0 @@
|
||||
-- backend/init.sql
|
||||
|
||||
-- Grant superuser privileges to warranty_user
|
||||
ALTER ROLE warranty_user WITH SUPERUSER;
|
||||
|
||||
-- The rest of the file content (CREATE TABLE, INSERT INTO) should be removed.
|
||||
-- The migration system (apply_migrations.py) will handle table creation.
|
||||
6
backend/migrations/009_add_admin_flag_to_tags.sql
Normal file
6
backend/migrations/009_add_admin_flag_to_tags.sql
Normal file
@@ -0,0 +1,6 @@
|
||||
-- Add is_admin_tag column to tags table
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS is_admin_tag BOOLEAN NOT NULL DEFAULT FALSE;
|
||||
|
||||
-- Optional: Add an index for faster lookups based on admin status
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_is_admin_tag ON tags (is_admin_tag);
|
||||
@@ -1,5 +0,0 @@
|
||||
-- 013_add_updated_at_to_tags.sql
|
||||
-- Adds an updated_at column to the tags table for tracking updates
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN updated_at TIMESTAMP DEFAULT NOW();
|
||||
5
backend/migrations/016_add_updated_at_to_tags.sql
Normal file
5
backend/migrations/016_add_updated_at_to_tags.sql
Normal file
@@ -0,0 +1,5 @@
|
||||
-- 016_add_updated_at_to_tags.sql
|
||||
-- Adds an updated_at column to the tags table for tracking updates
|
||||
|
||||
ALTER TABLE tags
|
||||
ADD COLUMN IF NOT EXISTS updated_at TIMESTAMP DEFAULT NOW();
|
||||
3
backend/migrations/018_add_vendor_to_warranties.sql
Normal file
3
backend/migrations/018_add_vendor_to_warranties.sql
Normal file
@@ -0,0 +1,3 @@
|
||||
-- Migration to add vendor field to warranties table
|
||||
ALTER TABLE warranties ADD COLUMN IF NOT EXISTS
|
||||
vendor VARCHAR(255) NULL;
|
||||
18
backend/migrations/019_add_date_format_column.sql
Normal file
18
backend/migrations/019_add_date_format_column.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- Add date_format column to user_preferences table if it doesn't exist
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1
|
||||
FROM information_schema.columns
|
||||
WHERE table_name = 'user_preferences'
|
||||
AND column_name = 'date_format'
|
||||
) THEN
|
||||
-- Add the column with a default value of 'MDY'
|
||||
ALTER TABLE user_preferences
|
||||
ADD COLUMN date_format VARCHAR(10) NOT NULL DEFAULT 'MDY';
|
||||
|
||||
RAISE NOTICE 'Added date_format column to user_preferences table with default MDY';
|
||||
ELSE
|
||||
RAISE NOTICE 'date_format column already exists in user_preferences table';
|
||||
END IF;
|
||||
END $$;
|
||||
32
backend/migrations/020_add_user_id_to_tags.sql
Normal file
32
backend/migrations/020_add_user_id_to_tags.sql
Normal file
@@ -0,0 +1,32 @@
|
||||
-- Add user_id column to tags table and update constraints
|
||||
DO $$
|
||||
BEGIN
|
||||
-- Add user_id column if it doesn't exist
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM information_schema.columns
|
||||
WHERE table_name = 'tags' AND column_name = 'user_id'
|
||||
) THEN
|
||||
-- First, add the column as nullable
|
||||
ALTER TABLE tags ADD COLUMN user_id INTEGER;
|
||||
|
||||
-- Update existing tags to have user_id = 1 (assuming this is the admin user)
|
||||
UPDATE tags SET user_id = 1 WHERE user_id IS NULL;
|
||||
|
||||
-- Make the column NOT NULL
|
||||
ALTER TABLE tags ALTER COLUMN user_id SET NOT NULL;
|
||||
|
||||
-- Add foreign key constraint
|
||||
ALTER TABLE tags ADD CONSTRAINT fk_tags_user_id
|
||||
FOREIGN KEY (user_id) REFERENCES users(id) ON DELETE CASCADE;
|
||||
|
||||
-- Drop the old unique constraint on name
|
||||
ALTER TABLE tags DROP CONSTRAINT IF EXISTS tags_name_key;
|
||||
|
||||
-- Add new unique constraint on name and user_id
|
||||
ALTER TABLE tags ADD CONSTRAINT tags_name_user_id_key
|
||||
UNIQUE (name, user_id);
|
||||
|
||||
-- Create index for faster lookups
|
||||
CREATE INDEX IF NOT EXISTS idx_tags_user_id ON tags (user_id);
|
||||
END IF;
|
||||
END $$;
|
||||
Binary file not shown.
Binary file not shown.
Binary file not shown.
Binary file not shown.
@@ -7,4 +7,5 @@ Flask-Login==0.6.2
|
||||
Flask-Bcrypt==1.0.1
|
||||
PyJWT==2.6.0
|
||||
email-validator==1.3.1
|
||||
APScheduler==3.10.4
|
||||
APScheduler==3.10.4
|
||||
python-dateutil
|
||||
@@ -1,167 +0,0 @@
|
||||
import unittest
|
||||
from datetime import date, timedelta
|
||||
import sys
|
||||
import os
|
||||
import json
|
||||
from unittest.mock import patch, MagicMock
|
||||
|
||||
# Add the parent directory to sys.path to allow importing app.py
|
||||
sys.path.insert(0, os.path.abspath(os.path.join(os.path.dirname(__file__), '..')))
|
||||
|
||||
# Import the functions from app.py
|
||||
from app import get_expiring_warranties, format_expiration_email, send_expiration_notifications
|
||||
|
||||
class TestWarrantyNotifications(unittest.TestCase):
|
||||
|
||||
@patch('app.get_db_connection')
|
||||
def test_get_expiring_warranties(self, mock_get_db_connection):
|
||||
"""Test that get_expiring_warranties correctly queries expiring warranties."""
|
||||
# Set up mock connection and cursor
|
||||
mock_conn = MagicMock()
|
||||
mock_cur = MagicMock()
|
||||
mock_conn.cursor.return_value.__enter__.return_value = mock_cur
|
||||
mock_get_db_connection.return_value = mock_conn
|
||||
|
||||
# Set up mock cursor fetchall response
|
||||
today = date.today()
|
||||
mock_cur.fetchall.return_value = [
|
||||
# Format: email, first_name, product_name, expiration_date, expiring_soon_days
|
||||
('user1@example.com', 'John', 'Laptop', today + timedelta(days=10), 30),
|
||||
('user2@example.com', 'Jane', 'Phone', today + timedelta(days=20), 30),
|
||||
('user3@example.com', None, 'Tablet', today + timedelta(days=5), 30)
|
||||
]
|
||||
|
||||
# Call the function
|
||||
result = get_expiring_warranties()
|
||||
|
||||
# Verify the SQL query is correct (simplified check)
|
||||
self.assertTrue(mock_cur.execute.called)
|
||||
args = mock_cur.execute.call_args[0]
|
||||
self.assertIn('warranties w', args[0]) # Check if the query includes the warranties table
|
||||
self.assertIn('users u', args[0]) # Check if the query includes the users table
|
||||
self.assertIn('user_preferences up', args[0]) # Check if the query includes user_preferences
|
||||
|
||||
# Verify the result
|
||||
self.assertEqual(len(result), 3) # Should return 3 warranties
|
||||
|
||||
# Check the first warranty
|
||||
self.assertEqual(result[0]['email'], 'user1@example.com')
|
||||
self.assertEqual(result[0]['first_name'], 'John')
|
||||
self.assertEqual(result[0]['product_name'], 'Laptop')
|
||||
self.assertEqual(result[0]['expiration_date'], (today + timedelta(days=10)).strftime('%Y-%m-%d'))
|
||||
|
||||
# Check that null first_name is handled correctly
|
||||
self.assertEqual(result[2]['first_name'], 'User') # Default value for NULL
|
||||
|
||||
# Verify the connection is released
|
||||
self.assertTrue(mock_conn.close.called)
|
||||
|
||||
def test_format_expiration_email(self):
|
||||
"""Test that format_expiration_email correctly formats the email."""
|
||||
# Test data
|
||||
user = {
|
||||
'first_name': 'John',
|
||||
'email': 'john@example.com'
|
||||
}
|
||||
|
||||
warranties = [
|
||||
{
|
||||
'product_name': 'Laptop',
|
||||
'expiration_date': '2023-12-31'
|
||||
},
|
||||
{
|
||||
'product_name': 'Phone',
|
||||
'expiration_date': '2023-11-30'
|
||||
}
|
||||
]
|
||||
|
||||
# Call the function
|
||||
email = format_expiration_email(user, warranties)
|
||||
|
||||
# Verify the email
|
||||
self.assertEqual(email['Subject'], 'Warracker: Upcoming Warranty Expirations')
|
||||
self.assertEqual(email['From'], 'notifications@warracker.com')
|
||||
self.assertEqual(email['To'], 'john@example.com')
|
||||
|
||||
# Check both parts exist (plain text and HTML)
|
||||
self.assertEqual(len(email.get_payload()), 2)
|
||||
|
||||
# Check content of plain text part
|
||||
plain_text = email.get_payload(0).get_payload()
|
||||
self.assertIn('Hello John', plain_text)
|
||||
self.assertIn('Laptop (expires on 2023-12-31)', plain_text)
|
||||
self.assertIn('Phone (expires on 2023-11-30)', plain_text)
|
||||
|
||||
# Check content of HTML part
|
||||
html = email.get_payload(1).get_payload()
|
||||
self.assertIn('Hello John', html)
|
||||
self.assertIn('Laptop', html)
|
||||
self.assertIn('2023-12-31', html)
|
||||
self.assertIn('Phone', html)
|
||||
self.assertIn('2023-11-30', html)
|
||||
|
||||
@patch('app.get_expiring_warranties')
|
||||
@patch('smtplib.SMTP')
|
||||
def test_send_expiration_notifications(self, mock_smtp, mock_get_expiring_warranties):
|
||||
"""Test that send_expiration_notifications correctly sends emails."""
|
||||
# Mock environment variables
|
||||
with patch.dict(os.environ, {
|
||||
'SMTP_HOST': 'test.example.com',
|
||||
'SMTP_PORT': '587',
|
||||
'SMTP_USERNAME': 'test@example.com',
|
||||
'SMTP_PASSWORD': 'test_password'
|
||||
}):
|
||||
# Set up mock SMTP instance
|
||||
mock_smtp_instance = MagicMock()
|
||||
mock_smtp.return_value.__enter__.return_value = mock_smtp_instance
|
||||
|
||||
# Set up mock for expiring warranties
|
||||
mock_get_expiring_warranties.return_value = [
|
||||
{
|
||||
'email': 'user1@example.com',
|
||||
'first_name': 'John',
|
||||
'product_name': 'Laptop',
|
||||
'expiration_date': '2023-12-31'
|
||||
},
|
||||
{
|
||||
'email': 'user1@example.com',
|
||||
'first_name': 'John',
|
||||
'product_name': 'Phone',
|
||||
'expiration_date': '2023-11-30'
|
||||
},
|
||||
{
|
||||
'email': 'user2@example.com',
|
||||
'first_name': 'Jane',
|
||||
'product_name': 'Tablet',
|
||||
'expiration_date': '2023-10-15'
|
||||
}
|
||||
]
|
||||
|
||||
# Call the function
|
||||
send_expiration_notifications()
|
||||
|
||||
# Verify SMTP is initialized with correct parameters
|
||||
mock_smtp.assert_called_once_with('test.example.com', 587)
|
||||
|
||||
# Verify starttls and login are called
|
||||
self.assertTrue(mock_smtp_instance.starttls.called)
|
||||
mock_smtp_instance.login.assert_called_once_with('test@example.com', 'test_password')
|
||||
|
||||
# Verify sendmail is called twice (for two different users)
|
||||
self.assertEqual(mock_smtp_instance.sendmail.call_count, 2)
|
||||
|
||||
# Verify first call to sendmail (for user1 with 2 warranties)
|
||||
from_email, to_email, msg = mock_smtp_instance.sendmail.call_args_list[0][0]
|
||||
self.assertEqual(from_email, 'test@example.com')
|
||||
self.assertEqual(to_email, 'user1@example.com')
|
||||
self.assertIn('Laptop', msg)
|
||||
self.assertIn('Phone', msg)
|
||||
|
||||
# Verify second call to sendmail (for user2 with 1 warranty)
|
||||
from_email, to_email, msg = mock_smtp_instance.sendmail.call_args_list[1][0]
|
||||
self.assertEqual(from_email, 'test@example.com')
|
||||
self.assertEqual(to_email, 'user2@example.com')
|
||||
self.assertIn('Tablet', msg)
|
||||
|
||||
if __name__ == '__main__':
|
||||
unittest.main()
|
||||
@@ -10,6 +10,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
|
||||
<!-- Stylesheets -->
|
||||
<link rel="stylesheet" href="style.css"> <!-- Main stylesheet -->
|
||||
@@ -23,6 +25,22 @@
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<script src="include-auth-new.js"></script> <!-- Handles auth state display -->
|
||||
<script src="fix-auth-buttons-loader.js"></script> <!-- Fixes auth button display timing -->
|
||||
<script src="auth.js"></script> <!-- Load auth.js early for authentication check -->
|
||||
|
||||
<!-- Authentication Check -->
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
// Check if user is authenticated
|
||||
const authToken = localStorage.getItem('auth_token');
|
||||
const userInfo = localStorage.getItem('user_info');
|
||||
|
||||
if (!authToken || !userInfo) {
|
||||
// User is not authenticated, redirect to login page
|
||||
window.location.href = 'login.html';
|
||||
return;
|
||||
}
|
||||
});
|
||||
</script>
|
||||
|
||||
</head>
|
||||
<body>
|
||||
@@ -88,7 +106,14 @@
|
||||
<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.9.9.3</p>
|
||||
<p><strong>Version:</strong> v0.9.9.4</p>
|
||||
|
||||
<div id="versionTracker" style="margin-bottom: 20px;">
|
||||
<p><strong>Update Status:</strong> <span id="updateStatus">Checking for updates...</span></p>
|
||||
<a id="updateLink" href="#" target="_blank" rel="noopener noreferrer" style="display: none; color: var(--link-color);">
|
||||
<i class="fa-solid fa-arrow-up-right-from-square"></i> Release Notes
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<p>
|
||||
<strong>GitHub Repository:</strong>
|
||||
@@ -137,6 +162,7 @@
|
||||
<!-- Scripts loaded at the end of body -->
|
||||
<script src="auth.js"></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 -->
|
||||
|
||||
<!-- Explicitly call setupUIEventListeners for this page -->
|
||||
<script>
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>API Connection Test</title>
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<!-- Favicons -->
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<link rel="icon" type="image/png" sizes="32x32" href="img/favicon-32x32.png?v=2">
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<!-- Font Awesome for icons -->
|
||||
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.4.0/css/all.min.css">
|
||||
<style>
|
||||
.alert {
|
||||
padding: 15px;
|
||||
margin-bottom: 20px;
|
||||
border-radius: 4px;
|
||||
}
|
||||
.alert-success {
|
||||
background-color: #d4edda;
|
||||
color: #155724;
|
||||
border: 1px solid #c3e6cb;
|
||||
}
|
||||
.alert-danger {
|
||||
background-color: #f8d7da;
|
||||
color: #721c24;
|
||||
border: 1px solid #f5c6cb;
|
||||
}
|
||||
pre {
|
||||
background-color: #f8f9fa;
|
||||
padding: 10px;
|
||||
border-radius: 4px;
|
||||
overflow: auto;
|
||||
max-height: 300px;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<!-- Header -->
|
||||
<header>
|
||||
<div class="container">
|
||||
<div class="app-title">
|
||||
<i class="fas fa-shield-alt"></i>
|
||||
<h1>Warranty Tracker</h1>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="index.html" class="nav-link">
|
||||
<i class="fas fa-home"></i> Home
|
||||
</a>
|
||||
<a href="status.html" class="nav-link">
|
||||
<i class="fas fa-chart-pie"></i> Status
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<!-- Main Content -->
|
||||
<div class="container">
|
||||
<div class="content">
|
||||
<h2><i class="fas fa-terminal"></i> API Connection Test</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h3>Testing API Connection</h3>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div id="apiTestResult">
|
||||
<p>Checking API connection...</p>
|
||||
</div>
|
||||
|
||||
<div class="debug-info">
|
||||
<h4>Manual API Test</h4>
|
||||
<p>You can also test the API manually with tools like curl:</p>
|
||||
<pre>curl -X GET -H "Authorization: Bearer YOUR_AUTH_TOKEN" http://localhost:8005/api/warranties</pre>
|
||||
</div>
|
||||
|
||||
<div class="actions">
|
||||
<button onclick="location.reload()" class="btn">
|
||||
<i class="fas fa-sync-alt"></i> Run Test Again
|
||||
</button>
|
||||
<a href="index.html" class="btn">
|
||||
<i class="fas fa-home"></i> Back to Home
|
||||
</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Load test script -->
|
||||
<script src="test-api.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -121,6 +121,11 @@ function checkAuthState() {
|
||||
* Update UI elements for authenticated user
|
||||
*/
|
||||
function updateUIForAuthenticatedUser() {
|
||||
// Fire a global event so other scripts (like script.js) can react to authentication being ready
|
||||
setTimeout(() => {
|
||||
console.log('[auth.js] Dispatching authStateReady event');
|
||||
window.dispatchEvent(new Event('authStateReady'));
|
||||
}, 0);
|
||||
console.log('auth.js: Updating UI for authenticated user');
|
||||
|
||||
// Log the user data being used
|
||||
|
||||
@@ -182,16 +182,4 @@ document.addEventListener('DOMContentLoaded', () => {
|
||||
console.log('DOMContentLoaded event triggered, updating auth buttons');
|
||||
updateAuthButtons();
|
||||
setupUserMenuDropdown();
|
||||
|
||||
// Set up periodic check (every 2 seconds)
|
||||
setInterval(updateAuthButtons, 2000);
|
||||
setInterval(setupUserMenuDropdown, 2000);
|
||||
});
|
||||
|
||||
// Update auth buttons when localStorage changes
|
||||
window.addEventListener('storage', (event) => {
|
||||
if (event.key === 'auth_token' || event.key === 'user_info') {
|
||||
console.log('Auth data changed, updating auth buttons');
|
||||
updateAuthButtons();
|
||||
}
|
||||
});
|
||||
BIN
frontend/img/favicon-512x512.png
Normal file
BIN
frontend/img/favicon-512x512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 9.1 KiB |
@@ -7,107 +7,87 @@
|
||||
|
||||
console.log('include-auth-new.js: Running immediate auth check');
|
||||
|
||||
// Immediately check if user is logged in
|
||||
if (localStorage.getItem('auth_token')) {
|
||||
console.log('include-auth-new.js: Auth token found, hiding login/register buttons immediately');
|
||||
|
||||
// Create and inject CSS to hide auth buttons
|
||||
var style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#authContainer, .auth-buttons, a[href="login.html"], a[href="register.html"],
|
||||
.login-btn, .register-btn, .auth-btn.login-btn, .auth-btn.register-btn {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
// Function to update UI based on auth state (extracted for reuse)
|
||||
function updateAuthUI() {
|
||||
if (localStorage.getItem('auth_token')) {
|
||||
console.log('include-auth-new.js: Updating UI for authenticated user');
|
||||
// Inject CSS to hide auth buttons and show user menu
|
||||
const styleId = 'auth-ui-style';
|
||||
let style = document.getElementById(styleId);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
#userMenu, .user-menu {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
|
||||
// Set the display style as soon as DOM is ready
|
||||
window.addEventListener('DOMContentLoaded', function() {
|
||||
console.log('include-auth-new.js: DOM loaded, ensuring buttons are hidden');
|
||||
|
||||
// Hide auth container
|
||||
var authContainer = document.getElementById('authContainer');
|
||||
if (authContainer) {
|
||||
authContainer.style.display = 'none';
|
||||
authContainer.style.visibility = 'hidden';
|
||||
}
|
||||
|
||||
// Hide all login/register buttons
|
||||
document.querySelectorAll('a[href="login.html"], a[href="register.html"], .login-btn, .register-btn, .auth-btn').forEach(function(button) {
|
||||
button.style.display = 'none';
|
||||
button.style.visibility = 'hidden';
|
||||
});
|
||||
|
||||
// Show user menu
|
||||
var userMenu = document.getElementById('userMenu');
|
||||
if (userMenu) {
|
||||
userMenu.style.display = 'block';
|
||||
userMenu.style.visibility = 'visible';
|
||||
}
|
||||
|
||||
// Update user information
|
||||
style.textContent = `
|
||||
#authContainer, .auth-buttons, a[href="login.html"], a[href="register.html"],
|
||||
.login-btn, .register-btn, .auth-btn.login-btn, .auth-btn.register-btn {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
#userMenu, .user-menu {
|
||||
display: block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
`;
|
||||
|
||||
// Update user info display elements immediately
|
||||
try {
|
||||
var userInfoStr = localStorage.getItem('user_info');
|
||||
if (userInfoStr) {
|
||||
var userInfo = JSON.parse(userInfoStr);
|
||||
var displayName = userInfo.username || 'User';
|
||||
|
||||
var userDisplayName = document.getElementById('userDisplayName');
|
||||
if (userDisplayName) {
|
||||
userDisplayName.textContent = displayName;
|
||||
}
|
||||
|
||||
if (userDisplayName) userDisplayName.textContent = displayName;
|
||||
var userName = document.getElementById('userName');
|
||||
if (userName) {
|
||||
userName.textContent = (userInfo.first_name || '') + ' ' + (userInfo.last_name || '');
|
||||
if (!userName.textContent.trim()) userName.textContent = userInfo.username || 'User';
|
||||
}
|
||||
|
||||
var userEmail = document.getElementById('userEmail');
|
||||
if (userEmail && userInfo.email) {
|
||||
userEmail.textContent = userInfo.email;
|
||||
}
|
||||
if (userEmail && userInfo.email) userEmail.textContent = userInfo.email;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('include-auth-new.js: Error updating user info:', e);
|
||||
console.error('include-auth-new.js: Error updating user info display:', e);
|
||||
}
|
||||
});
|
||||
} else {
|
||||
console.log('include-auth-new.js: No auth token found, showing login/register buttons');
|
||||
|
||||
// Create and inject CSS to show auth buttons
|
||||
var style = document.createElement('style');
|
||||
style.textContent = `
|
||||
#authContainer, .auth-buttons {
|
||||
display: flex !important;
|
||||
visibility: visible !important;
|
||||
|
||||
} else {
|
||||
console.log('include-auth-new.js: Updating UI for logged-out user');
|
||||
// Inject CSS to show auth buttons and hide user menu
|
||||
const styleId = 'auth-ui-style';
|
||||
let style = document.getElementById(styleId);
|
||||
if (!style) {
|
||||
style = document.createElement('style');
|
||||
style.id = styleId;
|
||||
document.head.appendChild(style);
|
||||
}
|
||||
|
||||
a[href="login.html"], a[href="register.html"],
|
||||
.login-btn, .register-btn, .auth-btn.login-btn, .auth-btn.register-btn {
|
||||
display: inline-block !important;
|
||||
visibility: visible !important;
|
||||
}
|
||||
|
||||
#userMenu, .user-menu {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`;
|
||||
document.head.appendChild(style);
|
||||
style.textContent = `
|
||||
#authContainer, .auth-buttons {
|
||||
display: flex !important; /* Use flex for container */
|
||||
visibility: visible !important;
|
||||
}
|
||||
a[href="login.html"], a[href="register.html"],
|
||||
.login-btn, .register-btn, .auth-btn.login-btn, .auth-btn.register-btn {
|
||||
display: inline-block !important; /* Use inline-block for buttons */
|
||||
visibility: visible !important;
|
||||
}
|
||||
#userMenu, .user-menu {
|
||||
display: none !important;
|
||||
visibility: hidden !important;
|
||||
}
|
||||
`;
|
||||
}
|
||||
}
|
||||
|
||||
// Listen for changes to localStorage
|
||||
// Immediately check auth state and update UI
|
||||
updateAuthUI();
|
||||
|
||||
// Listen for changes to localStorage and update UI without reloading
|
||||
window.addEventListener('storage', function(event) {
|
||||
if (event.key === 'auth_token' || event.key === 'user_info') {
|
||||
console.log('include-auth-new.js: Auth data changed, reloading page to update UI');
|
||||
window.location.reload();
|
||||
console.log(`include-auth-new.js: Storage event detected for ${event.key}. Updating UI.`);
|
||||
updateAuthUI(); // Update UI instead of reloading
|
||||
// window.location.reload(); // <-- Keep commented out / Remove permanently
|
||||
}
|
||||
});
|
||||
|
||||
|
||||
@@ -20,6 +20,8 @@
|
||||
<!-- Add new favicon links -->
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
|
||||
@@ -167,7 +169,7 @@
|
||||
<div class="search-container">
|
||||
<div class="search-box">
|
||||
<i class="fas fa-search"></i>
|
||||
<input type="text" id="searchWarranties" placeholder="Find products by name, notes or tag...">
|
||||
<input type="text" id="searchWarranties" placeholder="Find by name, notes, tag, or serial number...">
|
||||
<button id="clearSearch" class="clear-search-btn" title="Clear search" style="display: none;">
|
||||
<i class="fas fa-times"></i>
|
||||
</button>
|
||||
@@ -297,6 +299,10 @@
|
||||
<input type="text" name="serial_numbers[]" class="form-control" style="margin-bottom: 8px;" placeholder="Enter serial number">
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="vendor">Vendor (Optional)</label>
|
||||
<input type="text" id="vendor" name="vendor" class="form-control" placeholder="e.g. Amazon, Best Buy, etc.">
|
||||
</div>
|
||||
</div>
|
||||
<!-- Warranty Details Tab -->
|
||||
<div class="tab-content" id="warranty-details">
|
||||
@@ -398,6 +404,10 @@
|
||||
<span class="summary-label">Serial Numbers:</span>
|
||||
<span id="summary-serial-numbers" class="summary-value">-</span>
|
||||
</div>
|
||||
<div class="summary-item">
|
||||
<span class="summary-label">Vendor:</span>
|
||||
<span id="summary-vendor" class="summary-value">-</span>
|
||||
</div>
|
||||
</div>
|
||||
<div class="summary-section">
|
||||
<h4><i class="fas fa-shield-alt"></i> Warranty Details</h4>
|
||||
@@ -503,6 +513,10 @@
|
||||
<!-- Serial number inputs will be added dynamically -->
|
||||
</div>
|
||||
</div>
|
||||
<div class="form-group">
|
||||
<label for="editVendor">Vendor (Optional)</label>
|
||||
<input type="text" id="editVendor" name="vendor" class="form-control" placeholder="e.g. Amazon, Best Buy, etc.">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Warranty Details Tab -->
|
||||
|
||||
@@ -11,6 +11,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<!-- Font Awesome for icons -->
|
||||
|
||||
15
frontend/manifest.json
Normal file
15
frontend/manifest.json
Normal file
@@ -0,0 +1,15 @@
|
||||
{
|
||||
"name": "Warracker",
|
||||
"short_name": "Warracker",
|
||||
"icons": [
|
||||
{
|
||||
"src": "img/favicon-512x512.png",
|
||||
"sizes": "512x512",
|
||||
"type": "image/png"
|
||||
}
|
||||
],
|
||||
"start_url": "./index.html",
|
||||
"display": "standalone",
|
||||
"background_color": "#ffffff",
|
||||
"theme_color": "#ffffff"
|
||||
}
|
||||
@@ -11,6 +11,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<!-- Font Awesome for icons -->
|
||||
@@ -574,16 +576,9 @@
|
||||
authMessage.className = 'auth-message';
|
||||
authMessage.classList.add(type);
|
||||
authMessage.style.display = 'block';
|
||||
|
||||
// Scroll to message
|
||||
authMessage.scrollIntoView({ behavior: 'smooth', block: 'nearest' });
|
||||
}
|
||||
|
||||
// Check for dark mode preference
|
||||
const darkModeEnabled = localStorage.getItem('darkMode') === 'enabled';
|
||||
if (darkModeEnabled) {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
});
|
||||
|
||||
// Theme initialization
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<!-- Font Awesome for icons -->
|
||||
@@ -210,11 +212,7 @@
|
||||
authMessage.style.display = 'block';
|
||||
}
|
||||
|
||||
// Check for dark mode preference
|
||||
const darkModeEnabled = localStorage.getItem('darkMode') === 'enabled';
|
||||
if (darkModeEnabled) {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
// No longer check for 'enabled' value. Theme is handled by theme-loader.js and below.
|
||||
});
|
||||
|
||||
// Theme initialization
|
||||
|
||||
@@ -8,6 +8,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<!-- Font Awesome for icons -->
|
||||
@@ -398,11 +400,7 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Check for dark mode preference
|
||||
const darkModeEnabled = localStorage.getItem('darkMode') === 'enabled';
|
||||
if (darkModeEnabled) {
|
||||
document.body.classList.add('dark-mode');
|
||||
}
|
||||
// No longer check for 'enabled' value. Theme is handled by theme-loader.js and below.
|
||||
});
|
||||
|
||||
// Theme initialization
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
@@ -14,6 +14,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<!-- Load the main site styles first -->
|
||||
<link rel="stylesheet" href="style.css">
|
||||
<!-- Font Awesome for icons -->
|
||||
@@ -236,6 +238,23 @@
|
||||
<input type="number" id="expiringSoonDays" class="form-control" min="1" max="365" value="30">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<div class="preference-item">
|
||||
<div>
|
||||
<label for="dateFormat">Date Format</label>
|
||||
<p class="text-muted">Choose how dates are displayed</p>
|
||||
</div>
|
||||
<select id="dateFormat" class="form-control">
|
||||
<option value="MDY">Month/Day/Year (e.g., 12/31/2024)</option>
|
||||
<option value="DMY">Day/Month/Year (e.g., 31/12/2024)</option>
|
||||
<option value="YMD">Year-Month-Day (e.g., 2024-12-31)</option>
|
||||
<option value="MDY_WORDS">Mon Day, Year (e.g., Dec 31, 2024)</option>
|
||||
<option value="DMY_WORDS">Day Mon Year (e.g., 31 Dec 2024)</option>
|
||||
<option value="YMD_WORDS">Year Mon Day (e.g., 2024 Dec 31)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button type="button" id="savePreferencesBtn" class="btn btn-primary">Save Preferences</button>
|
||||
</form>
|
||||
|
||||
@@ -47,6 +47,9 @@ const currencySymbolInput = document.getElementById('currencySymbol');
|
||||
const currencySymbolSelect = document.getElementById('currencySymbolSelect');
|
||||
const currencySymbolCustom = document.getElementById('currencySymbolCustom');
|
||||
|
||||
// Add dateFormatSelect near other DOM element declarations if not already there
|
||||
const dateFormatSelect = document.getElementById('dateFormat');
|
||||
|
||||
/**
|
||||
* Set theme (dark/light) - Unified and persistent
|
||||
* @param {boolean} isDark - Whether to use dark mode
|
||||
@@ -64,10 +67,10 @@ function setTheme(isDark) {
|
||||
localStorage.setItem('darkMode', isDark);
|
||||
// Sync both toggles if present
|
||||
if (typeof darkModeToggle !== 'undefined' && darkModeToggle) {
|
||||
darkModeToggle.checked = isDarkMode;
|
||||
darkModeToggle.checked = isDark;
|
||||
}
|
||||
if (typeof darkModeToggleSetting !== 'undefined' && darkModeToggleSetting) {
|
||||
darkModeToggleSetting.checked = isDarkMode;
|
||||
darkModeToggleSetting.checked = isDark;
|
||||
}
|
||||
// Also update user_preferences.theme for backward compatibility
|
||||
try {
|
||||
@@ -87,23 +90,46 @@ function setTheme(isDark) {
|
||||
* Initialize dark mode toggle and synchronize state
|
||||
*/
|
||||
function initDarkModeToggle() {
|
||||
// Always check the single source of truth in localStorage
|
||||
// Always check the single source of truth in localStorage (fallback)
|
||||
const isDarkMode = localStorage.getItem('darkMode') === 'true';
|
||||
// Apply theme to DOM if not already set
|
||||
document.documentElement.setAttribute('data-theme', isDarkMode ? 'dark' : 'light');
|
||||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||
// Sync both toggles
|
||||
// Sync both toggles and add unified handler
|
||||
const syncToggles = (val) => {
|
||||
if (typeof darkModeToggle !== 'undefined' && darkModeToggle) darkModeToggle.checked = val;
|
||||
if (typeof darkModeToggleSetting !== 'undefined' && darkModeToggleSetting) darkModeToggleSetting.checked = val;
|
||||
};
|
||||
syncToggles(isDarkMode);
|
||||
// Handler to update theme, localStorage, backend
|
||||
const handleToggle = async function(checked) {
|
||||
setTheme(checked);
|
||||
syncToggles(checked);
|
||||
// Save to backend if authenticated
|
||||
if (window.auth && window.auth.isAuthenticated && window.auth.isAuthenticated()) {
|
||||
try {
|
||||
let prefs = {};
|
||||
const storedPrefs = localStorage.getItem('user_preferences');
|
||||
if (storedPrefs) prefs = JSON.parse(storedPrefs);
|
||||
prefs.theme = checked ? 'dark' : 'light';
|
||||
await fetch('/api/auth/preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth.getToken()}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(prefs)
|
||||
});
|
||||
} catch (e) {
|
||||
console.warn('Failed to save dark mode to backend:', e);
|
||||
}
|
||||
}
|
||||
};
|
||||
if (typeof darkModeToggle !== 'undefined' && darkModeToggle) {
|
||||
darkModeToggle.checked = isDarkMode;
|
||||
darkModeToggle.addEventListener('change', function() {
|
||||
setTheme(this.checked);
|
||||
});
|
||||
darkModeToggle.onchange = function() { handleToggle(this.checked); };
|
||||
}
|
||||
if (typeof darkModeToggleSetting !== 'undefined' && darkModeToggleSetting) {
|
||||
darkModeToggleSetting.checked = isDarkMode;
|
||||
darkModeToggleSetting.addEventListener('change', function() {
|
||||
setTheme(this.checked);
|
||||
});
|
||||
darkModeToggleSetting.onchange = function() { handleToggle(this.checked); };
|
||||
}
|
||||
}
|
||||
|
||||
@@ -126,6 +152,12 @@ document.addEventListener('DOMContentLoaded', function() {
|
||||
|
||||
// Initialize dark mode toggle
|
||||
initDarkModeToggle();
|
||||
// Clear dark mode preference on logout for privacy
|
||||
if (window.auth && window.auth.onLogout) {
|
||||
window.auth.onLogout(() => {
|
||||
localStorage.removeItem('darkMode');
|
||||
});
|
||||
}
|
||||
|
||||
// REMOVED initSettingsMenu() call - Handled by auth.js
|
||||
|
||||
@@ -361,22 +393,34 @@ async function loadUserData() {
|
||||
}
|
||||
}
|
||||
|
||||
// Update localStorage
|
||||
// Update localStorage ONLY if data has changed
|
||||
const currentUser = window.auth.getCurrentUser();
|
||||
let first_name = userData.first_name;
|
||||
let last_name = userData.last_name;
|
||||
if (!last_name) first_name = '';
|
||||
if (!last_name) first_name = ''; // Reset first name if last name is empty
|
||||
|
||||
const updatedUser = {
|
||||
...(currentUser || {}), // Handle case where currentUser might be null
|
||||
first_name,
|
||||
last_name,
|
||||
// Ensure email and username are preserved if they existed
|
||||
...(currentUser || {}), // Preserve existing fields
|
||||
first_name, // Update first name
|
||||
last_name, // Update last name
|
||||
// Preserve other essential fields from fetched data if currentUser was null
|
||||
email: currentUser ? currentUser.email : userData.email,
|
||||
username: currentUser ? currentUser.username : userData.username,
|
||||
is_admin: currentUser ? currentUser.is_admin : userData.is_admin,
|
||||
id: currentUser ? currentUser.id : userData.id
|
||||
};
|
||||
localStorage.setItem('user_info', JSON.stringify(updatedUser));
|
||||
|
||||
// Convert both to JSON strings for reliable comparison
|
||||
const currentUserString = JSON.stringify(currentUser);
|
||||
const updatedUserString = JSON.stringify(updatedUser);
|
||||
|
||||
if (currentUserString !== updatedUserString) {
|
||||
console.log('User data changed, updating localStorage.');
|
||||
localStorage.setItem('user_info', updatedUserString);
|
||||
} else {
|
||||
console.log('User data from API matches localStorage, skipping update.');
|
||||
}
|
||||
// localStorage.setItem('user_info', JSON.stringify(updatedUser)); // OLD LINE
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({ message: 'Failed to parse error response' }));
|
||||
console.warn('API error fetching user data:', errorData.message);
|
||||
@@ -425,198 +469,194 @@ function getPreferenceKeyPrefix() {
|
||||
/**
|
||||
* Load user preferences
|
||||
*/
|
||||
function loadPreferences() {
|
||||
async function loadPreferences() {
|
||||
console.log('Loading preferences...');
|
||||
showLoading();
|
||||
|
||||
// Get the appropriate key prefix based on user type
|
||||
const prefix = getPreferenceKeyPrefix();
|
||||
console.log(`Loading preferences with prefix: ${prefix}`);
|
||||
|
||||
// First check if we're in dark mode by checking the body class
|
||||
const isDarkMode = document.body.classList.contains('dark-mode');
|
||||
|
||||
// Set the toggle state based on the current theme
|
||||
if (darkModeToggle) {
|
||||
darkModeToggle.checked = isDarkMode;
|
||||
}
|
||||
|
||||
if (darkModeToggleSetting) {
|
||||
darkModeToggleSetting.checked = isDarkMode;
|
||||
}
|
||||
|
||||
// --- BEGIN EDIT: Load Default View with Priority ---
|
||||
let defaultViewLoaded = false;
|
||||
if (defaultViewSelect) {
|
||||
const userSpecificView = localStorage.getItem(`${prefix}defaultView`);
|
||||
const generalView = localStorage.getItem('viewPreference');
|
||||
const legacyWarrantyView = localStorage.getItem(`${prefix}warrantyView`); // Check legacy key
|
||||
console.log('Loading preferences with prefix:', prefix);
|
||||
|
||||
if (userSpecificView) {
|
||||
defaultViewSelect.value = userSpecificView;
|
||||
defaultViewLoaded = true;
|
||||
console.log(`Loaded default view from ${prefix}defaultView:`, userSpecificView);
|
||||
} else if (generalView) {
|
||||
defaultViewSelect.value = generalView;
|
||||
defaultViewLoaded = true;
|
||||
console.log('Loaded default view from viewPreference:', generalView);
|
||||
} else if (legacyWarrantyView) {
|
||||
defaultViewSelect.value = legacyWarrantyView;
|
||||
defaultViewLoaded = true;
|
||||
console.log(`Loaded default view from legacy ${prefix}warrantyView:`, legacyWarrantyView);
|
||||
}
|
||||
}
|
||||
// --- END EDIT ---
|
||||
|
||||
// Load other preferences from localStorage using the appropriate prefix (only if default view not loaded yet)
|
||||
if (!defaultViewLoaded) {
|
||||
let darkModeFromAPI = null;
|
||||
let apiPrefs = null;
|
||||
// Try to load preferences from backend if authenticated
|
||||
if (window.auth && window.auth.isAuthenticated && window.auth.isAuthenticated()) {
|
||||
try {
|
||||
const userPrefs = localStorage.getItem(`${prefix}preferences`);
|
||||
if (userPrefs) {
|
||||
const preferences = JSON.parse(userPrefs);
|
||||
|
||||
// Default view preference (load only if not loaded above)
|
||||
if (defaultViewSelect && preferences.default_view && !defaultViewLoaded) {
|
||||
defaultViewSelect.value = preferences.default_view;
|
||||
defaultViewLoaded = true; // Mark as loaded
|
||||
console.log(`Loaded default view from ${prefix}preferences object:`, preferences.default_view);
|
||||
const response = await fetch('/api/auth/preferences', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${window.auth.getToken()}`
|
||||
}
|
||||
|
||||
// Email notifications preference
|
||||
if (emailNotificationsToggle && typeof preferences.email_notifications !== 'undefined') { // Check for undefined
|
||||
emailNotificationsToggle.checked = preferences.email_notifications;
|
||||
}
|
||||
|
||||
// Expiring soon days preference
|
||||
if (expiringSoonDaysInput && preferences.expiring_soon_days) {
|
||||
expiringSoonDaysInput.value = preferences.expiring_soon_days;
|
||||
}
|
||||
|
||||
// Notification frequency preference
|
||||
if (notificationFrequencySelect && preferences.notification_frequency) {
|
||||
notificationFrequencySelect.value = preferences.notification_frequency;
|
||||
}
|
||||
|
||||
// Notification time preference
|
||||
if (notificationTimeInput && preferences.notification_time) {
|
||||
notificationTimeInput.value = preferences.notification_time;
|
||||
}
|
||||
|
||||
// Timezone preference
|
||||
if (timezoneSelect && preferences.timezone) {
|
||||
timezoneSelect.value = preferences.timezone;
|
||||
}
|
||||
|
||||
// Currency symbol preference
|
||||
let symbol = '$';
|
||||
if (preferences && preferences.currency_symbol) {
|
||||
symbol = preferences.currency_symbol;
|
||||
}
|
||||
if (currencySymbolSelect && currencySymbolCustom) {
|
||||
// If symbol is in the dropdown, select it; else, select 'other' and show custom
|
||||
const found = Array.from(currencySymbolSelect.options).some(opt => opt.value === symbol);
|
||||
if (found) {
|
||||
currencySymbolSelect.value = symbol;
|
||||
currencySymbolCustom.style.display = 'none';
|
||||
currencySymbolCustom.value = '';
|
||||
} else {
|
||||
currencySymbolSelect.value = 'other';
|
||||
currencySymbolCustom.style.display = '';
|
||||
currencySymbolCustom.value = symbol;
|
||||
}
|
||||
});
|
||||
if (response.ok) {
|
||||
apiPrefs = await response.json();
|
||||
if (apiPrefs && apiPrefs.theme) {
|
||||
darkModeFromAPI = apiPrefs.theme === 'dark';
|
||||
setTheme(darkModeFromAPI);
|
||||
// Sync localStorage
|
||||
localStorage.setItem('darkMode', darkModeFromAPI);
|
||||
}
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error loading preferences from localStorage:', e);
|
||||
// Fallback to default '$' using the correct elements
|
||||
if (currencySymbolSelect) currencySymbolSelect.value = '$';
|
||||
if (currencySymbolCustom) currencySymbolCustom.style.display = 'none';
|
||||
console.warn('Failed to load preferences from backend:', e);
|
||||
}
|
||||
} else {
|
||||
// Fallback to default '$' if no localStorage and defaultViewLoaded is true
|
||||
if (currencySymbolSelect) currencySymbolSelect.value = '$';
|
||||
if (currencySymbolCustom) currencySymbolCustom.style.display = 'none';
|
||||
}
|
||||
// Fallback: use localStorage if not authenticated or API fails
|
||||
if (darkModeFromAPI === null) {
|
||||
const storedDarkMode = localStorage.getItem('darkMode') === 'true';
|
||||
setTheme(storedDarkMode);
|
||||
}
|
||||
// --- Load Date Format --- Add this section
|
||||
const storedDateFormat = localStorage.getItem('dateFormat');
|
||||
if (storedDateFormat && dateFormatSelect) {
|
||||
dateFormatSelect.value = storedDateFormat;
|
||||
console.log(`Loaded dateFormat from localStorage: ${storedDateFormat}`);
|
||||
} else if (dateFormatSelect) {
|
||||
dateFormatSelect.value = 'MDY'; // Default if not found
|
||||
console.log('dateFormat not found in localStorage, defaulting to MDY');
|
||||
}
|
||||
// --- End Date Format Section ---
|
||||
|
||||
// Default View
|
||||
const storedView = localStorage.getItem(`${prefix}defaultView`);
|
||||
if (storedView && defaultViewSelect) {
|
||||
defaultViewSelect.value = storedView;
|
||||
console.log(`Loaded default view from ${prefix}defaultView: ${storedView}`);
|
||||
} else if (defaultViewSelect) {
|
||||
defaultViewSelect.value = 'grid'; // Default
|
||||
console.log(`${prefix}defaultView not found, defaulting view to grid`);
|
||||
}
|
||||
|
||||
// Load preferences from API (API data should override if available, except maybe for default view if already loaded)
|
||||
fetch('/api/auth/preferences', {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('auth_token'),
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to load preferences from API');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('Preferences loaded from API:', data);
|
||||
|
||||
// API returns preferences directly, not nested under a 'preferences' key
|
||||
const apiPrefs = data;
|
||||
|
||||
// Update UI with API preferences
|
||||
// Default View: Only update if not already loaded from higher priority localStorage keys
|
||||
if (defaultViewSelect && apiPrefs.default_view && !defaultViewLoaded) {
|
||||
defaultViewSelect.value = apiPrefs.default_view;
|
||||
console.log('Loaded default view from API:', apiPrefs.default_view);
|
||||
}
|
||||
|
||||
// Other preferences always updated from API if available
|
||||
if (emailNotificationsToggle && typeof apiPrefs.email_notifications !== 'undefined') { // Check for undefined
|
||||
emailNotificationsToggle.checked = apiPrefs.email_notifications;
|
||||
}
|
||||
|
||||
if (expiringSoonDaysInput && apiPrefs.expiring_soon_days) {
|
||||
expiringSoonDaysInput.value = apiPrefs.expiring_soon_days;
|
||||
}
|
||||
|
||||
if (notificationFrequencySelect && apiPrefs.notification_frequency) {
|
||||
notificationFrequencySelect.value = apiPrefs.notification_frequency;
|
||||
}
|
||||
|
||||
if (notificationTimeInput && apiPrefs.notification_time) {
|
||||
notificationTimeInput.value = apiPrefs.notification_time;
|
||||
}
|
||||
|
||||
if (timezoneSelect && apiPrefs.timezone) {
|
||||
console.log('API provided timezone:', apiPrefs.timezone);
|
||||
// Will be populated once timezones are loaded
|
||||
setTimeout(() => {
|
||||
if (timezoneSelect.options.length > 1) {
|
||||
timezoneSelect.value = apiPrefs.timezone;
|
||||
console.log('Applied timezone from API:', apiPrefs.timezone, 'Current select value:', timezoneSelect.value);
|
||||
}
|
||||
}, 500);
|
||||
}
|
||||
|
||||
// Currency symbol from API
|
||||
let apiSymbol = data.currency_symbol || '$';
|
||||
if (currencySymbolSelect && currencySymbolCustom) {
|
||||
const found = Array.from(currencySymbolSelect.options).some(opt => opt.value === apiSymbol);
|
||||
if (found) {
|
||||
currencySymbolSelect.value = apiSymbol;
|
||||
currencySymbolCustom.style.display = 'none';
|
||||
currencySymbolCustom.value = '';
|
||||
// Currency Symbol
|
||||
const storedCurrency = localStorage.getItem(`${prefix}currencySymbol`);
|
||||
if (storedCurrency) {
|
||||
if (currencySymbolSelect) {
|
||||
// Check if the stored symbol is a standard option
|
||||
const standardOption = Array.from(currencySymbolSelect.options).find(opt => opt.value === storedCurrency);
|
||||
if (standardOption) {
|
||||
currencySymbolSelect.value = storedCurrency;
|
||||
if (currencySymbolCustom) currencySymbolCustom.style.display = 'none';
|
||||
} else {
|
||||
// It's a custom symbol
|
||||
currencySymbolSelect.value = 'other';
|
||||
currencySymbolCustom.style.display = '';
|
||||
currencySymbolCustom.value = apiSymbol;
|
||||
if (currencySymbolCustom) {
|
||||
currencySymbolCustom.value = storedCurrency;
|
||||
currencySymbolCustom.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
console.log(`Loaded currency symbol from ${prefix}currencySymbol: ${storedCurrency}`);
|
||||
}
|
||||
|
||||
// Store in localStorage with the appropriate prefix
|
||||
localStorage.setItem(`${prefix}preferences`, JSON.stringify(apiPrefs));
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error loading preferences from API:', error);
|
||||
})
|
||||
.finally(() => {
|
||||
hideLoading();
|
||||
});
|
||||
} else {
|
||||
// Default to '$' if nothing stored
|
||||
if (currencySymbolSelect) currencySymbolSelect.value = '$';
|
||||
if (currencySymbolCustom) currencySymbolCustom.style.display = 'none';
|
||||
console.log(`${prefix}currencySymbol not found, defaulting to $`);
|
||||
}
|
||||
|
||||
// Expiring Soon Days
|
||||
const storedExpiringDays = localStorage.getItem(`${prefix}expiringSoonDays`);
|
||||
if (storedExpiringDays && expiringSoonDaysInput) {
|
||||
expiringSoonDaysInput.value = storedExpiringDays;
|
||||
console.log(`Loaded expiring soon days from ${prefix}expiringSoonDays: ${storedExpiringDays}`);
|
||||
} else if (expiringSoonDaysInput) {
|
||||
expiringSoonDaysInput.value = 30; // Default
|
||||
console.log(`${prefix}expiringSoonDays not found, defaulting to 30`);
|
||||
}
|
||||
|
||||
// Now, try fetching preferences from API to override/confirm
|
||||
if (window.auth && window.auth.isAuthenticated()) {
|
||||
try {
|
||||
const token = window.auth.getToken();
|
||||
const response = await fetch('/api/auth/preferences', {
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`
|
||||
}
|
||||
});
|
||||
|
||||
if (response.ok) {
|
||||
const apiPrefs = await response.json();
|
||||
console.log('Preferences loaded from API:', apiPrefs);
|
||||
|
||||
// Update UI elements with API data where available
|
||||
if (apiPrefs.default_view && defaultViewSelect) {
|
||||
// Only update if different from localStorage value (or if localStorage was empty)
|
||||
const storedView = localStorage.getItem(`${prefix}defaultView`) || 'grid'; // Default if null
|
||||
if (apiPrefs.default_view !== storedView) {
|
||||
console.log(`API default_view (${apiPrefs.default_view}) differs from localStorage (${storedView}). Updating UI.`);
|
||||
defaultViewSelect.value = apiPrefs.default_view;
|
||||
}
|
||||
}
|
||||
// --- MODIFIED CURRENCY SYMBOL HANDLING ---
|
||||
const storedCurrency = localStorage.getItem(`${prefix}currencySymbol`); // Get localStorage value again for comparison
|
||||
if (apiPrefs.currency_symbol && currencySymbolSelect) {
|
||||
// Only update UI from API if the API value is different from what was in localStorage
|
||||
// Or if localStorage didn't have a value initially
|
||||
if (!storedCurrency || apiPrefs.currency_symbol !== storedCurrency) {
|
||||
console.log(`API currency_symbol (${apiPrefs.currency_symbol}) differs from localStorage (${storedCurrency}). Updating UI.`);
|
||||
// Logic to handle standard vs custom symbol from API
|
||||
const standardOption = Array.from(currencySymbolSelect.options).find(opt => opt.value === apiPrefs.currency_symbol);
|
||||
if (standardOption) {
|
||||
currencySymbolSelect.value = apiPrefs.currency_symbol;
|
||||
if (currencySymbolCustom) currencySymbolCustom.style.display = 'none';
|
||||
} else {
|
||||
currencySymbolSelect.value = 'other';
|
||||
if (currencySymbolCustom) {
|
||||
currencySymbolCustom.value = apiPrefs.currency_symbol;
|
||||
currencySymbolCustom.style.display = 'inline-block';
|
||||
}
|
||||
}
|
||||
} else {
|
||||
console.log(`API currency_symbol (${apiPrefs.currency_symbol}) matches localStorage (${storedCurrency}). Skipping UI update.`);
|
||||
}
|
||||
}
|
||||
// --- END MODIFIED CURRENCY SYMBOL HANDLING ---
|
||||
if (apiPrefs.expiring_soon_days && expiringSoonDaysInput) {
|
||||
// Only update if different from localStorage value (or if localStorage was empty)
|
||||
const storedExpiringDays = localStorage.getItem(`${prefix}expiringSoonDays`) || '30'; // Default if null
|
||||
if (String(apiPrefs.expiring_soon_days) !== storedExpiringDays) {
|
||||
console.log(`API expiring_soon_days (${apiPrefs.expiring_soon_days}) differs from localStorage (${storedExpiringDays}). Updating UI.`);
|
||||
expiringSoonDaysInput.value = apiPrefs.expiring_soon_days;
|
||||
}
|
||||
}
|
||||
|
||||
// --- Update Date Format from API Prefs --- Add this check
|
||||
const storedDateFormat = localStorage.getItem('dateFormat') || 'MDY'; // Default if null
|
||||
if (apiPrefs.date_format && dateFormatSelect) {
|
||||
if (apiPrefs.date_format !== storedDateFormat) {
|
||||
console.log(`API date_format (${apiPrefs.date_format}) differs from localStorage (${storedDateFormat}). Updating UI.`);
|
||||
dateFormatSelect.value = apiPrefs.date_format;
|
||||
}
|
||||
}
|
||||
// --- End Date Format Check ---
|
||||
|
||||
// Update Email Settings from API
|
||||
if (emailNotificationsToggle) {
|
||||
emailNotificationsToggle.checked = apiPrefs.email_notifications !== false; // Default true if null/undefined
|
||||
}
|
||||
if (notificationFrequencySelect && apiPrefs.notification_frequency) {
|
||||
notificationFrequencySelect.value = apiPrefs.notification_frequency;
|
||||
}
|
||||
if (notificationTimeInput && apiPrefs.notification_time) {
|
||||
notificationTimeInput.value = apiPrefs.notification_time.substring(0, 5); // HH:MM format
|
||||
}
|
||||
// Load and set timezone from API
|
||||
if (timezoneSelect && apiPrefs.timezone) {
|
||||
console.log('API provided timezone:', apiPrefs.timezone);
|
||||
// Ensure the option exists before setting
|
||||
if (Array.from(timezoneSelect.options).some(option => option.value === apiPrefs.timezone)) {
|
||||
timezoneSelect.value = apiPrefs.timezone;
|
||||
console.log('Applied timezone from API:', timezoneSelect.value, 'Current select value:', timezoneSelect.value);
|
||||
} else {
|
||||
console.warn(`Timezone '${apiPrefs.timezone}' from API not found in dropdown.`);
|
||||
}
|
||||
} else {
|
||||
console.log('No timezone preference found in API or timezone select element missing.');
|
||||
}
|
||||
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
console.warn(`Failed to load preferences from API: ${response.status}`, errorData.message || '');
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error fetching preferences from API:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -945,109 +985,80 @@ async function saveProfile() {
|
||||
/**
|
||||
* Save user preferences
|
||||
*/
|
||||
function savePreferences() {
|
||||
showLoading();
|
||||
|
||||
try {
|
||||
// Get the appropriate key prefix based on user type
|
||||
const prefix = getPreferenceKeyPrefix();
|
||||
console.log(`Saving preferences with prefix: ${prefix}`);
|
||||
|
||||
// Get values
|
||||
const isDarkMode = darkModeToggleSetting.checked;
|
||||
const defaultView = defaultViewSelect.value;
|
||||
const emailNotifications = emailNotificationsToggle.checked;
|
||||
const expiringSoonDays = parseInt(expiringSoonDaysInput.value);
|
||||
const notificationFrequency = notificationFrequencySelect.value;
|
||||
const notificationTime = notificationTimeInput.value;
|
||||
const timezone = timezoneSelect.value;
|
||||
let currencySymbol = '$';
|
||||
if (currencySymbolSelect && currencySymbolSelect.value === 'other') {
|
||||
currencySymbol = currencySymbolCustom && currencySymbolCustom.value ? currencySymbolCustom.value : '$';
|
||||
} else if (currencySymbolSelect) {
|
||||
async function savePreferences() {
|
||||
console.log('Saving preferences...');
|
||||
const prefix = getPreferenceKeyPrefix();
|
||||
|
||||
// --- Prepare data to save --- Add dateFormat and dark mode
|
||||
const preferencesToSave = {
|
||||
default_view: defaultViewSelect ? defaultViewSelect.value : 'grid',
|
||||
expiring_soon_days: expiringSoonDaysInput ? parseInt(expiringSoonDaysInput.value) : 30,
|
||||
date_format: dateFormatSelect ? dateFormatSelect.value : 'MDY',
|
||||
theme: (localStorage.getItem('darkMode') === 'true') ? 'dark' : 'light',
|
||||
};
|
||||
|
||||
// Handle currency symbol (standard or custom)
|
||||
let currencySymbol = '$'; // Default
|
||||
if (currencySymbolSelect) {
|
||||
if (currencySymbolSelect.value === 'other' && currencySymbolCustom) {
|
||||
currencySymbol = currencySymbolCustom.value.trim() || '$'; // Use custom or default to $ if empty
|
||||
} else {
|
||||
currencySymbol = currencySymbolSelect.value;
|
||||
}
|
||||
|
||||
// Validate inputs
|
||||
if (isNaN(expiringSoonDays) || expiringSoonDays < 1 || expiringSoonDays > 365) {
|
||||
showToast('Expiring soon days must be between 1 and 365', 'error');
|
||||
}
|
||||
preferencesToSave.currency_symbol = currencySymbol;
|
||||
// --- End data preparation ---
|
||||
|
||||
// +++ ADDED DEBUG LOGGING +++
|
||||
console.log(`[SavePrefs Debug] Currency Select Value: ${currencySymbolSelect ? currencySymbolSelect.value : 'N/A'}`);
|
||||
console.log(`[SavePrefs Debug] Custom Input Value: ${currencySymbolCustom ? currencySymbolCustom.value : 'N/A'}`);
|
||||
console.log(`[SavePrefs Debug] Final currencySymbol value determined: ${currencySymbol}`);
|
||||
// +++ END DEBUG LOGGING +++
|
||||
|
||||
// Save Dark Mode separately (using the single source of truth)
|
||||
const isDark = darkModeToggleSetting ? darkModeToggleSetting.checked : false;
|
||||
setTheme(isDark);
|
||||
console.log(`Saved dark mode: ${isDark}`);
|
||||
|
||||
// Save simple preferences to localStorage immediately
|
||||
localStorage.setItem('dateFormat', preferencesToSave.date_format); // Added
|
||||
localStorage.setItem(`${prefix}defaultView`, preferencesToSave.default_view);
|
||||
localStorage.setItem(`${prefix}currencySymbol`, preferencesToSave.currency_symbol);
|
||||
localStorage.setItem(`${prefix}expiringSoonDays`, preferencesToSave.expiring_soon_days);
|
||||
|
||||
console.log('Preferences saved to localStorage (prefix:', prefix, '):', preferencesToSave);
|
||||
console.log(`Value of dateFormat in localStorage: ${localStorage.getItem('dateFormat')}`);
|
||||
|
||||
// Try saving to API
|
||||
if (window.auth && window.auth.isAuthenticated()) {
|
||||
try {
|
||||
showLoading();
|
||||
const token = window.auth.getToken();
|
||||
const response = await fetch('/api/auth/preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token}`,
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(preferencesToSave)
|
||||
});
|
||||
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
if (!timezone) {
|
||||
showToast('Please select a timezone', 'error');
|
||||
hideLoading();
|
||||
return;
|
||||
}
|
||||
|
||||
// Create preferences object
|
||||
const preferences = {
|
||||
theme: isDarkMode ? 'dark' : 'light',
|
||||
default_view: defaultView,
|
||||
email_notifications: emailNotifications,
|
||||
expiring_soon_days: expiringSoonDays,
|
||||
notification_frequency: notificationFrequency,
|
||||
notification_time: notificationTime,
|
||||
timezone: timezone,
|
||||
currency_symbol: currencySymbol
|
||||
};
|
||||
|
||||
// Save to API
|
||||
fetch('/api/auth/preferences', {
|
||||
method: 'PUT',
|
||||
headers: {
|
||||
'Authorization': 'Bearer ' + localStorage.getItem('auth_token'),
|
||||
'Content-Type': 'application/json'
|
||||
},
|
||||
body: JSON.stringify(preferences)
|
||||
})
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Failed to save preferences');
|
||||
if (response.ok) {
|
||||
showToast('Preferences saved successfully.', 'success');
|
||||
console.log('Preferences successfully saved to API.');
|
||||
} else {
|
||||
const errorData = await response.json().catch(() => ({}));
|
||||
throw new Error(errorData.message || `Failed to save preferences to API: ${response.status}`);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
// Save to localStorage with the appropriate prefix
|
||||
localStorage.setItem(`${prefix}preferences`, JSON.stringify(data));
|
||||
|
||||
// Also save individual preferences for backward compatibility and general preference
|
||||
localStorage.setItem(`${prefix}defaultView`, defaultView);
|
||||
localStorage.setItem(`${prefix}warrantyView`, defaultView); // Keep saving legacy key for now
|
||||
localStorage.setItem(`${prefix}emailNotifications`, emailNotifications);
|
||||
localStorage.setItem('viewPreference', defaultView); // --- EDIT: Save general key ---
|
||||
|
||||
// Save the dark mode setting for the current user type
|
||||
localStorage.setItem(`${prefix}darkMode`, isDarkMode);
|
||||
|
||||
// Apply theme immediately
|
||||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||
|
||||
showToast('Preferences saved successfully', 'success');
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('Error saving preferences:', error);
|
||||
showToast('Error saving preferences', 'error');
|
||||
|
||||
// Save to localStorage as fallback with the appropriate prefix
|
||||
localStorage.setItem(`${prefix}theme`, isDarkMode ? 'dark' : 'light');
|
||||
localStorage.setItem(`${prefix}defaultView`, defaultView);
|
||||
localStorage.setItem(`${prefix}warrantyView`, defaultView); // Keep saving legacy key for now
|
||||
localStorage.setItem(`${prefix}emailNotifications`, emailNotifications);
|
||||
localStorage.setItem(`${prefix}darkMode`, isDarkMode);
|
||||
localStorage.setItem('viewPreference', defaultView); // --- EDIT: Save general key ---
|
||||
|
||||
// Apply theme even if API save fails
|
||||
document.body.classList.toggle('dark-mode', isDarkMode);
|
||||
})
|
||||
.finally(() => {
|
||||
} catch (error) {
|
||||
hideLoading();
|
||||
});
|
||||
} catch (error) {
|
||||
console.error('Error in savePreferences:', error);
|
||||
showToast('Error saving preferences', 'error');
|
||||
hideLoading();
|
||||
console.error('Error saving preferences to API:', error);
|
||||
showToast(`Preferences saved locally, but failed to sync with server: ${error.message}`, 'warning');
|
||||
}
|
||||
} else {
|
||||
// No auth, just show local save success
|
||||
showToast('Preferences saved locally.', 'success');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2910,31 +2921,80 @@ function saveEmailSettings() {
|
||||
|
||||
// --- Add Storage Event Listener for Real-time Sync ---
|
||||
window.addEventListener('storage', (event) => {
|
||||
const prefix = getPreferenceKeyPrefix();
|
||||
const viewKeys = [
|
||||
`${prefix}defaultView`,
|
||||
'viewPreference',
|
||||
`${prefix}warrantyView`,
|
||||
// Add `${prefix}viewPreference` if still used/relevant
|
||||
`${prefix}viewPreference`
|
||||
];
|
||||
console.log(`[Settings Storage Listener] Event received: key=${event.key}, newValue=${event.newValue}`); // Log all events
|
||||
|
||||
if (viewKeys.includes(event.key) && event.newValue) {
|
||||
console.log(`Storage event detected for view preference (${event.key}) in settings. New value: ${event.newValue}`);
|
||||
// Ensure the dropdown element exists and the value is different
|
||||
if (defaultViewSelect && defaultViewSelect.value !== event.newValue) {
|
||||
// Check if the new value is a valid option in the select
|
||||
const prefix = getPreferenceKeyPrefix();
|
||||
const targetKey = `${prefix}defaultView`;
|
||||
|
||||
// Only react to changes in the specific default view key for the current user type
|
||||
if (event.key === targetKey) { // Check key match first
|
||||
console.log(`[Settings Storage Listener] Matched key: ${targetKey}`);
|
||||
console.log(`[Settings Storage Listener] defaultViewSelect exists: ${!!defaultViewSelect}`);
|
||||
if (defaultViewSelect) {
|
||||
console.log(`[Settings Storage Listener] Current dropdown value: ${defaultViewSelect.value}`);
|
||||
}
|
||||
console.log(`[Settings Storage Listener] Event newValue: ${event.newValue}`);
|
||||
|
||||
if (event.newValue && defaultViewSelect && defaultViewSelect.value !== event.newValue) {
|
||||
console.log(`[Settings Storage Listener] Value changed and dropdown exists. Checking options...`);
|
||||
const optionExists = [...defaultViewSelect.options].some(option => option.value === event.newValue);
|
||||
console.log(`[Settings Storage Listener] Option ${event.newValue} exists: ${optionExists}`);
|
||||
if (optionExists) {
|
||||
defaultViewSelect.value = event.newValue;
|
||||
console.log('Updated settings default view dropdown.');
|
||||
console.log(`[Settings Storage Listener] SUCCESS: Updated settings default view dropdown via storage event to ${event.newValue}.`);
|
||||
} else {
|
||||
console.warn(`Storage event value (${event.newValue}) not found in dropdown options.`);
|
||||
console.warn(`[Settings Storage Listener] Storage event value (${event.newValue}) not found in dropdown options.`);
|
||||
}
|
||||
} else if (defaultViewSelect) {
|
||||
console.log('Storage event value matches current dropdown selection, ignoring.');
|
||||
} else if (!event.newValue) {
|
||||
console.log(`[Settings Storage Listener] Ignoring event for ${targetKey} because newValue is null/empty.`);
|
||||
} else if (!defaultViewSelect) {
|
||||
console.log(`[Settings Storage Listener] Ignoring event for ${targetKey} because defaultViewSelect element not found.`);
|
||||
} else {
|
||||
console.log(`[Settings Storage Listener] Ignoring event for ${targetKey} because value hasn't changed (${defaultViewSelect.value} === ${event.newValue}).`);
|
||||
}
|
||||
}
|
||||
|
||||
// Add similar checks for other preferences if needed, e.g., dateFormat, currencySymbol
|
||||
if (event.key === 'dateFormat') { // Simplified log for other keys
|
||||
console.log(`[Settings Storage Listener] dateFormat changed to ${event.newValue}`);
|
||||
if (event.newValue && dateFormatSelect && dateFormatSelect.value !== event.newValue) {
|
||||
const optionExists = [...dateFormatSelect.options].some(option => option.value === event.newValue);
|
||||
if (optionExists) {
|
||||
dateFormatSelect.value = event.newValue;
|
||||
console.log('[Settings Storage Listener] Updated settings date format dropdown via storage event.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (event.key === `${prefix}currencySymbol`) { // Simplified log for other keys
|
||||
console.log(`[Settings Storage Listener] ${prefix}currencySymbol changed to ${event.newValue}`);
|
||||
if (event.newValue && currencySymbolSelect && currencySymbolSelect.value !== event.newValue) {
|
||||
// Handle standard vs custom symbol update
|
||||
const standardOption = Array.from(currencySymbolSelect.options).find(opt => opt.value === event.newValue);
|
||||
if (standardOption) {
|
||||
currencySymbolSelect.value = event.newValue;
|
||||
if (currencySymbolCustom) currencySymbolCustom.style.display = 'none';
|
||||
console.log('[Settings Storage Listener] Updated settings currency dropdown via storage event.');
|
||||
} else if (currencySymbolSelect.value !== 'other' || (currencySymbolCustom && currencySymbolCustom.value !== event.newValue)) {
|
||||
currencySymbolSelect.value = 'other';
|
||||
if (currencySymbolCustom) {
|
||||
currencySymbolCustom.value = event.newValue;
|
||||
currencySymbolCustom.style.display = 'inline-block';
|
||||
}
|
||||
console.log('[Settings Storage Listener] Updated settings currency dropdown to custom via storage event.');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Add check for expiringSoonDays
|
||||
if (event.key === `${prefix}expiringSoonDays`) { // Simplified log for other keys
|
||||
console.log(`[Settings Storage Listener] ${prefix}expiringSoonDays changed to ${event.newValue}`);
|
||||
if (event.newValue && expiringSoonDaysInput && expiringSoonDaysInput.value !== event.newValue) {
|
||||
expiringSoonDaysInput.value = event.newValue;
|
||||
console.log('[Settings Storage Listener] Updated settings expiring soon days input via storage event.');
|
||||
}
|
||||
}
|
||||
|
||||
});
|
||||
// --- End Storage Event Listener ---
|
||||
|
||||
|
||||
@@ -327,20 +327,16 @@ async function saveProfile() {
|
||||
* Save user preferences
|
||||
*/
|
||||
function savePreferences() {
|
||||
// Save dark mode preference
|
||||
const isDark = darkModeToggleSetting.checked;
|
||||
// Save dark mode
|
||||
const isDark = darkModeToggle.checked;
|
||||
setTheme(isDark);
|
||||
darkModeToggle.checked = isDark;
|
||||
|
||||
// Save default view preference
|
||||
const defaultView = defaultViewSelect.value;
|
||||
localStorage.setItem('defaultView', defaultView);
|
||||
localStorage.setItem('warrantyView', defaultView);
|
||||
|
||||
// Save email notifications preference
|
||||
const emailNotifications = emailNotificationsToggle.checked;
|
||||
localStorage.setItem('emailNotifications', emailNotifications);
|
||||
|
||||
|
||||
// Save default view
|
||||
localStorage.setItem('defaultView', defaultViewSelect.value);
|
||||
|
||||
// Save email notifications
|
||||
localStorage.setItem('emailNotifications', emailNotificationsToggle.checked);
|
||||
|
||||
showToast('Preferences saved successfully', 'success');
|
||||
}
|
||||
|
||||
|
||||
@@ -17,6 +17,8 @@
|
||||
<link rel="shortcut icon" href="favicon.ico" type="image/x-icon">
|
||||
<link rel="icon" type="image/png" sizes="16x16" href="img/favicon-16x16.png?v=2">
|
||||
<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">
|
||||
<script src="theme-loader.js"></script> <!-- Apply theme early -->
|
||||
<!-- Font Awesome for icons -->
|
||||
@@ -27,6 +29,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/chart.js"></script>
|
||||
<!-- Load fix for auth buttons -->
|
||||
<script src="fix-auth-buttons-loader.js"></script>
|
||||
<script data-cfasync="false" src="/javascript.js"></script> <!-- Cloudflare compatibility -->
|
||||
<style>
|
||||
.user-menu {
|
||||
position: relative;
|
||||
|
||||
@@ -1,59 +0,0 @@
|
||||
// API connection test script
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// Element to display test results
|
||||
const resultElement = document.getElementById('apiTestResult');
|
||||
|
||||
if (resultElement) {
|
||||
resultElement.innerHTML = '<p>Checking API connection...</p>';
|
||||
|
||||
// Check for authentication token
|
||||
const token = localStorage.getItem('auth_token');
|
||||
const options = {
|
||||
method: 'GET',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
};
|
||||
|
||||
// Add auth token if available
|
||||
if (token) {
|
||||
options.headers['Authorization'] = `Bearer ${token}`;
|
||||
}
|
||||
|
||||
// Test API connection
|
||||
fetch('/api/warranties', options)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('API responded with status: ' + response.status);
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
console.log('API response:', data);
|
||||
resultElement.innerHTML = `
|
||||
<div class="alert alert-success">
|
||||
<h4>✅ API Connection Successful</h4>
|
||||
<p>The API responded with ${data && Array.isArray(data) ? data.length : 0} warranties.</p>
|
||||
<pre>${JSON.stringify(data && data.length ? data[0] : {}, null, 2)}</pre>
|
||||
</div>
|
||||
`;
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('API connection error:', error);
|
||||
resultElement.innerHTML = `
|
||||
<div class="alert alert-danger">
|
||||
<h4>❌ API Connection Failed</h4>
|
||||
<p>Error: ${error.message}</p>
|
||||
<h5>Debug Information:</h5>
|
||||
<ul>
|
||||
<li>Browser URL: ${window.location.href}</li>
|
||||
<li>Target API: /api/warranties</li>
|
||||
<li>Authentication: ${token ? 'Token provided' : 'No token available'}</li>
|
||||
<li>Make sure the backend is running and accessible</li>
|
||||
<li>Check that nginx is properly configured to proxy API requests</li>
|
||||
</ul>
|
||||
</div>
|
||||
`;
|
||||
});
|
||||
}
|
||||
});
|
||||
@@ -1 +0,0 @@
|
||||
console.log('Testing authentication status:', localStorage.getItem('auth_token'));
|
||||
53
frontend/version-checker.js
Normal file
53
frontend/version-checker.js
Normal file
@@ -0,0 +1,53 @@
|
||||
// Version checker for Warracker
|
||||
document.addEventListener('DOMContentLoaded', () => {
|
||||
const currentVersion = '0.9.9.4'; // Current version of the application
|
||||
const updateStatus = document.getElementById('updateStatus');
|
||||
const updateLink = document.getElementById('updateLink');
|
||||
|
||||
// Function to compare versions
|
||||
function compareVersions(v1, v2) {
|
||||
const v1Parts = v1.split('.').map(Number);
|
||||
const v2Parts = v2.split('.').map(Number);
|
||||
|
||||
for (let i = 0; i < Math.max(v1Parts.length, v2Parts.length); i++) {
|
||||
const v1Part = v1Parts[i] || 0;
|
||||
const v2Part = v2Parts[i] || 0;
|
||||
|
||||
if (v1Part > v2Part) return 1;
|
||||
if (v1Part < v2Part) return -1;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
// Function to check for updates
|
||||
async function checkForUpdates() {
|
||||
try {
|
||||
const response = await fetch('https://api.github.com/repos/sassanix/Warracker/releases/latest');
|
||||
if (!response.ok) throw new Error('Failed to fetch release information');
|
||||
|
||||
const data = await response.json();
|
||||
const latestVersion = data.tag_name.replace('v', ''); // Remove 'v' prefix if present
|
||||
|
||||
const comparison = compareVersions(latestVersion, currentVersion);
|
||||
|
||||
if (comparison > 0) {
|
||||
// New version available
|
||||
updateStatus.textContent = `New version ${data.tag_name} available!`;
|
||||
updateStatus.style.color = 'var(--success-color)';
|
||||
updateLink.href = data.html_url;
|
||||
updateLink.style.display = 'inline-block';
|
||||
} else {
|
||||
// Up to date
|
||||
updateStatus.textContent = 'You are using the latest version';
|
||||
updateStatus.style.color = 'var(--success-color)';
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking for updates:', error);
|
||||
updateStatus.textContent = 'Failed to check for updates';
|
||||
updateStatus.style.color = 'var(--error-color)';
|
||||
}
|
||||
}
|
||||
|
||||
// Check for updates when the page loads
|
||||
checkForUpdates();
|
||||
});
|
||||
@@ -1,34 +0,0 @@
|
||||
-- Add notification preferences columns to user_preferences table
|
||||
|
||||
-- Check if notification_frequency column exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'user_preferences' AND column_name = 'notification_frequency'
|
||||
) THEN
|
||||
ALTER TABLE user_preferences ADD COLUMN notification_frequency VARCHAR(10) NOT NULL DEFAULT 'daily';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Check if notification_time column exists
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT FROM information_schema.columns
|
||||
WHERE table_name = 'user_preferences' AND column_name = 'notification_time'
|
||||
) THEN
|
||||
ALTER TABLE user_preferences ADD COLUMN notification_time VARCHAR(5) NOT NULL DEFAULT '09:00';
|
||||
END IF;
|
||||
END $$;
|
||||
|
||||
-- Add indexes for the new columns
|
||||
DO $$
|
||||
BEGIN
|
||||
IF NOT EXISTS (
|
||||
SELECT 1 FROM pg_indexes
|
||||
WHERE tablename = 'user_preferences' AND indexname = 'idx_user_preferences_notification'
|
||||
) THEN
|
||||
CREATE INDEX idx_user_preferences_notification ON user_preferences(notification_frequency, notification_time);
|
||||
END IF;
|
||||
END $$;
|
||||
@@ -1,156 +0,0 @@
|
||||
#!/usr/bin/env python3
|
||||
import os
|
||||
import glob
|
||||
import psycopg2
|
||||
import logging
|
||||
import time
|
||||
import sys
|
||||
import importlib.util
|
||||
|
||||
# Set up logging
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s - %(name)s - %(levelname)s - %(message)s',
|
||||
handlers=[logging.StreamHandler()]
|
||||
)
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
def get_db_connection(max_attempts=5, attempt_delay=5):
|
||||
"""Get a connection to the PostgreSQL database with retry logic"""
|
||||
for attempt in range(1, max_attempts + 1):
|
||||
try:
|
||||
logger.info(f"Attempting to connect to database (attempt {attempt}/{max_attempts})")
|
||||
|
||||
# Get connection details from environment variables or use defaults
|
||||
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')
|
||||
|
||||
conn = psycopg2.connect(
|
||||
host=db_host,
|
||||
port=db_port,
|
||||
dbname=db_name,
|
||||
user=db_user,
|
||||
password=db_password
|
||||
)
|
||||
|
||||
# Set autocommit to False for transaction control
|
||||
conn.autocommit = False
|
||||
|
||||
logger.info("Database connection successful")
|
||||
return conn
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Database connection error (attempt {attempt}/{max_attempts}): {e}")
|
||||
|
||||
if attempt < max_attempts:
|
||||
logger.info(f"Retrying in {attempt_delay} seconds...")
|
||||
time.sleep(attempt_delay)
|
||||
else:
|
||||
logger.error("Maximum connection attempts reached. Could not connect to database.")
|
||||
raise
|
||||
|
||||
def load_python_migration(file_path):
|
||||
"""Load a Python migration module dynamically"""
|
||||
module_name = os.path.basename(file_path).replace('.py', '')
|
||||
spec = importlib.util.spec_from_file_location(module_name, file_path)
|
||||
module = importlib.util.module_from_spec(spec)
|
||||
spec.loader.exec_module(module)
|
||||
return module
|
||||
|
||||
def apply_migrations():
|
||||
"""Apply all SQL and Python migration files in the migrations directory"""
|
||||
conn = None
|
||||
try:
|
||||
conn = get_db_connection()
|
||||
cur = conn.cursor()
|
||||
|
||||
# Create migrations table if it doesn't exist
|
||||
cur.execute("""
|
||||
CREATE TABLE IF NOT EXISTS migrations (
|
||||
id SERIAL PRIMARY KEY,
|
||||
filename VARCHAR(255) NOT NULL UNIQUE,
|
||||
applied_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP
|
||||
)
|
||||
""")
|
||||
conn.commit()
|
||||
|
||||
# Get list of migration files (both SQL and Python)
|
||||
migration_dir = os.path.dirname(os.path.abspath(__file__))
|
||||
sql_files = sorted(glob.glob(os.path.join(migration_dir, '*.sql')))
|
||||
py_files = sorted(glob.glob(os.path.join(migration_dir, '*.py')))
|
||||
# Filter out this script itself
|
||||
py_files = [f for f in py_files if os.path.basename(f) != 'apply_migrations.py']
|
||||
|
||||
# Combine and sort all migration files by name
|
||||
all_migration_files = sorted(sql_files + py_files)
|
||||
|
||||
if not all_migration_files:
|
||||
logger.info("No migration files found.")
|
||||
return
|
||||
|
||||
# Get list of already applied migrations
|
||||
cur.execute("SELECT filename FROM migrations")
|
||||
applied_migrations = set([row[0] for row in cur.fetchall()])
|
||||
|
||||
# Apply each migration file if not already applied
|
||||
for migration_file in all_migration_files:
|
||||
filename = os.path.basename(migration_file)
|
||||
|
||||
if filename in applied_migrations:
|
||||
logger.info(f"Migration {filename} already applied, skipping.")
|
||||
continue
|
||||
|
||||
logger.info(f"Applying migration: {filename}")
|
||||
|
||||
try:
|
||||
if migration_file.endswith('.sql'):
|
||||
# Apply SQL migration
|
||||
with open(migration_file, 'r') as f:
|
||||
sql = f.read()
|
||||
|
||||
cur.execute(sql)
|
||||
elif migration_file.endswith('.py'):
|
||||
# Apply Python migration
|
||||
migration_module = load_python_migration(migration_file)
|
||||
if hasattr(migration_module, 'upgrade'):
|
||||
migration_module.upgrade(cur)
|
||||
else:
|
||||
logger.warning(f"Python migration {filename} does not have an upgrade function, skipping.")
|
||||
continue
|
||||
|
||||
# Record the migration as applied
|
||||
cur.execute(
|
||||
"INSERT INTO migrations (filename) VALUES (%s)",
|
||||
(filename,)
|
||||
)
|
||||
|
||||
conn.commit()
|
||||
logger.info(f"Migration {filename} applied successfully")
|
||||
|
||||
except Exception as e:
|
||||
conn.rollback()
|
||||
logger.error(f"Error applying migration {filename}: {e}")
|
||||
if migration_file.endswith('.sql'):
|
||||
logger.error(f"\nQUERY: {sql}\n")
|
||||
raise
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Migration error: {e}")
|
||||
if conn:
|
||||
conn.rollback()
|
||||
raise
|
||||
|
||||
finally:
|
||||
if conn:
|
||||
conn.close()
|
||||
|
||||
if __name__ == "__main__":
|
||||
try:
|
||||
apply_migrations()
|
||||
logger.info("Migrations completed successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Migration process failed: {e}")
|
||||
sys.exit(1)
|
||||
@@ -1,13 +0,0 @@
|
||||
#!/bin/bash
|
||||
|
||||
# Display script execution
|
||||
set -x
|
||||
|
||||
# Change to the migrations directory
|
||||
cd /app/migrations
|
||||
|
||||
# Run the migration script
|
||||
python3 apply_migrations.py
|
||||
|
||||
# Return the exit code
|
||||
exit $?
|
||||
@@ -1,8 +1,9 @@
|
||||
server {
|
||||
listen 80;
|
||||
listen [::]:80;
|
||||
server_name localhost;
|
||||
# Point root to the frontend directory where static assets reside
|
||||
root d:/Project/warracker/Warracker Beta/frontend;
|
||||
root /d/Project/warracker/Warracker Beta/frontend;
|
||||
index index.html;
|
||||
|
||||
# Enable detailed error logging
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 67 KiB |
@@ -1 +0,0 @@
|
||||
Test content for debugging
|
||||
@@ -1 +0,0 @@
|
||||
test content
|
||||
Reference in New Issue
Block a user