Fix scheduled task errors and improve error handling

- Fix webhook retry scheduled task app context issue
  * Update retry_failed_webhooks() to properly capture and use app instance
  * Pass app instance when registering scheduled tasks
  * Prevents 'Working outside of application context' errors

- Improve timezone function error handling
  * Add app context check before database access in get_app_timezone()
  * Gracefully fallback to environment variable when app context unavailable
  * Prevents RuntimeError when accessing database outside app context

- Add error deduplication to prevent stacked error messages
  * Track recent errors to prevent duplicate notifications within 1 minute
  * Reduces error message stacking when same error occurs repeatedly
  * Still logs all errors to console for debugging

- Fix theme toggle endpoint error
  * Change from /api/preferences (PATCH) to /api/theme (POST)
  * Add proper error handling to prevent unhandled promise rejections
  * Fixes 405 Method Not Allowed error when changing theme

- Bump version to 3.10.3
This commit is contained in:
Dries Peeters
2025-11-20 20:51:53 +01:00
parent 4e8b42ad56
commit 60fb259f9e
6 changed files with 316 additions and 206 deletions

View File

@@ -278,9 +278,9 @@ def create_app(config=None):
if (not app.config.get("TESTING")) and (not scheduler.running):
from app.utils.scheduled_tasks import register_scheduled_tasks
scheduler.start()
# Register tasks after app context is available
# Register tasks after app context is available, passing app instance
with app.app_context():
register_scheduled_tasks(scheduler)
register_scheduled_tasks(scheduler, app=app)
# Only initialize CSRF protection if enabled
if app.config.get('WTF_CSRF_ENABLED'):

View File

