mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-04-28 00:11:34 -05:00
Merge pull request #157 from DRYTRIX/152-getting-started-doc-first-login
feat: Complete Admin Settings UI and enhance PDF logo reliability
This commit is contained in:
+19
-1
@@ -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')
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -46,6 +46,149 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- User Management -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">User Management</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="allow_self_register" id="allow_self_register" {% if settings.allow_self_register %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="allow_self_register" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Allow self-registration (users can create accounts by entering any username on login page)</label>
|
||||
</div>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark ml-6">
|
||||
Note: Admin users are configured via the ADMIN_USERNAMES environment variable, not in this UI.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company Branding -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Company Branding</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="company_name" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Company Name</label>
|
||||
<input type="text" name="company_name" id="company_name" value="{{ settings.company_name }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_email" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Company Email</label>
|
||||
<input type="email" name="company_email" id="company_email" value="{{ settings.company_email }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_phone" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Company Phone</label>
|
||||
<input type="text" name="company_phone" id="company_phone" value="{{ settings.company_phone }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_website" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Company Website</label>
|
||||
<input type="text" name="company_website" id="company_website" value="{{ settings.company_website }}" class="form-input">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="company_address" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Company Address</label>
|
||||
<textarea name="company_address" id="company_address" rows="3" class="form-input">{{ settings.company_address }}</textarea>
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_tax_id" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Tax ID (optional)</label>
|
||||
<input type="text" name="company_tax_id" id="company_tax_id" value="{{ settings.company_tax_id }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="company_bank_info" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Bank Information (optional)</label>
|
||||
<textarea name="company_bank_info" id="company_bank_info" rows="3" class="form-input">{{ settings.company_bank_info }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Company Logo Upload -->
|
||||
<div class="mt-6">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-2">Company Logo</label>
|
||||
{% if settings.has_logo() %}
|
||||
<div class="mb-4">
|
||||
<img src="{{ settings.get_logo_url() }}" alt="Company Logo" class="max-h-24 mb-2">
|
||||
<form method="POST" action="{{ url_for('admin.remove_logo') }}" class="inline">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<button type="submit" class="text-sm text-red-600 hover:text-red-800 dark:text-red-400 dark:hover:text-red-300">Remove Logo</button>
|
||||
</form>
|
||||
</div>
|
||||
{% endif %}
|
||||
<form method="POST" action="{{ url_for('admin.upload_logo') }}" enctype="multipart/form-data" class="flex items-center gap-4">
|
||||
<input type="hidden" name="csrf_token" value="{{ csrf_token() }}">
|
||||
<input type="file" name="logo" accept="image/png,image/jpeg,image/jpg,image/gif,image/svg+xml,image/webp" class="form-input flex-1">
|
||||
<button type="submit" class="bg-secondary text-white px-4 py-2 rounded-lg whitespace-nowrap">Upload Logo</button>
|
||||
</form>
|
||||
<p class="text-xs text-text-muted-light dark:text-text-muted-dark mt-1">Allowed formats: PNG, JPG, GIF, SVG, WEBP</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Invoice Defaults -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Invoice Defaults</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="invoice_prefix" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Invoice Prefix</label>
|
||||
<input type="text" name="invoice_prefix" id="invoice_prefix" value="{{ settings.invoice_prefix }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="invoice_start_number" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Invoice Start Number</label>
|
||||
<input type="number" name="invoice_start_number" id="invoice_start_number" value="{{ settings.invoice_start_number }}" class="form-input">
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="invoice_terms" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default Payment Terms</label>
|
||||
<textarea name="invoice_terms" id="invoice_terms" rows="3" class="form-input">{{ settings.invoice_terms }}</textarea>
|
||||
</div>
|
||||
<div class="md:col-span-2">
|
||||
<label for="invoice_notes" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Default Invoice Notes</label>
|
||||
<textarea name="invoice_notes" id="invoice_notes" rows="3" class="form-input">{{ settings.invoice_notes }}</textarea>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Backup Settings -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Backup Settings</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="backup_retention_days" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Backup Retention (Days)</label>
|
||||
<input type="number" name="backup_retention_days" id="backup_retention_days" value="{{ settings.backup_retention_days }}" class="form-input">
|
||||
</div>
|
||||
<div>
|
||||
<label for="backup_time" class="block text-sm font-medium text-gray-700 dark:text-gray-300">Backup Time (HH:MM)</label>
|
||||
<input type="text" name="backup_time" id="backup_time" value="{{ settings.backup_time }}" placeholder="02:00" class="form-input">
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Export Settings -->
|
||||
<div class="border-b border-border-light dark:border-border-dark pb-6">
|
||||
<h2 class="text-lg font-semibold mb-4">Export Settings</h2>
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-6">
|
||||
<div>
|
||||
<label for="export_delimiter" class="block text-sm font-medium text-gray-700 dark:text-gray-300">CSV Export Delimiter</label>
|
||||
<select name="export_delimiter" id="export_delimiter" class="form-input">
|
||||
<option value="," {% if settings.export_delimiter == ',' %}selected{% endif %}>Comma (,)</option>
|
||||
<option value=";" {% if settings.export_delimiter == ';' %}selected{% endif %}>Semicolon (;)</option>
|
||||
<option value="\t" {% if settings.export_delimiter == '\t' %}selected{% endif %}>Tab</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Analytics Settings -->
|
||||
<div>
|
||||
<h2 class="text-lg font-semibold mb-4">Privacy & Analytics</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center">
|
||||
<input type="checkbox" name="allow_analytics" id="allow_analytics" {% if settings.allow_analytics %}checked{% endif %} class="h-4 w-4 rounded border-gray-300 text-indigo-600 shadow-sm focus:border-indigo-500 focus:ring-indigo-500">
|
||||
<label for="allow_analytics" class="ml-2 block text-sm text-gray-900 dark:text-gray-300">Enable anonymous usage analytics</label>
|
||||
</div>
|
||||
<div class="ml-6 text-xs text-text-muted-light dark:text-text-muted-dark space-y-1">
|
||||
<p>Help improve TimeTracker by sharing anonymous usage data:</p>
|
||||
<ul class="list-disc ml-4 space-y-0.5">
|
||||
<li>Platform and version information</li>
|
||||
<li>Feature usage patterns (no personal data)</li>
|
||||
<li>Performance and error metrics</li>
|
||||
</ul>
|
||||
<p class="mt-2"><strong>Privacy:</strong> All data is anonymized. No personal information, time entries, or client data is ever collected.</p>
|
||||
<p>This is the same setting as the telemetry preference shown during initial setup.</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mt-8 pt-6 flex justify-end">
|
||||
|
||||
@@ -44,6 +44,7 @@
|
||||
<button type="submit" class="px-4 py-2 rounded-lg transition {% if telemetry.enabled %}bg-red-600 hover:bg-red-700 text-white{% else %}bg-green-600 hover:bg-green-700 text-white{% endif %}">
|
||||
{% if telemetry.enabled %}Disable Telemetry{% else %}Enable Telemetry{% endif %}
|
||||
</button>
|
||||
<p class="text-xs text-gray-600 mt-2">Tip: You can also manage this setting in <a href="{{ url_for('admin.settings') }}" class="text-primary hover:underline">Admin → Settings</a> (Privacy & Analytics section)</p>
|
||||
</form>
|
||||
|
||||
{% if telemetry.enabled %}
|
||||
|
||||
@@ -11,7 +11,14 @@
|
||||
{% if settings.has_logo() %}
|
||||
{% set logo_path = settings.get_logo_path() %}
|
||||
{% if logo_path %}
|
||||
<img src="{{ Path(logo_path).resolve().as_uri() if Path and logo_path else 'file://' ~ logo_path }}" alt="{{ _('Company Logo') }}" class="company-logo">
|
||||
{# Base64 encode the logo for reliable PDF embedding #}
|
||||
{% set logo_data = get_logo_base64(logo_path) %}
|
||||
{% if logo_data %}
|
||||
<img src="{{ logo_data }}" alt="{{ _('Company Logo') }}" class="company-logo">
|
||||
{% else %}
|
||||
{# Fallback to file URI if base64 fails #}
|
||||
<img src="{{ Path(logo_path).resolve().as_uri() if Path and logo_path else 'file://' ~ logo_path }}" alt="{{ _('Company Logo') }}" class="company-logo">
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
{% endif %}
|
||||
<div>
|
||||
|
||||
@@ -118,7 +118,7 @@
|
||||
<div class="bg-primary/10 rounded p-3 text-xs">
|
||||
<p class="text-text-light dark:text-text-dark">
|
||||
<strong>Why?</strong> Anonymous usage data helps us prioritize features and fix issues.
|
||||
You can change this anytime in <strong>Admin → Telemetry Dashboard</strong>.
|
||||
You can change this anytime in <strong>Admin → Settings</strong> (Privacy & Analytics section).
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"<html><body><h1>{_('Invoice')} {self.invoice.invoice_number}</h1></body></html>"
|
||||
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'<img src="{file_url}" alt="Company Logo" class="company-logo">'
|
||||
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'<img src="{data_uri}" alt="Company Logo" class="company-logo">'
|
||||
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'<img src="{file_url}" alt="Company Logo" class="company-logo">'
|
||||
return ''
|
||||
|
||||
def _get_company_tax_info(self):
|
||||
|
||||
+64
-29
@@ -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?
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
Reference in New Issue
Block a user