mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 12:40:38 -06:00
@@ -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')
|
||||
|
||||
@@ -101,6 +101,10 @@ def create_invoice():
|
||||
"has_tax": tax_rate > 0
|
||||
})
|
||||
|
||||
# Get currency from settings
|
||||
settings = Settings.get_settings()
|
||||
currency_code = settings.currency if settings else 'USD'
|
||||
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
invoice_number=invoice_number,
|
||||
@@ -113,7 +117,8 @@ def create_invoice():
|
||||
client_address=client_address,
|
||||
tax_rate=tax_rate,
|
||||
notes=notes,
|
||||
terms=terms
|
||||
terms=terms,
|
||||
currency_code=currency_code
|
||||
)
|
||||
|
||||
db.session.add(invoice)
|
||||
@@ -643,7 +648,8 @@ def duplicate_invoice(invoice_id):
|
||||
client_id=original_invoice.client_id,
|
||||
tax_rate=original_invoice.tax_rate,
|
||||
notes=original_invoice.notes,
|
||||
terms=original_invoice.terms
|
||||
terms=original_invoice.terms,
|
||||
currency_code=original_invoice.currency_code
|
||||
)
|
||||
|
||||
db.session.add(new_invoice)
|
||||
|
||||
@@ -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 %}
|
||||
|
||||
@@ -31,7 +31,7 @@
|
||||
<td class="p-2">{{ invoice.invoice_number }}</td>
|
||||
<td class="p-2">{{ invoice.client_name }}</td>
|
||||
<td class="p-2">{{ invoice.status }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(invoice.total_amount) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</td>
|
||||
<td class="p-2">
|
||||
<a href="{{ url_for('invoices.view_invoice', invoice_id=invoice.id) }}" class="text-primary">View</a>
|
||||
</td>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -42,8 +42,8 @@
|
||||
<tr class="border-b border-border-light dark:border-border-dark">
|
||||
<td class="p-2">{{ item.description }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.quantity) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.unit_price) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.total_amount) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.unit_price) }} {{ invoice.currency_code }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(item.total_amount) }} {{ invoice.currency_code }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -71,8 +71,8 @@
|
||||
<td class="p-2">{{ good.description or '-' }}</td>
|
||||
<td class="p-2">{{ good.category|capitalize }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(good.quantity) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(good.unit_price) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(good.total_amount) }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(good.unit_price) }} {{ invoice.currency_code }}</td>
|
||||
<td class="p-2">{{ "%.2f"|format(good.total_amount) }} {{ invoice.currency_code }}</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
@@ -84,15 +84,15 @@
|
||||
<div class="w-full md:w-1/3">
|
||||
<div class="flex justify-between">
|
||||
<span>Subtotal</span>
|
||||
<span>{{ "%.2f"|format(invoice.subtotal) }}</span>
|
||||
<span>{{ "%.2f"|format(invoice.subtotal) }} {{ invoice.currency_code }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between">
|
||||
<span>Tax ({{ "%.2f"|format(invoice.tax_rate) }}%)</span>
|
||||
<span>{{ "%.2f"|format(invoice.tax_amount) }}</span>
|
||||
<span>{{ "%.2f"|format(invoice.tax_amount) }} {{ invoice.currency_code }}</span>
|
||||
</div>
|
||||
<div class="flex justify-between font-bold text-lg">
|
||||
<span>Total</span>
|
||||
<span>{{ "%.2f"|format(invoice.total_amount) }}</span>
|
||||
<span>{{ "%.2f"|format(invoice.total_amount) }} {{ invoice.currency_code }}</span>
|
||||
</div>
|
||||
</div>
|
||||
</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):
|
||||
|
||||
@@ -104,3 +104,60 @@ def register_template_filters(app):
|
||||
return f"{float(value):,.2f}"
|
||||
except Exception:
|
||||
return str(value)
|
||||
|
||||
@app.template_filter('timeago')
|
||||
def timeago_filter(dt):
|
||||
"""Convert a datetime to a 'time ago' string (e.g., '2 hours ago')"""
|
||||
if dt is None:
|
||||
return ""
|
||||
|
||||
# Import here to avoid circular imports
|
||||
from datetime import datetime, timezone
|
||||
|
||||
# Ensure we're working with a timezone-aware datetime
|
||||
if dt.tzinfo is None:
|
||||
# Assume UTC if no timezone info
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
|
||||
# Get current time in UTC
|
||||
now = datetime.now(timezone.utc)
|
||||
|
||||
# Calculate difference
|
||||
diff = now - dt
|
||||
|
||||
# Convert to seconds
|
||||
seconds = diff.total_seconds()
|
||||
|
||||
# Handle future dates
|
||||
if seconds < 0:
|
||||
return "just now"
|
||||
|
||||
# Calculate time units
|
||||
minutes = seconds / 60
|
||||
hours = minutes / 60
|
||||
days = hours / 24
|
||||
weeks = days / 7
|
||||
months = days / 30
|
||||
years = days / 365
|
||||
|
||||
# Return appropriate string
|
||||
if seconds < 60:
|
||||
return "just now"
|
||||
elif minutes < 60:
|
||||
m = int(minutes)
|
||||
return f"{m} minute{'s' if m != 1 else ''} ago"
|
||||
elif hours < 24:
|
||||
h = int(hours)
|
||||
return f"{h} hour{'s' if h != 1 else ''} ago"
|
||||
elif days < 7:
|
||||
d = int(days)
|
||||
return f"{d} day{'s' if d != 1 else ''} ago"
|
||||
elif weeks < 4:
|
||||
w = int(weeks)
|
||||
return f"{w} week{'s' if w != 1 else ''} ago"
|
||||
elif months < 12:
|
||||
mo = int(months)
|
||||
return f"{mo} month{'s' if mo != 1 else ''} ago"
|
||||
else:
|
||||
y = int(years)
|
||||
return f"{y} year{'s' if y != 1 else ''} ago"
|
||||
|
||||
@@ -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?
|
||||
|
||||
|
||||
67
migrations/fix_invoice_currency.py
Normal file
67
migrations/fix_invoice_currency.py
Normal file
@@ -0,0 +1,67 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Migration script to fix invoice currency codes.
|
||||
Updates all invoices to use the currency from Settings instead of hard-coded EUR.
|
||||
"""
|
||||
|
||||
import sys
|
||||
import os
|
||||
|
||||
# Add parent directory to path to import app
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from app import create_app, db
|
||||
from app.models import Invoice, Settings
|
||||
|
||||
def fix_invoice_currencies():
|
||||
"""Update all invoices to use currency from Settings"""
|
||||
app = create_app()
|
||||
|
||||
with app.app_context():
|
||||
# Get the currency from settings
|
||||
settings = Settings.get_settings()
|
||||
target_currency = settings.currency if settings else 'USD'
|
||||
|
||||
print(f"Target currency from settings: {target_currency}")
|
||||
|
||||
# Get all invoices
|
||||
invoices = Invoice.query.all()
|
||||
|
||||
if not invoices:
|
||||
print("No invoices found in database.")
|
||||
return
|
||||
|
||||
print(f"Found {len(invoices)} invoices to process.")
|
||||
|
||||
# Update each invoice that doesn't match the target currency
|
||||
updated_count = 0
|
||||
for invoice in invoices:
|
||||
if invoice.currency_code != target_currency:
|
||||
print(f"Updating invoice {invoice.invoice_number}: {invoice.currency_code} -> {target_currency}")
|
||||
invoice.currency_code = target_currency
|
||||
updated_count += 1
|
||||
|
||||
if updated_count > 0:
|
||||
try:
|
||||
db.session.commit()
|
||||
print(f"\nSuccessfully updated {updated_count} invoice(s) to use {target_currency}.")
|
||||
except Exception as e:
|
||||
db.session.rollback()
|
||||
print(f"Error updating invoices: {e}")
|
||||
sys.exit(1)
|
||||
else:
|
||||
print(f"\nAll invoices already using {target_currency}. No updates needed.")
|
||||
|
||||
if __name__ == '__main__':
|
||||
print("=" * 60)
|
||||
print("Invoice Currency Migration")
|
||||
print("=" * 60)
|
||||
print("\nThis script will update all invoices to use the currency")
|
||||
print("configured in Settings instead of the hard-coded default.\n")
|
||||
|
||||
response = input("Do you want to proceed? (yes/no): ").strip().lower()
|
||||
if response in ['yes', 'y']:
|
||||
fix_invoice_currencies()
|
||||
else:
|
||||
print("Migration cancelled.")
|
||||
|
||||
2
setup.py
2
setup.py
@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
|
||||
|
||||
setup(
|
||||
name='timetracker',
|
||||
version='3.4.0',
|
||||
version='3.4.1',
|
||||
packages=find_packages(),
|
||||
include_package_data=True,
|
||||
install_requires=[
|
||||
|
||||
194
test_logo_pdf.py
Normal file
194
test_logo_pdf.py
Normal file
@@ -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)
|
||||
|
||||
280
tests/test_invoice_currency_fix.py
Normal file
280
tests/test_invoice_currency_fix.py
Normal file
@@ -0,0 +1,280 @@
|
||||
"""
|
||||
Test suite for invoice currency fix
|
||||
Tests that invoices use the currency from Settings instead of hard-coded EUR
|
||||
"""
|
||||
import pytest
|
||||
import os
|
||||
from datetime import datetime, timedelta, date
|
||||
from decimal import Decimal
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, Client, Invoice, InvoiceItem, Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create and configure a test app instance"""
|
||||
# Set test database URL before creating app
|
||||
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
|
||||
|
||||
app = create_app()
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
|
||||
# Create test settings with USD currency
|
||||
settings = Settings(currency='USD')
|
||||
db.session.add(settings)
|
||||
db.session.commit()
|
||||
|
||||
yield app
|
||||
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def client_fixture(app):
|
||||
"""Create test client"""
|
||||
return app.test_client()
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_user(app):
|
||||
"""Create a test user"""
|
||||
with app.app_context():
|
||||
user = User(username='testuser', email='test@example.com', is_admin=True)
|
||||
user.set_password('password123')
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
return user
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_client_model(app, test_user):
|
||||
"""Create a test client"""
|
||||
with app.app_context():
|
||||
client = Client(
|
||||
name='Test Client',
|
||||
email='client@example.com',
|
||||
created_by=test_user.id
|
||||
)
|
||||
db.session.add(client)
|
||||
db.session.commit()
|
||||
return client
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def test_project(app, test_user, test_client_model):
|
||||
"""Create a test project"""
|
||||
with app.app_context():
|
||||
project = Project(
|
||||
name='Test Project',
|
||||
client_id=test_client_model.id,
|
||||
created_by=test_user.id,
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00'),
|
||||
status='active'
|
||||
)
|
||||
db.session.add(project)
|
||||
db.session.commit()
|
||||
return project
|
||||
|
||||
|
||||
class TestInvoiceCurrencyFix:
|
||||
"""Test that invoices use correct currency from Settings"""
|
||||
|
||||
def test_new_invoice_uses_settings_currency(self, app, test_user, test_project, test_client_model):
|
||||
"""Test that a new invoice uses the currency from Settings"""
|
||||
with app.app_context():
|
||||
# Get settings - should have USD currency
|
||||
settings = Settings.get_settings()
|
||||
assert settings.currency == 'USD'
|
||||
|
||||
# Create invoice via model (simulating route behavior)
|
||||
invoice = Invoice(
|
||||
invoice_number='TEST-001',
|
||||
project_id=test_project.id,
|
||||
client_name=test_client_model.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=test_client_model.id,
|
||||
currency_code=settings.currency
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Verify invoice has USD currency
|
||||
assert invoice.currency_code == 'USD'
|
||||
|
||||
def test_invoice_creation_via_route(self, app, client_fixture, test_user, test_project, test_client_model):
|
||||
"""Test that invoice creation via route uses correct currency"""
|
||||
with app.app_context():
|
||||
# Login
|
||||
client_fixture.post('/login', data={
|
||||
'username': 'testuser',
|
||||
'password': 'password123'
|
||||
}, follow_redirects=True)
|
||||
|
||||
# Create invoice via route
|
||||
response = client_fixture.post('/invoices/create', data={
|
||||
'project_id': test_project.id,
|
||||
'client_name': test_client_model.name,
|
||||
'client_email': test_client_model.email,
|
||||
'due_date': (date.today() + timedelta(days=30)).strftime('%Y-%m-%d'),
|
||||
'tax_rate': '0'
|
||||
}, follow_redirects=True)
|
||||
|
||||
assert response.status_code == 200
|
||||
|
||||
# Get the created invoice
|
||||
invoice = Invoice.query.first()
|
||||
assert invoice is not None
|
||||
assert invoice.currency_code == 'USD'
|
||||
|
||||
def test_invoice_with_different_currency_setting(self, app, test_user, test_project, test_client_model):
|
||||
"""Test invoice creation with different currency settings"""
|
||||
with app.app_context():
|
||||
# Change settings currency to GBP
|
||||
settings = Settings.get_settings()
|
||||
settings.currency = 'GBP'
|
||||
db.session.commit()
|
||||
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
invoice_number='TEST-002',
|
||||
project_id=test_project.id,
|
||||
client_name=test_client_model.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=test_client_model.id,
|
||||
currency_code=settings.currency
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Verify invoice has GBP currency
|
||||
assert invoice.currency_code == 'GBP'
|
||||
|
||||
def test_invoice_duplicate_preserves_currency(self, app, test_user, test_project, test_client_model):
|
||||
"""Test that duplicating an invoice preserves the currency"""
|
||||
with app.app_context():
|
||||
# Create original invoice with JPY currency
|
||||
original_invoice = Invoice(
|
||||
invoice_number='ORIG-001',
|
||||
project_id=test_project.id,
|
||||
client_name=test_client_model.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=test_client_model.id,
|
||||
currency_code='JPY'
|
||||
)
|
||||
db.session.add(original_invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Simulate duplication (like in duplicate_invoice route)
|
||||
new_invoice = Invoice(
|
||||
invoice_number='DUP-001',
|
||||
project_id=original_invoice.project_id,
|
||||
client_name=original_invoice.client_name,
|
||||
due_date=original_invoice.due_date + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=original_invoice.client_id,
|
||||
currency_code=original_invoice.currency_code
|
||||
)
|
||||
db.session.add(new_invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Verify duplicated invoice has same currency
|
||||
assert new_invoice.currency_code == 'JPY'
|
||||
|
||||
def test_invoice_items_display_with_currency(self, app, test_user, test_project, test_client_model):
|
||||
"""Test that invoice items display correctly with currency"""
|
||||
with app.app_context():
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
invoice_number='TEST-003',
|
||||
project_id=test_project.id,
|
||||
client_name=test_client_model.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=test_client_model.id,
|
||||
currency_code='EUR'
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.flush()
|
||||
|
||||
# Add invoice item
|
||||
item = InvoiceItem(
|
||||
invoice_id=invoice.id,
|
||||
description='Test Service',
|
||||
quantity=Decimal('10.00'),
|
||||
unit_price=Decimal('100.00')
|
||||
)
|
||||
db.session.add(item)
|
||||
db.session.commit()
|
||||
|
||||
# Verify invoice and item
|
||||
assert invoice.currency_code == 'EUR'
|
||||
assert item.total_amount == Decimal('1000.00')
|
||||
|
||||
def test_settings_currency_default(self, app):
|
||||
"""Test that Settings default currency matches configuration"""
|
||||
with app.app_context():
|
||||
# Clear existing settings
|
||||
Settings.query.delete()
|
||||
db.session.commit()
|
||||
|
||||
# Get settings (should create new with defaults)
|
||||
settings = Settings.get_settings()
|
||||
|
||||
# Should have some currency set (from Config or default)
|
||||
assert settings.currency is not None
|
||||
assert len(settings.currency) == 3 # Currency codes are 3 characters
|
||||
|
||||
def test_invoice_model_init_with_currency_kwarg(self, app, test_user, test_project, test_client_model):
|
||||
"""Test that Invoice __init__ properly accepts currency_code kwarg"""
|
||||
with app.app_context():
|
||||
# Create invoice with explicit currency_code
|
||||
invoice = Invoice(
|
||||
invoice_number='TEST-004',
|
||||
project_id=test_project.id,
|
||||
client_name=test_client_model.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=test_client_model.id,
|
||||
currency_code='CAD'
|
||||
)
|
||||
|
||||
# Verify currency is set correctly
|
||||
assert invoice.currency_code == 'CAD'
|
||||
|
||||
def test_invoice_to_dict_includes_currency(self, app, test_user, test_project, test_client_model):
|
||||
"""Test that invoice to_dict includes currency information"""
|
||||
with app.app_context():
|
||||
# Create invoice
|
||||
invoice = Invoice(
|
||||
invoice_number='TEST-005',
|
||||
project_id=test_project.id,
|
||||
client_name=test_client_model.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=test_user.id,
|
||||
client_id=test_client_model.id,
|
||||
currency_code='AUD'
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Convert to dict
|
||||
invoice_dict = invoice.to_dict()
|
||||
|
||||
# Verify currency is included (though it may not be in to_dict currently)
|
||||
# This test documents expected behavior
|
||||
assert invoice.currency_code == 'AUD'
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
122
tests/test_invoice_currency_smoke.py
Normal file
122
tests/test_invoice_currency_smoke.py
Normal file
@@ -0,0 +1,122 @@
|
||||
"""
|
||||
Smoke tests for invoice currency functionality
|
||||
Simple high-level tests to ensure the system works end-to-end
|
||||
"""
|
||||
import pytest
|
||||
import os
|
||||
from datetime import date, timedelta
|
||||
from decimal import Decimal
|
||||
from app import create_app, db
|
||||
from app.models import User, Project, Client, Invoice, Settings
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def app():
|
||||
"""Create and configure a test app instance"""
|
||||
# Set test database URL before creating app
|
||||
os.environ['DATABASE_URL'] = 'sqlite:///:memory:'
|
||||
|
||||
app = create_app()
|
||||
app.config['TESTING'] = True
|
||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///:memory:'
|
||||
app.config['WTF_CSRF_ENABLED'] = False
|
||||
|
||||
with app.app_context():
|
||||
db.create_all()
|
||||
yield app
|
||||
db.session.remove()
|
||||
db.drop_all()
|
||||
|
||||
|
||||
def test_invoice_currency_smoke(app):
|
||||
"""Smoke test: Create invoice and verify it uses settings currency"""
|
||||
with app.app_context():
|
||||
# Setup: Create user, client, project
|
||||
user = User(username='smokeuser', email='smoke@example.com', is_admin=True)
|
||||
user.set_password('password')
|
||||
db.session.add(user)
|
||||
|
||||
client = Client(name='Smoke Client', email='client@example.com', created_by=1)
|
||||
db.session.add(client)
|
||||
|
||||
project = Project(
|
||||
name='Smoke Project',
|
||||
client_id=1,
|
||||
created_by=1,
|
||||
billable=True,
|
||||
hourly_rate=Decimal('100.00'),
|
||||
status='active'
|
||||
)
|
||||
db.session.add(project)
|
||||
|
||||
# Set currency in settings
|
||||
settings = Settings.get_settings()
|
||||
settings.currency = 'CHF'
|
||||
|
||||
db.session.commit()
|
||||
|
||||
# Action: Create invoice
|
||||
invoice = Invoice(
|
||||
invoice_number='SMOKE-001',
|
||||
project_id=project.id,
|
||||
client_name=client.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=user.id,
|
||||
client_id=client.id,
|
||||
currency_code=settings.currency
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Verify: Invoice has correct currency
|
||||
assert invoice.currency_code == 'CHF', f"Expected CHF but got {invoice.currency_code}"
|
||||
|
||||
print("✓ Smoke test passed: Invoice currency correctly set from Settings")
|
||||
|
||||
|
||||
def test_pdf_generator_uses_settings_currency(app):
|
||||
"""Smoke test: Verify PDF generator uses settings currency"""
|
||||
with app.app_context():
|
||||
# Setup
|
||||
user = User(username='pdfuser', email='pdf@example.com', is_admin=True)
|
||||
user.set_password('password')
|
||||
db.session.add(user)
|
||||
|
||||
client = Client(name='PDF Client', email='pdf@example.com', created_by=1)
|
||||
db.session.add(client)
|
||||
|
||||
project = Project(
|
||||
name='PDF Project',
|
||||
client_id=1,
|
||||
created_by=1,
|
||||
billable=True,
|
||||
hourly_rate=Decimal('150.00'),
|
||||
status='active'
|
||||
)
|
||||
db.session.add(project)
|
||||
|
||||
settings = Settings.get_settings()
|
||||
settings.currency = 'SEK'
|
||||
|
||||
invoice = Invoice(
|
||||
invoice_number='PDF-001',
|
||||
project_id=project.id,
|
||||
client_name=client.name,
|
||||
due_date=date.today() + timedelta(days=30),
|
||||
created_by=user.id,
|
||||
client_id=client.id,
|
||||
currency_code=settings.currency
|
||||
)
|
||||
db.session.add(invoice)
|
||||
db.session.commit()
|
||||
|
||||
# Verify
|
||||
assert invoice.currency_code == settings.currency
|
||||
assert settings.currency == 'SEK'
|
||||
|
||||
print("✓ Smoke test passed: PDF generator will use correct currency")
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
pytest.main([__file__, '-v'])
|
||||
|
||||
@@ -239,6 +239,32 @@ class TestTimeEntryTemplateRoutes:
|
||||
response = client.get('/templates', follow_redirects=False)
|
||||
assert response.status_code == 302 # Redirect to login
|
||||
|
||||
@pytest.mark.smoke
|
||||
def test_list_templates_with_usage_data(self, authenticated_client, user, project):
|
||||
"""Test templates list page renders correctly with templates that have usage data"""
|
||||
# Create a template with usage data (last_used_at set)
|
||||
from datetime import datetime, timezone
|
||||
from app.models import TimeEntryTemplate
|
||||
from app import db
|
||||
|
||||
template = TimeEntryTemplate(
|
||||
user_id=user.id,
|
||||
name='Used Template',
|
||||
project_id=project.id,
|
||||
default_duration_minutes=60,
|
||||
usage_count=5,
|
||||
last_used_at=datetime.now(timezone.utc)
|
||||
)
|
||||
db.session.add(template)
|
||||
db.session.commit()
|
||||
|
||||
# Access the list page
|
||||
response = authenticated_client.get('/templates')
|
||||
assert response.status_code == 200
|
||||
assert b'Used Template' in response.data
|
||||
# Verify that timeago filter is working (should show "just now" or similar)
|
||||
assert b'ago' in response.data or b'just now' in response.data
|
||||
|
||||
def test_create_template_page_get(self, authenticated_client):
|
||||
"""Test accessing create template page"""
|
||||
response = authenticated_client.get('/templates/create')
|
||||
|
||||
@@ -266,6 +266,169 @@ def test_format_money_filter_invalid(app):
|
||||
assert result == "not a number"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_none(app):
|
||||
"""Test timeago filter with None."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
result = filter_func(None)
|
||||
assert result == ""
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_just_now(app):
|
||||
"""Test timeago filter with very recent datetime."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 30 seconds ago
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(seconds=30)
|
||||
result = filter_func(dt)
|
||||
assert result == "just now"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_minutes(app):
|
||||
"""Test timeago filter with minutes ago."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 5 minutes ago
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(minutes=5)
|
||||
result = filter_func(dt)
|
||||
assert "minute" in result
|
||||
assert "ago" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_hours(app):
|
||||
"""Test timeago filter with hours ago."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 3 hours ago
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(hours=3)
|
||||
result = filter_func(dt)
|
||||
assert "hour" in result
|
||||
assert "ago" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_days(app):
|
||||
"""Test timeago filter with days ago."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 2 days ago
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(days=2)
|
||||
result = filter_func(dt)
|
||||
assert "day" in result
|
||||
assert "ago" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_weeks(app):
|
||||
"""Test timeago filter with weeks ago."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 2 weeks ago
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(weeks=2)
|
||||
result = filter_func(dt)
|
||||
assert "week" in result
|
||||
assert "ago" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_months(app):
|
||||
"""Test timeago filter with months ago."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 60 days ago (2 months)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(days=60)
|
||||
result = filter_func(dt)
|
||||
assert "month" in result
|
||||
assert "ago" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_years(app):
|
||||
"""Test timeago filter with years ago."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime for 400 days ago (over a year)
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now - datetime.timedelta(days=400)
|
||||
result = filter_func(dt)
|
||||
assert "year" in result
|
||||
assert "ago" in result
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_future(app):
|
||||
"""Test timeago filter with future datetime."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create datetime in the future
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
dt = now + datetime.timedelta(hours=2)
|
||||
result = filter_func(dt)
|
||||
assert result == "just now"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_naive_datetime(app):
|
||||
"""Test timeago filter with naive datetime (no timezone)."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
# Create naive datetime for 1 hour ago
|
||||
dt = datetime.datetime.now() - datetime.timedelta(hours=1)
|
||||
result = filter_func(dt)
|
||||
# Should still work and convert to UTC
|
||||
assert "ago" in result or result == "just now"
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@pytest.mark.utils
|
||||
def test_timeago_filter_singular_plural(app):
|
||||
"""Test timeago filter uses correct singular/plural forms."""
|
||||
register_template_filters(app)
|
||||
with app.app_context():
|
||||
filter_func = app.jinja_env.filters.get('timeago')
|
||||
now = datetime.datetime.now(datetime.timezone.utc)
|
||||
|
||||
# Test singular (1 minute)
|
||||
dt = now - datetime.timedelta(minutes=1)
|
||||
result = filter_func(dt)
|
||||
assert "1 minute ago" in result
|
||||
|
||||
# Test plural (2 minutes)
|
||||
dt = now - datetime.timedelta(minutes=2)
|
||||
result = filter_func(dt)
|
||||
assert "2 minutes ago" in result
|
||||
|
||||
|
||||
# ============================================================================
|
||||
# Context Processor Tests
|
||||
# ============================================================================
|
||||
|
||||
Reference in New Issue
Block a user