diff --git a/app/__init__.py b/app/__init__.py
index 611d4fea..80c945d2 100644
--- a/app/__init__.py
+++ b/app/__init__.py
@@ -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'):
diff --git a/app/static/enhanced-ui.css b/app/static/enhanced-ui.css
index bfcc20de..fa3e7f7f 100644
--- a/app/static/enhanced-ui.css
+++ b/app/static/enhanced-ui.css
@@ -662,8 +662,27 @@
.prose p, .prose-sm p { margin: 0.5rem 0; }
.prose a, .prose-sm a { color: #3B82F6; text-decoration: underline; }
.dark .prose a, .dark .prose-sm a { color: #60A5FA; }
-.prose ul, .prose ol, .prose-sm ul, .prose-sm ol { padding-left: 1.25rem; margin: 0.5rem 0; }
-.prose li, .prose-sm li { margin: 0.25rem 0; }
+.prose ul, .prose ol, .prose-sm ul, .prose-sm ol {
+ padding-left: 1.5rem;
+ margin: 0.75rem 0;
+ display: block;
+ list-style-position: outside;
+}
+.prose ul, .prose-sm ul {
+ list-style-type: disc;
+}
+.prose ol, .prose-sm ol {
+ list-style-type: decimal;
+}
+.prose li, .prose-sm li {
+ margin: 0.25rem 0;
+ display: list-item;
+}
+.prose ul ul, .prose ol ol, .prose ul ol, .prose ol ul,
+.prose-sm ul ul, .prose-sm ol ol, .prose-sm ul ol, .prose-sm ol ul {
+ margin-top: 0.25rem;
+ margin-bottom: 0.25rem;
+}
.prose code, .prose-sm code {
background: #F7F9FB;
color: #1F2937;
diff --git a/app/static/error-handling-enhanced.js b/app/static/error-handling-enhanced.js
index 7ffe90ba..1bd3a6fb 100644
--- a/app/static/error-handling-enhanced.js
+++ b/app/static/error-handling-enhanced.js
@@ -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
diff --git a/app/templates/base.html b/app/templates/base.html
index a734bee6..9731929f 100644
--- a/app/templates/base.html
+++ b/app/templates/base.html
@@ -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 %}
});
diff --git a/app/utils/scheduled_tasks.py b/app/utils/scheduled_tasks.py
index 0edd06b3..fa41db60 100644
--- a/app/utils/scheduled_tasks.py
+++ b/app/utils/scheduled_tasks.py
@@ -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
diff --git a/app/utils/template_filters.py b/app/utils/template_filters.py
index 082be76e..8e928199 100644
--- a/app/utils/template_filters.py
+++ b/app/utils/template_filters.py
@@ -75,9 +75,67 @@ def register_template_filters(app):
@app.template_filter('markdown')
def markdown_filter(text):
- """Render markdown to safe HTML using bleach sanitation."""
+ """Render markdown to safe HTML using bleach sanitation, preserving rich text styling."""
if not text:
return ""
+
+ # Check if text appears to be pure HTML (starts with < and looks like HTML document)
+ # Only treat as HTML if it starts with a tag and doesn't look like markdown
+ import re
+ # More specific check: HTML should start with a tag and not be markdown list/bullet syntax
+ is_html = (re.match(r'^\s*<[a-z]', text, re.IGNORECASE) and
+ not re.match(r'^\s*[-*+]\s+', text) and # Not markdown list
+ not re.match(r'^\s*\d+\.\s+', text)) # Not numbered list
+
+ if is_html:
+ if bleach is None:
+ try:
+ from markupsafe import escape
+ return escape(text)
+ except Exception:
+ return text
+ # Allow style attributes for rich text preservation
+ allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({
+ 'p', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'ul', 'ol', 'li',
+ 'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'blockquote', 'a', 'div', 'span',
+ 'sub', 'sup', 'del', 'ins', 'mark', 'small', 'big'
+ })
+ # Build allowed_attrs with style support for common rich text elements
+ allowed_attrs = {
+ **bleach.sanitizer.ALLOWED_ATTRIBUTES,
+ 'a': ['href', 'title', 'rel', 'target', 'style'],
+ 'img': ['src', 'alt', 'title', 'style', 'width', 'height'],
+ 'p': ['style', 'class', 'id'],
+ 'div': ['style', 'class', 'id'],
+ 'span': ['style', 'class', 'id'],
+ 'h1': ['style', 'class', 'id'],
+ 'h2': ['style', 'class', 'id'],
+ 'h3': ['style', 'class', 'id'],
+ 'h4': ['style', 'class', 'id'],
+ 'h5': ['style', 'class', 'id'],
+ 'h6': ['style', 'class', 'id'],
+ 'strong': ['style', 'class', 'id'],
+ 'em': ['style', 'class', 'id'],
+ 'b': ['style', 'class', 'id'],
+ 'i': ['style', 'class', 'id'],
+ 'u': ['style', 'class', 'id'],
+ 's': ['style', 'class', 'id'],
+ 'strike': ['style', 'class', 'id'],
+ 'blockquote': ['style', 'class', 'id'],
+ 'ul': ['style', 'class', 'id', 'type'],
+ 'ol': ['style', 'class', 'id', 'type', 'start'],
+ 'li': ['style', 'class', 'id'],
+ 'table': ['style', 'class', 'id'],
+ 'thead': ['style', 'class', 'id'],
+ 'tbody': ['style', 'class', 'id'],
+ 'tr': ['style', 'class', 'id'],
+ 'th': ['style', 'class', 'id'],
+ 'td': ['style', 'class', 'id'],
+ }
+ return bleach.clean(text, tags=allowed_tags, attributes=allowed_attrs, strip=True)
+
+ # Process as markdown
if _md is None:
# Fallback: escape and basic nl2br
try:
@@ -86,14 +144,49 @@ def register_template_filters(app):
return text
return escape(text).replace('\n', '
')
- html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty'])
+ # Convert markdown to HTML
+ html = _md.markdown(text, extensions=['extra', 'sane_lists', 'smarty', 'codehilite'])
if bleach is None:
return html
- allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({'p','pre','code','img','h1','h2','h3','h4','h5','h6','table','thead','tbody','tr','th','td','hr','br','ul','ol','li','strong','em','blockquote','a'})
+
+ # Sanitize the HTML output from markdown
+ allowed_tags = bleach.sanitizer.ALLOWED_TAGS.union({
+ 'p', 'pre', 'code', 'img', 'h1', 'h2', 'h3', 'h4', 'h5', 'h6',
+ 'table', 'thead', 'tbody', 'tr', 'th', 'td', 'hr', 'br', 'ul', 'ol', 'li',
+ 'strong', 'em', 'b', 'i', 'u', 's', 'strike', 'blockquote', 'a', 'div', 'span',
+ 'sub', 'sup', 'del', 'ins', 'mark', 'small', 'big'
+ })
+ # Build allowed_attrs with style support for common rich text elements
allowed_attrs = {
**bleach.sanitizer.ALLOWED_ATTRIBUTES,
- 'a': ['href', 'title', 'rel', 'target'],
- 'img': ['src', 'alt', 'title'],
+ 'a': ['href', 'title', 'rel', 'target', 'style'],
+ 'img': ['src', 'alt', 'title', 'style', 'width', 'height'],
+ 'p': ['style', 'class', 'id'],
+ 'div': ['style', 'class', 'id'],
+ 'span': ['style', 'class', 'id'],
+ 'h1': ['style', 'class', 'id'],
+ 'h2': ['style', 'class', 'id'],
+ 'h3': ['style', 'class', 'id'],
+ 'h4': ['style', 'class', 'id'],
+ 'h5': ['style', 'class', 'id'],
+ 'h6': ['style', 'class', 'id'],
+ 'strong': ['style', 'class', 'id'],
+ 'em': ['style', 'class', 'id'],
+ 'b': ['style', 'class', 'id'],
+ 'i': ['style', 'class', 'id'],
+ 'u': ['style', 'class', 'id'],
+ 's': ['style', 'class', 'id'],
+ 'strike': ['style', 'class', 'id'],
+ 'blockquote': ['style', 'class', 'id'],
+ 'ul': ['style', 'class', 'id', 'type'],
+ 'ol': ['style', 'class', 'id', 'type', 'start'],
+ 'li': ['style', 'class', 'id'],
+ 'table': ['style', 'class', 'id'],
+ 'thead': ['style', 'class', 'id'],
+ 'tbody': ['style', 'class', 'id'],
+ 'tr': ['style', 'class', 'id'],
+ 'th': ['style', 'class', 'id'],
+ 'td': ['style', 'class', 'id'],
}
return bleach.clean(html, tags=allowed_tags, attributes=allowed_attrs, strip=True)
diff --git a/app/utils/timezone.py b/app/utils/timezone.py
index 8713a3f8..1cd1eb03 100644
--- a/app/utils/timezone.py
+++ b/app/utils/timezone.py
@@ -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}")
diff --git a/setup.py b/setup.py
index 5217d374..fafecd35 100644
--- a/setup.py
+++ b/setup.py
@@ -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=[
diff --git a/templates/admin/pdf_layout.html b/templates/admin/pdf_layout.html
index 8390a510..fdfe6b2c 100644
--- a/templates/admin/pdf_layout.html
+++ b/templates/admin/pdf_layout.html
@@ -2482,8 +2482,23 @@ function initializePDFEditor() {
fd.append('css', css);
fd.append('csrf_token', CSRF_TOKEN);
- fetch(PREVIEW_URL, { method: 'POST', body: fd })
- .then(r => r.text())
+ // Add cache-busting parameter to ensure fresh preview
+ const previewUrl = PREVIEW_URL + '?t=' + Date.now();
+
+ fetch(previewUrl, {
+ method: 'POST',
+ body: fd,
+ cache: 'no-cache',
+ headers: {
+ 'Cache-Control': 'no-cache'
+ }
+ })
+ .then(r => {
+ if (!r.ok) {
+ throw new Error(`HTTP error! status: ${r.status}`);
+ }
+ return r.text();
+ })
.then(html => {
const doc = frame.contentDocument || frame.contentWindow.document;
doc.open();
@@ -2494,6 +2509,7 @@ function initializePDFEditor() {
})
.catch(err => {
loading.classList.remove('active');
+ console.error('Preview error:', err);
alert('Preview error: ' + err.message);
closePreviewModal();
});
@@ -2519,8 +2535,10 @@ function initializePDFEditor() {
const fontStyleCss = fontStyle === 'italic' ? 'italic' : 'normal';
const color = attrs.fill || 'black';
const text = (attrs.text || '').replace(//g, '>');
+ // Preserve text alignment (Konva Text uses 'align' attribute: 'left', 'center', 'right')
+ const textAlign = attrs.align || 'left';
- bodyContent += `
${text}
\n`;
+ bodyContent += ` ${text}
\n`;
} else if (child.className === 'Image') {
const w = Math.round(attrs.width || 100);
const h = Math.round(attrs.height || 50);
@@ -2577,16 +2595,32 @@ function initializePDFEditor() {
}
if (isItemsTable) {
+ // Extract actual header text from the table group's first Text child
+ const children = child.getChildren ? child.getChildren() : (child.children || []);
+ const textElements = children.filter(c => c.className === 'Text');
+ const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
+
+ // Parse header text (format: "Description | Qty | Price | Total" or German equivalent)
+ // Default to English if header text is empty or doesn't contain |
+ let headerParts = ['Description', 'Qty', 'Unit Price', 'Total'];
+ if (headerText && headerText.includes('|')) {
+ headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
+ // Ensure we have at least 4 parts, pad with defaults if needed
+ while (headerParts.length < 4) {
+ headerParts.push(['Description', 'Qty', 'Unit Price', 'Total'][headerParts.length]);
+ }
+ }
+
// Generate proper HTML table for invoice items
bodyContent += ` \n`;
bodyContent += ` \n`;
bodyContent += `
\n`;
bodyContent += ` \n`;
bodyContent += ` \n`;
- bodyContent += ` | Description | \n`;
- bodyContent += ` Qty | \n`;
- bodyContent += ` Unit Price | \n`;
- bodyContent += ` Total | \n`;
+ bodyContent += ` ${(headerParts[0] || 'Description').replace(//g, '>')} | \n`;
+ bodyContent += ` ${(headerParts[1] || 'Qty').replace(//g, '>')} | \n`;
+ bodyContent += ` ${(headerParts[2] || 'Unit Price').replace(//g, '>')} | \n`;
+ bodyContent += ` ${(headerParts[3] || 'Total').replace(//g, '>')} | \n`;
bodyContent += `
\n`;
bodyContent += ` \n`;
bodyContent += ` \n`;
@@ -2611,16 +2645,32 @@ function initializePDFEditor() {
bodyContent += ` \n`;
bodyContent += ` \n`;
} else if (isExpensesTable) {
+ // Extract actual header text from the table group's first Text child
+ const children = child.getChildren ? child.getChildren() : (child.children || []);
+ const textElements = children.filter(c => c.className === 'Text');
+ const headerText = textElements[0] ? (textElements[0].attrs.text || '') : '';
+
+ // Parse header text (format: "Expense | Date | Category | Amount" or localized)
+ // Default to English if header text is empty or doesn't contain |
+ let headerParts = ['Expense', 'Date', 'Category', 'Amount'];
+ if (headerText && headerText.includes('|')) {
+ headerParts = headerText.split('|').map(part => part.trim()).filter(part => part.length > 0);
+ // Ensure we have at least 4 parts, pad with defaults if needed
+ while (headerParts.length < 4) {
+ headerParts.push(['Expense', 'Date', 'Category', 'Amount'][headerParts.length]);
+ }
+ }
+
// Generate proper HTML table for project expenses
bodyContent += ` \n`;
bodyContent += ` \n`;
bodyContent += `
\n`;
bodyContent += ` \n`;
bodyContent += ` \n`;
- bodyContent += ` | Expense | \n`;
- bodyContent += ` Date | \n`;
- bodyContent += ` Category | \n`;
- bodyContent += ` Amount | \n`;
+ bodyContent += ` ${(headerParts[0] || 'Expense').replace(//g, '>')} | \n`;
+ bodyContent += ` ${(headerParts[1] || 'Date').replace(//g, '>')} | \n`;
+ bodyContent += ` ${(headerParts[2] || 'Category').replace(//g, '>')} | \n`;
+ bodyContent += ` ${(headerParts[3] || 'Amount').replace(//g, '>')} | \n`;
bodyContent += `
\n`;
bodyContent += ` \n`;
bodyContent += ` \n`;
@@ -2650,7 +2700,9 @@ function initializePDFEditor() {
child.children.forEach(c => {
if (c.className === 'Text') {
const text = (c.attrs.text || '').replace(//g, '>');
- bodyContent += ` ${text}
\n`;
+ // Preserve text alignment for text in groups
+ const textAlign = c.attrs.align || 'left';
+ bodyContent += ` ${text}
\n`;
} else if (c.className === 'Line') {
bodyContent += `
\n`;
}
@@ -2660,78 +2712,17 @@ function initializePDFEditor() {
}
});
- // Get dimensions for current page size
- const currentSizeHtml = CURRENT_PAGE_SIZE || 'A4';
- const dimensionsHtml = PAGE_SIZE_DIMENSIONS[currentSizeHtml] || PAGE_SIZE_DIMENSIONS['A4'];
- const widthPxHtml = dimensionsHtml.width;
- const heightPxHtml = dimensionsHtml.height;
-
- // Wrap in complete HTML document for proper PDF rendering
- const html = `
-
-
-
- Invoice
-
-
-
-
-${bodyContent}
-
-`;
-
// Get dimensions for current page size
const currentSize = CURRENT_PAGE_SIZE || 'A4';
const dimensions = PAGE_SIZE_DIMENSIONS[currentSize] || PAGE_SIZE_DIMENSIONS['A4'];
const widthPx = dimensions.width;
const heightPx = dimensions.height;
+ // For preview, return just the body content (template HTML) wrapped in invoice-wrapper
+ // The preview endpoint will wrap it with its own HTML structure
+ const html = `
+${bodyContent}
`;
+
const css = `@page {
size: ${currentSize};
margin: 0;