Files
TimeTracker/app/utils/excel_export.py
Dries Peeters b1973ca49a feat: Add Quick Wins feature set - activity tracking, templates, and user preferences
This commit introduces several high-impact features to improve user experience
and productivity:

New Features:
- Activity Logging: Comprehensive audit trail tracking user actions across the
  system with Activity model, including IP address and user agent tracking
- Time Entry Templates: Reusable templates for frequently logged activities with
  usage tracking and quick-start functionality
- Saved Filters: Save and reuse common search/filter combinations across
  different views (projects, tasks, reports)
- User Preferences: Enhanced user settings including email notifications,
  timezone, date/time formats, week start day, and theme preferences
- Excel Export: Generate formatted Excel exports for time entries and reports
  with styling and proper formatting
- Email Notifications: Complete email system for task assignments, overdue
  invoices, comments, and weekly summaries with HTML templates
- Scheduled Tasks: Background task scheduler for periodic operations

Models Added:
- Activity: Tracks all user actions with detailed context and metadata
- TimeEntryTemplate: Stores reusable time entry configurations
- SavedFilter: Manages user-saved filter configurations

Routes Added:
- user.py: User profile and settings management
- saved_filters.py: CRUD operations for saved filters
- time_entry_templates.py: Template management endpoints

UI Enhancements:
- Bulk actions widget component
- Keyboard shortcuts help modal with advanced shortcuts
- Save filter widget component
- Email notification templates
- User profile and settings pages
- Saved filters management interface
- Time entry templates interface

Database Changes:
- Migration 022: Creates activities and time_entry_templates tables
- Adds user preference columns (notifications, timezone, date/time formats)
- Proper indexes for query optimization

Backend Updates:
- Enhanced keyboard shortcuts system (commands.js, keyboard-shortcuts-advanced.js)
- Updated projects, reports, and tasks routes with activity logging
- Safe database commit utilities integration
- Event tracking for analytics

Dependencies:
- Added openpyxl for Excel generation
- Added Flask-Mail dependencies
- Updated requirements.txt

All new features include proper error handling, activity logging integration,
and maintain existing functionality while adding new capabilities.
2025-10-23 09:05:07 +02:00

299 lines
10 KiB
Python

