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 += ` \n`; - bodyContent += ` \n`; - bodyContent += ` \n`; - bodyContent += ` \n`; + bodyContent += ` \n`; + bodyContent += ` \n`; + bodyContent += ` \n`; + bodyContent += ` \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 += `
DescriptionQtyUnit PriceTotal${(headerParts[0] || 'Description').replace(//g, '>')}${(headerParts[1] || 'Qty').replace(//g, '>')}${(headerParts[2] || 'Unit Price').replace(//g, '>')}${(headerParts[3] || 'Total').replace(//g, '>')}
\n`; bodyContent += ` \n`; bodyContent += ` \n`; - bodyContent += ` \n`; - bodyContent += ` \n`; - bodyContent += ` \n`; - bodyContent += ` \n`; + bodyContent += ` \n`; + bodyContent += ` \n`; + bodyContent += ` \n`; + bodyContent += ` \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;
ExpenseDateCategoryAmount${(headerParts[0] || 'Expense').replace(//g, '>')}${(headerParts[1] || 'Date').replace(//g, '>')}${(headerParts[2] || 'Category').replace(//g, '>')}${(headerParts[3] || 'Amount').replace(//g, '>')}