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() %} +
+ Company 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.

+
+
+
diff --git a/app/templates/admin/telemetry.html b/app/templates/admin/telemetry.html index d534710..25efeb0 100644 --- a/app/templates/admin/telemetry.html +++ b/app/templates/admin/telemetry.html @@ -44,6 +44,7 @@ +

Tip: You can also manage this setting in Admin → Settings (Privacy & Analytics section)

{% if telemetry.enabled %} diff --git a/app/templates/invoices/pdf_default.html b/app/templates/invoices/pdf_default.html index 8cad2fa..e0c41c1 100644 --- a/app/templates/invoices/pdf_default.html +++ b/app/templates/invoices/pdf_default.html @@ -11,7 +11,14 @@ {% if settings.has_logo() %} {% set logo_path = settings.get_logo_path() %} {% if logo_path %} - + {# Base64 encode the logo for reliable PDF embedding #} + {% set logo_data = get_logo_base64(logo_path) %} + {% if logo_data %} + + {% else %} + {# Fallback to file URI if base64 fails #} + + {% endif %} {% endif %} {% endif %}
diff --git a/app/templates/setup/initial_setup.html b/app/templates/setup/initial_setup.html index 23f7665..4a0a388 100644 --- a/app/templates/setup/initial_setup.html +++ b/app/templates/setup/initial_setup.html @@ -118,7 +118,7 @@

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) +