diff --git a/app/routes/admin.py b/app/routes/admin.py
index e8ab80c..de1960d 100644
--- a/app/routes/admin.py
+++ b/app/routes/admin.py
@@ -305,6 +305,13 @@ def toggle_telemetry():
def settings():
"""Manage system settings"""
settings_obj = Settings.get_settings()
+ installation_config = get_installation_config()
+
+ # Sync analytics preference from installation config to database on load
+ # (installation config is the source of truth for telemetry)
+ if settings_obj.allow_analytics != installation_config.get_telemetry_preference():
+ settings_obj.allow_analytics = installation_config.get_telemetry_preference()
+ db.session.commit()
if request.method == 'POST':
# Validate timezone
@@ -343,7 +350,18 @@ def settings():
settings_obj.invoice_notes = request.form.get('invoice_notes', 'Thank you for your business!')
# Update privacy and analytics settings
- settings_obj.allow_analytics = request.form.get('allow_analytics') == 'on'
+ allow_analytics = request.form.get('allow_analytics') == 'on'
+ old_analytics_state = settings_obj.allow_analytics
+ settings_obj.allow_analytics = allow_analytics
+
+ # Also update the installation config (used by telemetry system)
+ # This ensures the telemetry system sees the updated preference
+ installation_config.set_telemetry_preference(allow_analytics)
+
+ # Log analytics preference change if it changed
+ if old_analytics_state != allow_analytics:
+ app_module.log_event("admin.analytics_toggled", user_id=current_user.id, new_state=allow_analytics)
+ app_module.track_event(current_user.id, "admin.analytics_toggled", {"enabled": allow_analytics})
if not safe_commit('admin_update_settings'):
flash('Could not update settings due to a database error. Please check server logs.', 'error')
diff --git a/app/static/uploads/logos/.gitkeep b/app/static/uploads/logos/.gitkeep
index 8f5b058..9d486bd 100644
--- a/app/static/uploads/logos/.gitkeep
+++ b/app/static/uploads/logos/.gitkeep
@@ -1,2 +1,2 @@
-# This file ensures the uploads directory is preserved in git
-# Company logos will be stored in this directory
+# This file ensures the logos directory is tracked by git
+# Logo files uploaded through the admin interface will be stored here
diff --git a/app/templates/admin/settings.html b/app/templates/admin/settings.html
index 8e757f7..4c52856 100644
--- a/app/templates/admin/settings.html
+++ b/app/templates/admin/settings.html
@@ -46,6 +46,149 @@
+
+
+
+
User Management
+
+
+
+
+
+
+ Note: Admin users are configured via the ADMIN_USERNAMES environment variable, not in this UI.
+
+
+
+
+
+
+
Company Branding
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ {% if settings.has_logo() %}
+
+
+
+
+ {% endif %}
+
+
Allowed formats: PNG, JPG, GIF, SVG, WEBP
+
+
+
+
+
+
Invoice Defaults
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Backup Settings
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Export Settings
+
+
+
+
+
+
+
+
+
+
+
Privacy & Analytics
+
+
+
+
+
+
+
Help improve TimeTracker by sharing anonymous usage data:
+
+
Platform and version information
+
Feature usage patterns (no personal data)
+
Performance and error metrics
+
+
Privacy: All data is anonymized. No personal information, time entries, or client data is ever collected.
+
This is the same setting as the telemetry preference shown during initial setup.
Why? Anonymous usage data helps us prioritize features and fix issues.
- You can change this anytime in Admin → Telemetry Dashboard.
+ You can change this anytime in Admin → Settings (Privacy & Analytics section).
diff --git a/app/utils/pdf_generator.py b/app/utils/pdf_generator.py
index 116b7f8..48500e0 100644
--- a/app/utils/pdf_generator.py
+++ b/app/utils/pdf_generator.py
@@ -71,8 +71,35 @@ class InvoicePDFGenerator:
html = ''
if not html:
try:
- html = render_template('invoices/pdf_default.html', invoice=self.invoice, settings=self.settings, Path=Path)
- except Exception:
+ # Import helper functions for template
+ from app.utils.template_filters import get_logo_base64
+ from babel.dates import format_date as babel_format_date
+
+ def format_date(value, format='medium'):
+ """Format date for template"""
+ if babel_format_date:
+ return babel_format_date(value, format=format)
+ return value.strftime('%Y-%m-%d') if value else ''
+
+ def format_money(value):
+ """Format money for template"""
+ try:
+ return f"{float(value):,.2f}"
+ except Exception:
+ return str(value)
+
+ html = render_template('invoices/pdf_default.html',
+ invoice=self.invoice,
+ settings=self.settings,
+ Path=Path,
+ get_logo_base64=get_logo_base64,
+ format_date=format_date,
+ format_money=format_money)
+ except Exception as e:
+ # Log the exception for debugging
+ import traceback
+ print(f"Error rendering PDF template: {e}")
+ print(traceback.format_exc())
html = f"
{_('Invoice')} {self.invoice.invoice_number}
"
return html, css
@@ -237,13 +264,29 @@ class InvoicePDFGenerator:
if self.settings.has_logo():
logo_path = self.settings.get_logo_path()
if logo_path and os.path.exists(logo_path):
- # Build a cross-platform file URI (handles Windows and POSIX paths)
+ # Use base64 data URI for reliable PDF embedding (works better with WeasyPrint)
try:
- file_url = Path(logo_path).resolve().as_uri()
- except Exception:
- # Fallback to naive file:// if as_uri fails
- file_url = f'file://{logo_path}'
- return f''
+ import base64
+ import mimetypes
+
+ with open(logo_path, 'rb') as logo_file:
+ logo_data = base64.b64encode(logo_file.read()).decode('utf-8')
+
+ # Detect MIME type
+ mime_type, _ = mimetypes.guess_type(logo_path)
+ if not mime_type:
+ # Default to PNG if can't detect
+ mime_type = 'image/png'
+
+ data_uri = f'data:{mime_type};base64,{logo_data}'
+ return f''
+ except Exception as e:
+ # Fallback to file URI if base64 fails
+ try:
+ file_url = Path(logo_path).resolve().as_uri()
+ except Exception:
+ file_url = f'file://{logo_path}'
+ return f''
return ''
def _get_company_tax_info(self):
diff --git a/app/utils/template_filters.py b/app/utils/template_filters.py
index c068440..24ebc90 100644
--- a/app/utils/template_filters.py
+++ b/app/utils/template_filters.py
@@ -104,3 +104,48 @@ def register_template_filters(app):
return f"{float(value):,.2f}"
except Exception:
return str(value)
+
+ def get_logo_base64(logo_path):
+ """Convert logo file to base64 data URI for PDF embedding"""
+ import os
+ import base64
+ import mimetypes
+
+ if not logo_path:
+ print("DEBUG: logo_path is None or empty")
+ return None
+
+ if not os.path.exists(logo_path):
+ print(f"DEBUG: Logo file does not exist: {logo_path}")
+ return None
+
+ try:
+ print(f"DEBUG: Reading logo from: {logo_path}")
+ with open(logo_path, 'rb') as logo_file:
+ logo_data = base64.b64encode(logo_file.read()).decode('utf-8')
+
+ # Detect MIME type
+ mime_type, _ = mimetypes.guess_type(logo_path)
+ if not mime_type:
+ # Try to detect from file extension
+ ext = os.path.splitext(logo_path)[1].lower()
+ mime_map = {
+ '.png': 'image/png',
+ '.jpg': 'image/jpeg',
+ '.jpeg': 'image/jpeg',
+ '.gif': 'image/gif',
+ '.svg': 'image/svg+xml',
+ '.webp': 'image/webp'
+ }
+ mime_type = mime_map.get(ext, 'image/png')
+
+ print(f"DEBUG: Logo encoded successfully, MIME type: {mime_type}, size: {len(logo_data)} bytes")
+ return f'data:{mime_type};base64,{logo_data}'
+ except Exception as e:
+ print(f"DEBUG: Error encoding logo: {e}")
+ import traceback
+ print(traceback.format_exc())
+ return None
+
+ # Make get_logo_base64 available in templates as a global function
+ app.jinja_env.globals.update(get_logo_base64=get_logo_base64)
\ No newline at end of file
diff --git a/docs/GETTING_STARTED.md b/docs/GETTING_STARTED.md
index b6d387b..5623e31 100644
--- a/docs/GETTING_STARTED.md
+++ b/docs/GETTING_STARTED.md
@@ -31,10 +31,16 @@ export SECRET_KEY=$(python -c "import secrets; print(secrets.token_hex(32))")
# Windows PowerShell:
$env:SECRET_KEY = python -c "import secrets; print(secrets.token_hex(32))"
-# 3. Start TimeTracker
+# 3. (Optional) Set admin usernames
+# Linux/macOS:
+export ADMIN_USERNAMES=admin,manager
+# Windows PowerShell:
+$env:ADMIN_USERNAMES = "admin,manager"
+
+# 4. Start TimeTracker
docker-compose up -d
-# 4. Access the application
+# 5. Access the application
# Open your browser to: https://localhost
# (Self‑signed certificate; your browser will show a warning the first time.)
@@ -42,6 +48,8 @@ docker-compose up -d
# Use the example compose that publishes the app directly:
# docker-compose -f docker-compose.example.yml up -d
# Then open: http://localhost:8080
+
+# Note: Login with the username you set in ADMIN_USERNAMES (default: admin) to get admin access
```
**That's it!** TimeTracker is now running with PostgreSQL.
@@ -92,9 +100,10 @@ python app.py
- Example: `admin`, `john`, or your name
- This creates your account automatically
-3. **First user becomes admin** automatically
- - Full access to all features
- - Can manage users and settings
+3. **Admin users are configured in the environment**
+ - Set via `ADMIN_USERNAMES` environment variable (default: `admin`)
+ - When you login with a username matching the list, you get admin role
+ - Example: If `ADMIN_USERNAMES=admin,manager`, logging in as "admin" or "manager" gives admin access
4. **You're in!** Welcome to your dashboard
@@ -106,27 +115,49 @@ python app.py
### Step 1: Configure System Settings
-1. Go to **Admin → System Settings** (gear icon in top right)
+> **Important**: You need admin access for this step. Login with a username from `ADMIN_USERNAMES` (default: `admin`).
-2. **Set your company information**:
- - Company name
- - Address for invoices
- - Optional: Upload your logo
+1. Go to **Admin → Settings** (in the left sidebar menu, expand "Admin", then click "Settings")
-3. **Configure regional settings**:
- - **Timezone**: Your local timezone (e.g., `America/New_York`)
- - **Currency**: Your preferred currency (e.g., `USD`, `EUR`, `GBP`)
+The Admin Settings page has multiple sections. Configure what you need:
-4. **Adjust timer behavior**:
- - **Idle Timeout**: How long before auto-pause (default: 30 minutes)
- - **Single Active Timer**: Allow only one running timer per user
- - **Time Rounding**: Round to nearest minute/5 minutes/15 minutes
+#### General Settings
+- **Timezone**: Your local timezone (e.g., `America/New_York`, `Europe/Rome`)
+- **Currency**: Your preferred currency (e.g., `USD`, `EUR`, `GBP`)
-5. **Set user management**:
- - **Allow Self-Registration**: Let users create their own accounts
- - **Admin Usernames**: Comma-separated list of admin users
+#### Timer Settings
+- **Rounding (Minutes)**: Round to nearest 1/5/15 minutes
+- **Idle Timeout (Minutes)**: Auto-pause after idle (default: 30)
+- **Single Active Timer**: Allow only one running timer per user
-6. **Click Save** to apply settings
+#### User Management
+- **Allow Self-Registration**: ☑ Enable this to let users create accounts by entering any username on the login page
+- **Note**: Admin users are set via `ADMIN_USERNAMES` environment variable, not in this UI
+
+#### Company Branding
+- **Company Name**: Your company or business name
+- **Company Email**: Contact email for invoices
+- **Company Phone**: Contact phone number
+- **Company Website**: Your website URL
+- **Company Address**: Your billing address (multi-line)
+- **Tax ID**: Optional tax identification number
+- **Bank Information**: Optional bank account details for invoices
+- **Company Logo**: Upload your logo (PNG, JPG, GIF, SVG, WEBP)
+
+#### Invoice Defaults
+- **Invoice Prefix**: Prefix for invoice numbers (e.g., `INV`)
+- **Invoice Start Number**: Starting number for invoices (e.g., 1000)
+- **Default Payment Terms**: Terms text (e.g., "Payment due within 30 days")
+- **Default Invoice Notes**: Footer notes (e.g., "Thank you for your business!")
+
+#### Additional Settings
+- **Backup Settings**: Retention days and backup time
+- **Export Settings**: CSV delimiter preference
+- **Privacy & Analytics**: Allow analytics to help improve the application
+
+2. **Click "Save Settings"** at the bottom to apply all changes
+
+> **💡 Tip**: Don't confuse this with the **Settings** option in your account dropdown (top right) - that's for personal/user preferences. System-wide settings are in **Admin → Settings** in the left sidebar.
### Step 2: Add Your First Client
@@ -294,7 +325,7 @@ Break your project into manageable tasks:
### Customize Your Experience
-- **Upload your logo** for branded invoices
+- **Company branding**: Upload your logo and set company info in Admin → Settings
- **Configure notifications** for task due dates
- **Set up recurring time blocks** for regular tasks
- **Create saved filters** for common report views
@@ -305,9 +336,10 @@ Break your project into manageable tasks:
If you're setting up for a team:
1. **Add team members**:
- - Go to **Admin → Users**
- - Users can self-register (if enabled) or admin can add them
- - Assign roles (Admin/User)
+ - **Self-registration** (recommended): Enable in Admin → Settings → "Allow Self-Registration"
+ - **Admin creates users**: Go to Admin → Users → Create User
+ - **Admin roles**: Set via `ADMIN_USERNAMES` environment variable (comma-separated list)
+ - Regular users can be assigned Manager or User roles via Admin → Users → Edit
2. **Assign projects**:
- Projects are visible to all users
@@ -333,10 +365,13 @@ Ready to deploy for real use?
DATABASE_URL=postgresql://user:pass@localhost:5432/timetracker
```
-2. **Set a secure secret key**:
+2. **Set a secure secret key and admin users**:
```bash
# Generate a random key
SECRET_KEY=$(python -c 'import secrets; print(secrets.token_hex(32))')
+
+ # Set admin usernames (comma-separated)
+ ADMIN_USERNAMES=admin,yourusername
```
3. **Configure for production**:
@@ -411,9 +446,9 @@ docker-compose up -d
### How do I add more users?
-- Enable self-registration in settings, or
-- Users can just enter a username on first visit, or
-- Admin can create users in Admin → Users
+- **Enable self-registration**: In Admin → Settings, enable "Allow Self-Registration" - then anyone can create an account by entering a username on the login page
+- **Admin creates users**: In Admin → Users → Create User (requires admin access)
+- **Users in ADMIN_USERNAMES**: Any username listed in the `ADMIN_USERNAMES` environment variable will automatically get admin role when they login
### Can I export my data?
diff --git a/test_logo_pdf.py b/test_logo_pdf.py
new file mode 100644
index 0000000..4da1e57
--- /dev/null
+++ b/test_logo_pdf.py
@@ -0,0 +1,194 @@
+#!/usr/bin/env python3
+"""
+Debug script to test company logo in PDF generation
+Run this to check if logo is properly configured and can be embedded in PDFs
+"""
+
+import os
+import sys
+
+# Add app to path
+sys.path.insert(0, os.path.dirname(os.path.abspath(__file__)))
+
+from app import create_app
+from app.models import Settings, Invoice
+from app.utils.pdf_generator import InvoicePDFGenerator
+
+def test_logo_setup():
+ """Test if logo is properly configured"""
+ print("=" * 60)
+ print("LOGO CONFIGURATION TEST")
+ print("=" * 60)
+
+ app = create_app()
+ with app.app_context():
+ settings = Settings.get_settings()
+
+ print(f"\n1. Logo filename in database: {settings.company_logo_filename or 'NONE'}")
+
+ if not settings.company_logo_filename:
+ print(" ❌ NO LOGO UPLOADED")
+ print(" → Upload a logo in Admin → Settings → Company Branding")
+ return False
+
+ print(f" ✓ Logo filename found: {settings.company_logo_filename}")
+
+ logo_path = settings.get_logo_path()
+ print(f"\n2. Logo file path: {logo_path}")
+
+ if not logo_path:
+ print(" ❌ Could not determine logo path")
+ return False
+
+ if not os.path.exists(logo_path):
+ print(f" ❌ LOGO FILE DOES NOT EXIST AT: {logo_path}")
+ print(f" → Check if file exists in app/static/uploads/logos/")
+ return False
+
+ print(f" ✓ Logo file exists")
+
+ file_size = os.path.getsize(logo_path)
+ print(f"\n3. Logo file size: {file_size:,} bytes ({file_size/1024:.2f} KB)")
+
+ if file_size == 0:
+ print(" ❌ Logo file is empty")
+ return False
+
+ if file_size > 5 * 1024 * 1024: # 5MB
+ print(" ⚠️ Logo file is very large (>5MB). Consider optimizing.")
+
+ print(f" ✓ Logo file has content")
+
+ # Test base64 encoding
+ print(f"\n4. Testing base64 encoding...")
+ try:
+ from app.utils.template_filters import get_logo_base64
+ data_uri = get_logo_base64(logo_path)
+
+ if not data_uri:
+ print(" ❌ Base64 encoding failed (returned None)")
+ return False
+
+ if not data_uri.startswith('data:image/'):
+ print(f" ❌ Invalid data URI: {data_uri[:50]}...")
+ return False
+
+ encoded_size = len(data_uri)
+ print(f" ✓ Base64 encoding successful")
+ print(f" Data URI size: {encoded_size:,} bytes ({encoded_size/1024:.2f} KB)")
+ print(f" Data URI prefix: {data_uri[:50]}...")
+
+ except Exception as e:
+ print(f" ❌ Error encoding logo: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+ print("\n" + "=" * 60)
+ print("✓ LOGO CONFIGURATION IS CORRECT")
+ print("=" * 60)
+ return True
+
+def test_pdf_generation():
+ """Test PDF generation with logo"""
+ print("\n" + "=" * 60)
+ print("PDF GENERATION TEST")
+ print("=" * 60)
+
+ app = create_app()
+ with app.app_context():
+ # Get the most recent invoice
+ invoice = Invoice.query.order_by(Invoice.id.desc()).first()
+
+ if not invoice:
+ print("\n❌ NO INVOICES FOUND")
+ print(" → Create an invoice first to test PDF generation")
+ return False
+
+ print(f"\n1. Testing with Invoice #{invoice.invoice_number}")
+ print(f" Project: {invoice.project.name}")
+ print(f" Client: {invoice.client_name}")
+
+ try:
+ print(f"\n2. Generating PDF...")
+ generator = InvoicePDFGenerator(invoice)
+ pdf_bytes = generator.generate_pdf()
+
+ if not pdf_bytes:
+ print(" ❌ PDF generation returned no data")
+ return False
+
+ pdf_size = len(pdf_bytes)
+ print(f" ✓ PDF generated successfully")
+ print(f" PDF size: {pdf_size:,} bytes ({pdf_size/1024:.2f} KB)")
+
+ # Save PDF for inspection
+ output_file = 'test_invoice.pdf'
+ with open(output_file, 'wb') as f:
+ f.write(pdf_bytes)
+
+ print(f"\n3. PDF saved to: {output_file}")
+ print(f" → Open this file to check if logo appears")
+
+ print("\n" + "=" * 60)
+ print("✓ PDF GENERATION SUCCESSFUL")
+ print("=" * 60)
+ print(f"\n📄 Check the file: {output_file}")
+ print(" Look for the logo in the top-left corner of the invoice")
+
+ return True
+
+ except Exception as e:
+ print(f"\n ❌ Error generating PDF: {e}")
+ import traceback
+ traceback.print_exc()
+ return False
+
+def main():
+ """Run all tests"""
+ print("\n🔍 TimeTracker PDF Logo Diagnostic Tool\n")
+
+ logo_ok = test_logo_setup()
+
+ if logo_ok:
+ pdf_ok = test_pdf_generation()
+
+ if pdf_ok:
+ print("\n" + "=" * 60)
+ print("✅ ALL TESTS PASSED")
+ print("=" * 60)
+ print("\nIf the logo still doesn't appear in the PDF:")
+ print("1. Open test_invoice.pdf and check manually")
+ print("2. Check server logs for DEBUG messages when generating invoices")
+ print("3. Try uploading a different logo (simple PNG <1MB)")
+ print("4. Verify the logo works in the web UI first (Admin → Settings)")
+ return 0
+ else:
+ print("\n" + "=" * 60)
+ print("❌ PDF GENERATION FAILED")
+ print("=" * 60)
+ return 1
+ else:
+ print("\n" + "=" * 60)
+ print("❌ LOGO CONFIGURATION FAILED")
+ print("=" * 60)
+ print("\nPlease fix the logo configuration first:")
+ print("1. Login as admin")
+ print("2. Go to Admin → Settings")
+ print("3. Scroll to Company Branding section")
+ print("4. Upload a logo (PNG, JPG, GIF, SVG, or WEBP)")
+ print("5. Run this script again")
+ return 1
+
+if __name__ == '__main__':
+ try:
+ sys.exit(main())
+ except KeyboardInterrupt:
+ print("\n\nTest interrupted by user")
+ sys.exit(130)
+ except Exception as e:
+ print(f"\n\n❌ UNEXPECTED ERROR: {e}")
+ import traceback
+ traceback.print_exc()
+ sys.exit(1)
+