@@ -10,6 +10,9 @@ class EnhancedErrorHandler {
this.isOnline = navigator.onLine;
this.retryAttempts = new Map();
this.maxRetries = 3;
// Track recent errors to prevent duplicates
this.recentErrors = new Map(); // message -> timestamp
this.errorDeduplicationWindow = 60000; // 1 minute - don't show same error twice within this window
this.init();
}
@@ -427,12 +430,42 @@ class EnhancedErrorHandler {
}
showError(message, title = 'Error') {
// Check for duplicates before showing
if (this.isDuplicateError(message)) {
console.warn('Duplicate error suppressed:', message);
return;
}
if (window.toastManager) {
window.toastManager.error(message, title);
} else {
console.error(title + ':', message);
}
}
/**
* Check if an error message was recently shown (deduplication)
*/
isDuplicateError(message) {
const now = Date.now();
const lastShown = this.recentErrors.get(message);
if (lastShown && (now - lastShown) < this.errorDeduplicationWindow) {
return true; // This error was shown recently
}
// Update the timestamp for this error
this.recentErrors.set(message, now);
// Clean up old entries (older than deduplication window)
for (const [msg, timestamp] of this.recentErrors.entries()) {
if (now - timestamp >= this.errorDeduplicationWindow) {
this.recentErrors.delete(msg);
}
}
return false;
}
/**
* Recovery Options
@@ -593,6 +626,18 @@ class EnhancedErrorHandler {
const userFriendlyMessage = 'An unexpected error occurred. Please refresh the page or contact support if the problem persists.';
// Check if we've shown this error recently
if (this.isDuplicateError(userFriendlyMessage)) {
// Log to console but don't show duplicate toast
console.error('JavaScript Error (duplicate suppressed):', {
error,
message,
filename,
lineno
});
return;
}
this.showError(userFriendlyMessage, 'Application Error');
// Log to console for debugging
@@ -611,6 +656,13 @@ class EnhancedErrorHandler {
const userFriendlyMessage = 'An operation failed unexpectedly. Please try again or contact support if the problem persists.';
// Check if we've shown this error recently
if (this.isDuplicateError(userFriendlyMessage)) {
// Log to console but don't show duplicate toast
console.error('Unhandled Rejection (duplicate suppressed):', reason);
return;
}
this.showError(userFriendlyMessage, 'Operation Failed');
// Log to console for debugging

View File

@@ -958,14 +958,32 @@
// Save to database if user is logged in
{% if current_user.is_authenticated %}
fetch('/api/preferences', {
fetch('/api/theme', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-CSRFToken': '{{ csrf_token() }}'
},
body: JSON.stringify({ theme: newTheme })
}).catch(err => console.error('Failed to save theme preference:', err));
})
.then(response => {
if (!response.ok) {
return response.json().then(data => {
throw new Error(data.error || 'Failed to save theme preference');
});
}
return response.json();
})
.then(data => {
if (data.success) {
console.log('Theme preference saved:', data.theme);
}
})
.catch(err => {
console.error('Failed to save theme preference:', err);
// Don't show error toast for theme changes - it's not critical
// The theme change already worked locally, just didn't persist
});
{% endif %}
});

View File

@@ -18,52 +18,53 @@ def check_overdue_invoices():
This task should be run daily to check for invoices that are past their due date
and send notifications to users who have overdue invoice notifications enabled.
"""
try:
logger.info("Checking for overdue invoices...")
# Get all invoices that are overdue and not paid/cancelled
today = datetime.utcnow().date()
overdue_invoices = Invoice.query.filter(
Invoice.due_date < today,
Invoice.status.in_(['draft', 'sent'])
).all()
logger.info(f"Found {len(overdue_invoices)} overdue invoices")
notifications_sent = 0
for invoice in overdue_invoices:
# Update invoice status to overdue if it's not already
if invoice.status != 'overdue':
invoice.status = 'overdue'
db.session.commit()
with current_app.app_context():
try:
logger.info("Checking for overdue invoices...")
# Get users to notify (creator and admins)
users_to_notify = set()
# Get all invoices that are overdue and not paid/cancelled
today = datetime.utcnow().date()
overdue_invoices = Invoice.query.filter(
Invoice.due_date < today,
Invoice.status.in_(['draft', 'sent'])
).all()
# Add the invoice creator
if invoice.creator:
users_to_notify.add(invoice.creator)
logger.info(f"Found {len(overdue_invoices)} overdue invoices")
# Add all admins
admins = User.query.filter_by(role='admin', is_active=True).all()
users_to_notify.update(admins)
notifications_sent = 0
for invoice in overdue_invoices:
# Update invoice status to overdue if it's not already
if invoice.status != 'overdue':
invoice.status = 'overdue'
db.session.commit()
# Get users to notify (creator and admins)
users_to_notify = set()
# Add the invoice creator
if invoice.creator:
users_to_notify.add(invoice.creator)
# Add all admins
admins = User.query.filter_by(role='admin', is_active=True).all()
users_to_notify.update(admins)
# Send notifications
for user in users_to_notify:
if user.email and user.email_notifications and user.notification_overdue_invoices:
try:
send_overdue_invoice_notification(invoice, user)
notifications_sent += 1
logger.info(f"Sent overdue notification for invoice {invoice.invoice_number} to {user.username}")
except Exception as e:
logger.error(f"Failed to send notification to {user.username}: {e}")
# Send notifications
for user in users_to_notify:
if user.email and user.email_notifications and user.notification_overdue_invoices:
try:
send_overdue_invoice_notification(invoice, user)
notifications_sent += 1
logger.info(f"Sent overdue notification for invoice {invoice.invoice_number} to {user.username}")
except Exception as e:
logger.error(f"Failed to send notification to {user.username}: {e}")
logger.info(f"Sent {notifications_sent} overdue invoice notifications")
return notifications_sent
logger.info(f"Sent {notifications_sent} overdue invoice notifications")
return notifications_sent
except Exception as e:
logger.error(f"Error checking overdue invoices: {e}")
return 0
except Exception as e:
logger.error(f"Error checking overdue invoices: {e}")
return 0
def send_weekly_summaries():
@@ -72,75 +73,76 @@ def send_weekly_summaries():
This task should be run weekly (e.g., Sunday evening or Monday morning)
to send time tracking summaries to users who have opted in.
"""
try:
logger.info("Sending weekly summaries...")
# Get users who want weekly summaries
users = User.query.filter_by(
is_active=True,
email_notifications=True,
notification_weekly_summary=True
).all()
logger.info(f"Found {len(users)} users with weekly summaries enabled")
# Calculate date range (last 7 days)
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=7)
summaries_sent = 0
for user in users:
if not user.email:
continue
with current_app.app_context():
try:
logger.info("Sending weekly summaries...")
try:
# Get time entries for this user in the past week
entries = TimeEntry.query.filter(
TimeEntry.user_id == user.id,
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
TimeEntry.start_time < datetime.combine(end_date + timedelta(days=1), datetime.min.time()),
TimeEntry.end_time.isnot(None)
).all()
if not entries:
logger.info(f"No entries for {user.username}, skipping")
# Get users who want weekly summaries
users = User.query.filter_by(
is_active=True,
email_notifications=True,
notification_weekly_summary=True
).all()
logger.info(f"Found {len(users)} users with weekly summaries enabled")
# Calculate date range (last 7 days)
end_date = datetime.utcnow().date()
start_date = end_date - timedelta(days=7)
summaries_sent = 0
for user in users:
if not user.email:
continue
# Calculate hours worked
hours_worked = sum(e.duration_hours for e in entries)
try:
# Get time entries for this user in the past week
entries = TimeEntry.query.filter(
TimeEntry.user_id == user.id,
TimeEntry.start_time >= datetime.combine(start_date, datetime.min.time()),
TimeEntry.start_time < datetime.combine(end_date + timedelta(days=1), datetime.min.time()),
TimeEntry.end_time.isnot(None)
).all()
if not entries:
logger.info(f"No entries for {user.username}, skipping")
continue
# Calculate hours worked
hours_worked = sum(e.duration_hours for e in entries)
# Group by project
projects_map = {}
for entry in entries:
if entry.project:
project_name = entry.project.name
if project_name not in projects_map:
projects_map[project_name] = {'name': project_name, 'hours': 0}
projects_map[project_name]['hours'] += entry.duration_hours
projects_data = sorted(projects_map.values(), key=lambda x: x['hours'], reverse=True)
# Send email
send_weekly_summary(
user=user,
start_date=start_date.strftime('%Y-%m-%d'),
end_date=end_date.strftime('%Y-%m-%d'),
hours_worked=hours_worked,
projects_data=projects_data
)
summaries_sent += 1
logger.info(f"Sent weekly summary to {user.username}")
# Group by project
projects_map = {}
for entry in entries:
if entry.project:
project_name = entry.project.name
if project_name not in projects_map:
projects_map[project_name] = {'name': project_name, 'hours': 0}
projects_map[project_name]['hours'] += entry.duration_hours
projects_data = sorted(projects_map.values(), key=lambda x: x['hours'], reverse=True)
# Send email
send_weekly_summary(
user=user,
start_date=start_date.strftime('%Y-%m-%d'),
end_date=end_date.strftime('%Y-%m-%d'),
hours_worked=hours_worked,
projects_data=projects_data
)
summaries_sent += 1
logger.info(f"Sent weekly summary to {user.username}")
except Exception as e:
logger.error(f"Failed to send weekly summary to {user.username}: {e}")
except Exception as e:
logger.error(f"Failed to send weekly summary to {user.username}: {e}")
logger.info(f"Sent {summaries_sent} weekly summaries")
return summaries_sent
logger.info(f"Sent {summaries_sent} weekly summaries")
return summaries_sent
except Exception as e:
logger.error(f"Error sending weekly summaries: {e}")
return 0
except Exception as e:
logger.error(f"Error sending weekly summaries: {e}")
return 0
def check_project_budget_alerts():
@@ -149,44 +151,45 @@ def check_project_budget_alerts():
This task should be run periodically (e.g., every 6 hours) to check
project budgets and create alerts when thresholds are exceeded.
"""
try:
logger.info("Checking project budget alerts...")
# Get all active projects with budgets
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
logger.info(f"Found {len(projects)} active projects with budgets")
total_alerts_created = 0
for project in projects:
try:
# Check for budget alerts
alerts_to_create = check_budget_alerts(project.id)
# Create alerts
for alert_data in alerts_to_create:
alert = BudgetAlert.create_alert(
project_id=alert_data['project_id'],
alert_type=alert_data['type'],
budget_consumed_percent=alert_data['budget_consumed_percent'],
budget_amount=alert_data['budget_amount'],
consumed_amount=alert_data['consumed_amount']
)
total_alerts_created += 1
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
with current_app.app_context():
try:
logger.info("Checking project budget alerts...")
except Exception as e:
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
# Get all active projects with budgets
projects = Project.query.filter(
Project.budget_amount.isnot(None),
Project.status == 'active'
).all()
logger.info(f"Found {len(projects)} active projects with budgets")
total_alerts_created = 0
for project in projects:
try:
# Check for budget alerts
alerts_to_create = check_budget_alerts(project.id)
# Create alerts
for alert_data in alerts_to_create:
alert = BudgetAlert.create_alert(
project_id=alert_data['project_id'],
alert_type=alert_data['type'],
budget_consumed_percent=alert_data['budget_consumed_percent'],
budget_amount=alert_data['budget_amount'],
consumed_amount=alert_data['consumed_amount']
)
total_alerts_created += 1
logger.info(f"Created {alert_data['type']} alert for project {project.name}")
except Exception as e:
logger.error(f"Error checking budget alerts for project {project.id}: {e}")
logger.info(f"Created {total_alerts_created} budget alerts")
return total_alerts_created
logger.info(f"Created {total_alerts_created} budget alerts")
return total_alerts_created
except Exception as e:
logger.error(f"Error checking project budget alerts: {e}")
return 0
except Exception as e:
logger.error(f"Error checking project budget alerts: {e}")
return 0
def generate_recurring_invoices():
@@ -194,66 +197,68 @@ def generate_recurring_invoices():
This task should be run daily to check for recurring invoices that need to be generated.
"""
try:
logger.info("Generating recurring invoices...")
# Get all active recurring invoices that should generate today
today = datetime.utcnow().date()
recurring_invoices = RecurringInvoice.query.filter(
RecurringInvoice.is_active == True,
RecurringInvoice.next_run_date <= today
).all()
logger.info(f"Found {len(recurring_invoices)} recurring invoices to process")
invoices_generated = 0
emails_sent = 0
for recurring in recurring_invoices:
try:
# Check if we've reached the end date
if recurring.end_date and today > recurring.end_date:
logger.info(f"Recurring invoice {recurring.id} has reached end date, deactivating")
recurring.is_active = False
db.session.commit()
continue
# Generate invoice
invoice = recurring.generate_invoice()
if invoice:
db.session.commit()
invoices_generated += 1
logger.info(f"Generated invoice {invoice.invoice_number} from recurring template {recurring.name}")
# Auto-send if enabled
if recurring.auto_send and invoice.client_email:
try:
from app.utils.email import send_invoice_email
send_invoice_email(invoice, invoice.client_email, sender_user=recurring.creator)
emails_sent += 1
logger.info(f"Auto-sent invoice {invoice.invoice_number} to {invoice.client_email}")
except Exception as e:
logger.error(f"Failed to auto-send invoice {invoice.invoice_number}: {e}")
else:
logger.warning(f"Failed to generate invoice from recurring template {recurring.id}")
with current_app.app_context():
try:
logger.info("Generating recurring invoices...")
except Exception as e:
logger.error(f"Error processing recurring invoice {recurring.id}: {e}")
db.session.rollback()
# Get all active recurring invoices that should generate today
today = datetime.utcnow().date()
recurring_invoices = RecurringInvoice.query.filter(
RecurringInvoice.is_active == True,
RecurringInvoice.next_run_date <= today
).all()
logger.info(f"Found {len(recurring_invoices)} recurring invoices to process")
invoices_generated = 0
emails_sent = 0
for recurring in recurring_invoices:
try:
# Check if we've reached the end date
if recurring.end_date and today > recurring.end_date:
logger.info(f"Recurring invoice {recurring.id} has reached end date, deactivating")
recurring.is_active = False
db.session.commit()
continue
# Generate invoice
invoice = recurring.generate_invoice()
if invoice:
db.session.commit()
invoices_generated += 1
logger.info(f"Generated invoice {invoice.invoice_number} from recurring template {recurring.name}")
# Auto-send if enabled
if recurring.auto_send and invoice.client_email:
try:
from app.utils.email import send_invoice_email
send_invoice_email(invoice, invoice.client_email, sender_user=recurring.creator)
emails_sent += 1
logger.info(f"Auto-sent invoice {invoice.invoice_number} to {invoice.client_email}")
except Exception as e:
logger.error(f"Failed to auto-send invoice {invoice.invoice_number}: {e}")
else:
logger.warning(f"Failed to generate invoice from recurring template {recurring.id}")
except Exception as e:
logger.error(f"Error processing recurring invoice {recurring.id}: {e}")
db.session.rollback()
logger.info(f"Generated {invoices_generated} invoices, sent {emails_sent} emails")
return invoices_generated
logger.info(f"Generated {invoices_generated} invoices, sent {emails_sent} emails")
return invoices_generated
except Exception as e:
logger.error(f"Error generating recurring invoices: {e}")
return 0
except Exception as e:
logger.error(f"Error generating recurring invoices: {e}")
return 0
def register_scheduled_tasks(scheduler):
def register_scheduled_tasks(scheduler, app=None):
"""Register all scheduled tasks with APScheduler
Args:
scheduler: APScheduler instance
app: Flask app instance (optional, will use current_app if not provided)
"""
try:
# Check overdue invoices daily at 9 AM
@@ -306,8 +311,29 @@ def register_scheduled_tasks(scheduler):
logger.info("Registered recurring invoices generation task")
# Retry failed webhook deliveries every 5 minutes
# Create a closure that captures the app instance
if app is None:
try:
app = current_app._get_current_object()
except RuntimeError:
logger.warning("Could not get app instance for webhook retry task")
app = None
def retry_failed_webhooks_with_app():
"""Wrapper that uses the captured app instance"""
app_instance = app
if app_instance is None:
try:
app_instance = current_app._get_current_object()
except RuntimeError:
logger.error("No app instance available for webhook retry")
return
with app_instance.app_context():
retry_failed_webhooks()
scheduler.add_job(
func=retry_failed_webhooks,
func=retry_failed_webhooks_with_app,
trigger='cron',
minute='*/5',
id='retry_failed_webhooks',
@@ -325,6 +351,9 @@ def retry_failed_webhooks():
This task should be run periodically to retry webhook deliveries
that have failed and are scheduled for retry.
Note: This function should be called within an app context.
Use retry_failed_webhooks_with_app() wrapper for scheduled tasks.
"""
try:
from app.utils.webhook_service import WebhookService

View File

@@ -30,19 +30,30 @@ def _get_authenticated_user(user=None):
def get_app_timezone():
"""Get the application's configured timezone from database settings or environment."""
try:
# Check if we have an application context before accessing database
from flask import has_app_context
if not has_app_context():
# No app context, skip database lookup
return os.getenv('TZ', 'Europe/Rome')
# Try to get timezone from database settings first
from app.models import Settings
from app import db
# Check if we have a database connection
if db.session.is_active and not getattr(db.session, "_flushing", False):
try:
settings = Settings.get_settings()
if settings and settings.timezone:
return settings.timezone
except Exception as e:
# Log the error but continue with fallback
print(f"Warning: Could not get timezone from database: {e}")
try:
if db.session.is_active and not getattr(db.session, "_flushing", False):
try:
settings = Settings.get_settings()
if settings and settings.timezone:
return settings.timezone
except Exception as e:
# Log the error but continue with fallback
print(f"Warning: Could not get timezone from database: {e}")
except RuntimeError as e:
# RuntimeError typically means "Working outside of application context"
# Fall back to environment variable
pass
except Exception as e:
# If database is not available or settings don't exist, fall back to environment
print(f"Warning: Database not available for timezone: {e}")

View File

@@ -7,7 +7,7 @@ from setuptools import setup, find_packages
setup(
name='timetracker',
version='3.10.2',
version='3.10.3',
packages=find_packages(),
include_package_data=True,
install_requires=[