"""Excel export utilities for reports and data export"""
import io
from datetime import datetime
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill, Border, Side
from openpyxl.utils import get_column_letter
def create_time_entries_excel(entries, filename_prefix='timetracker_export'):
"""Create Excel file from time entries
Args:
entries: List of TimeEntry objects
filename_prefix: Prefix for the filename
Returns:
tuple: (BytesIO object with Excel file, filename)
"""
# Create workbook
wb = Workbook()
ws = wb.active
ws.title = "Time Entries"
# Define styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Headers
headers = [
'ID', 'User', 'Project', 'Client', 'Task', 'Start Time', 'End Time',
'Duration (hours)', 'Duration (formatted)', 'Notes', 'Tags',
'Source', 'Billable', 'Created At'
]
# Write headers with styling
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=1, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
cell.border = border
# Write data
for row_num, entry in enumerate(entries, 2):
data = [
entry.id,
entry.user.display_name if entry.user else 'Unknown',
entry.project.name if entry.project else 'N/A',
entry.project.client.name if (entry.project and entry.project.client) else 'N/A',
entry.task.name if entry.task else 'N/A',
entry.start_time.isoformat() if entry.start_time else '',
entry.end_time.isoformat() if entry.end_time else '',
entry.duration_hours if entry.end_time else 0,
entry.duration_formatted if entry.end_time else 'In Progress',
entry.notes or '',
entry.tags or '',
entry.source or 'manual',
'Yes' if entry.billable else 'No',
entry.created_at.isoformat() if entry.created_at else ''
]
for col_num, value in enumerate(data, 1):
cell = ws.cell(row=row_num, column=col_num, value=value)
cell.border = border
# Format duration column as number
if col_num == 8 and isinstance(value, (int, float)):
cell.number_format = '0.00'
# Auto-adjust column widths
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50) # Cap at 50
ws.column_dimensions[column].width = adjusted_width
# Add summary at the bottom
last_row = len(entries) + 2
ws.cell(row=last_row + 1, column=1, value="Summary")
ws.cell(row=last_row + 1, column=1).font = Font(bold=True)
total_hours = sum(e.duration_hours for e in entries if e.end_time)
billable_hours = sum(e.duration_hours for e in entries if e.end_time and e.billable)
ws.cell(row=last_row + 2, column=1, value="Total Hours:")
ws.cell(row=last_row + 2, column=2, value=total_hours).number_format = '0.00'
ws.cell(row=last_row + 3, column=1, value="Billable Hours:")
ws.cell(row=last_row + 3, column=2, value=billable_hours).number_format = '0.00'
ws.cell(row=last_row + 4, column=1, value="Total Entries:")
ws.cell(row=last_row + 4, column=2, value=len(entries))
# Save to BytesIO
output = io.BytesIO()
wb.save(output)
output.seek(0)
# Generate filename
timestamp = datetime.now().strftime('%Y%m%d_%H%M%S')
filename = f'{filename_prefix}_{timestamp}.xlsx'
return output, filename
def create_project_report_excel(projects_data, start_date, end_date):
"""Create Excel file for project report
Args:
projects_data: List of project dictionaries with hours and costs
start_date: Report start date
end_date: Report end date
Returns:
tuple: (BytesIO object with Excel file, filename)
"""
wb = Workbook()
ws = wb.active
ws.title = "Project Report"
# Styles
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
border = Border(
left=Side(style='thin'),
right=Side(style='thin'),
top=Side(style='thin'),
bottom=Side(style='thin')
)
# Add report header
ws.merge_cells('A1:H1')
title_cell = ws['A1']
title_cell.value = f"Project Report: {start_date} to {end_date}"
title_cell.font = Font(bold=True, size=14)
title_cell.alignment = Alignment(horizontal="center")
# Column headers
headers = [
'Project', 'Client', 'Total Hours', 'Billable Hours',
'Hourly Rate', 'Billable Amount', 'Total Costs', 'Total Value'
]
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=3, column=col_num, value=header)
cell.font = header_font
cell.fill = header_fill
cell.border = border
# Write project data
for row_num, project in enumerate(projects_data, 4):
data = [
project.get('name', ''),
project.get('client', ''),
project.get('total_hours', 0),
project.get('billable_hours', 0),
project.get('hourly_rate', 0),
project.get('billable_amount', 0),
project.get('total_costs', 0),
project.get('total_value', 0),
]
for col_num, value in enumerate(data, 1):
cell = ws.cell(row=row_num, column=col_num, value=value)
cell.border = border
# Format numbers
if col_num in [3, 4]: # Hours
cell.number_format = '0.00'
elif col_num in [5, 6, 7, 8]: # Money
cell.number_format = '#,##0.00'
# Auto-adjust columns
for col in ws.columns:
max_length = 0
column = col[0].column_letter
for cell in col:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 40)
ws.column_dimensions[column].width = adjusted_width
# Add totals
last_row = len(projects_data) + 4
ws.cell(row=last_row + 1, column=1, value="TOTALS").font = Font(bold=True)
total_hours = sum(p.get('total_hours', 0) for p in projects_data)
total_billable_hours = sum(p.get('billable_hours', 0) for p in projects_data)
total_amount = sum(p.get('billable_amount', 0) for p in projects_data)
total_costs = sum(p.get('total_costs', 0) for p in projects_data)
total_value = sum(p.get('total_value', 0) for p in projects_data)
ws.cell(row=last_row + 1, column=3, value=total_hours).number_format = '0.00'
ws.cell(row=last_row + 1, column=4, value=total_billable_hours).number_format = '0.00'
ws.cell(row=last_row + 1, column=6, value=total_amount).number_format = '#,##0.00'
ws.cell(row=last_row + 1, column=7, value=total_costs).number_format = '#,##0.00'
ws.cell(row=last_row + 1, column=8, value=total_value).number_format = '#,##0.00'
# Save to BytesIO
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f'project_report_{start_date}_to_{end_date}.xlsx'
return output, filename
def create_invoice_excel(invoice, items):
"""Create Excel file for a single invoice
Args:
invoice: Invoice object
items: List of InvoiceItem objects
Returns:
tuple: (BytesIO object with Excel file, filename)
"""
wb = Workbook()
ws = wb.active
ws.title = "Invoice"
# Invoice header
ws.merge_cells('A1:D1')
ws['A1'] = f"INVOICE {invoice.invoice_number}"
ws['A1'].font = Font(bold=True, size=16)
ws['A1'].alignment = Alignment(horizontal="center")
# Invoice details
ws['A3'] = "Client:"
ws['B3'] = invoice.client_name
ws['A4'] = "Issue Date:"
ws['B4'] = invoice.issue_date.strftime('%Y-%m-%d')
ws['A5'] = "Due Date:"
ws['B5'] = invoice.due_date.strftime('%Y-%m-%d')
ws['A6'] = "Status:"
ws['B6'] = invoice.status.upper()
# Items header
headers = ['Description', 'Quantity', 'Unit Price', 'Amount']
for col_num, header in enumerate(headers, 1):
cell = ws.cell(row=8, column=col_num, value=header)
cell.font = Font(bold=True)
cell.fill = PatternFill(start_color="E7E6E6", end_color="E7E6E6", fill_type="solid")
# Items
row = 9
for item in items:
ws.cell(row=row, column=1, value=item.description)
ws.cell(row=row, column=2, value=item.quantity).number_format = '0.00'
ws.cell(row=row, column=3, value=float(item.unit_price)).number_format = '#,##0.00'
ws.cell(row=row, column=4, value=float(item.amount)).number_format = '#,##0.00'
row += 1
# Totals
row += 1
ws.cell(row=row, column=3, value="Subtotal:").font = Font(bold=True)
ws.cell(row=row, column=4, value=float(invoice.subtotal)).number_format = '#,##0.00'
row += 1
ws.cell(row=row, column=3, value=f"Tax ({invoice.tax_rate}%):").font = Font(bold=True)
ws.cell(row=row, column=4, value=float(invoice.tax_amount)).number_format = '#,##0.00'
row += 1
ws.cell(row=row, column=3, value="TOTAL:").font = Font(bold=True, size=12)
total_cell = ws.cell(row=row, column=4, value=float(invoice.total_amount))
total_cell.number_format = '#,##0.00'
total_cell.font = Font(bold=True, size=12)
total_cell.fill = PatternFill(start_color="FFFF00", end_color="FFFF00", fill_type="solid")
# Adjust columns
ws.column_dimensions['A'].width = 40
ws.column_dimensions['B'].width = 15
ws.column_dimensions['C'].width = 15
ws.column_dimensions['D'].width = 15
# Save
output = io.BytesIO()
wb.save(output)
output.seek(0)
filename = f'invoice_{invoice.invoice_number}.xlsx'
return output, filename