mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-08 12:40:38 -06:00
- share a centralized timezone list across admin and user settings - allow admins to pick from the same list when setting the system default - let users clear their personal override to fall back to the global default - add regression tests covering the new helper and reset path
534 lines
20 KiB
Python
534 lines
20 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
|
|
from app.utils.timezone import convert_app_datetime_to_user
|
|
|
|
|
|
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 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_idx, col in enumerate(ws.columns, 1):
|
|
max_length = 0
|
|
# Get column letter - use column index (1-based) to avoid MergedCell issues
|
|
column = get_column_letter(col_idx)
|
|
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_idx, col in enumerate(ws.columns, 1):
|
|
max_length = 0
|
|
# Get column letter - use column index (1-based) to avoid MergedCell issues
|
|
# MergedCell objects don't have column_letter attribute
|
|
column = get_column_letter(col_idx)
|
|
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
|
|
|
|
|
|
def create_invoices_list_excel(invoices):
|
|
"""Create Excel file for invoice list
|
|
|
|
Args:
|
|
invoices: List of Invoice objects
|
|
|
|
Returns:
|
|
tuple: (BytesIO object with Excel file, filename)
|
|
"""
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Invoices"
|
|
|
|
# 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 = [
|
|
'Invoice Number', 'Client Name', 'Project', 'Issue Date', 'Due Date',
|
|
'Status', 'Payment Status', 'Subtotal', 'Tax Rate (%)', 'Tax Amount',
|
|
'Total Amount', 'Amount Paid', 'Outstanding', 'Currency', 'Created By', '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, invoice in enumerate(invoices, 2):
|
|
data = [
|
|
invoice.invoice_number,
|
|
invoice.client_name or 'N/A',
|
|
invoice.project.name if invoice.project else 'N/A',
|
|
invoice.issue_date.strftime('%Y-%m-%d') if invoice.issue_date else '',
|
|
invoice.due_date.strftime('%Y-%m-%d') if invoice.due_date else '',
|
|
invoice.status or 'draft',
|
|
invoice.payment_status or 'unpaid',
|
|
float(invoice.subtotal or 0),
|
|
float(invoice.tax_rate or 0),
|
|
float(invoice.tax_amount or 0),
|
|
float(invoice.total_amount or 0),
|
|
float(invoice.amount_paid or 0),
|
|
float(invoice.outstanding_amount or 0),
|
|
invoice.currency_code or 'USD',
|
|
invoice.creator.display_name if invoice.creator else 'Unknown',
|
|
(convert_app_datetime_to_user(invoice.created_at).strftime('%Y-%m-%d %H:%M') if invoice.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 number columns
|
|
if col_num in [8, 10, 11, 12, 13]: # Money columns
|
|
if isinstance(value, (int, float)):
|
|
cell.number_format = '#,##0.00'
|
|
elif col_num == 9: # Tax rate percentage
|
|
if isinstance(value, (int, float)):
|
|
cell.number_format = '0.00'
|
|
|
|
# Auto-adjust column widths
|
|
for col_idx, col in enumerate(ws.columns, 1):
|
|
max_length = 0
|
|
# Get column letter - use column index (1-based) to avoid MergedCell issues
|
|
column = get_column_letter(col_idx)
|
|
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(invoices) + 2
|
|
ws.cell(row=last_row + 1, column=1, value="Summary")
|
|
ws.cell(row=last_row + 1, column=1).font = Font(bold=True)
|
|
|
|
total_invoiced = sum(float(inv.total_amount or 0) for inv in invoices)
|
|
total_paid = sum(float(inv.amount_paid or 0) for inv in invoices)
|
|
total_outstanding = sum(float(inv.outstanding_amount or 0) for inv in invoices)
|
|
|
|
ws.cell(row=last_row + 2, column=1, value="Total Invoiced:")
|
|
ws.cell(row=last_row + 2, column=2, value=total_invoiced).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 3, column=1, value="Total Paid:")
|
|
ws.cell(row=last_row + 3, column=2, value=total_paid).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 4, column=1, value="Total Outstanding:")
|
|
ws.cell(row=last_row + 4, column=2, value=total_outstanding).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 5, column=1, value="Total Invoices:")
|
|
ws.cell(row=last_row + 5, column=2, value=len(invoices))
|
|
|
|
# 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'invoices_list_{timestamp}.xlsx'
|
|
|
|
return output, filename
|
|
|
|
|
|
def create_payments_list_excel(payments):
|
|
"""Create Excel file for payment list
|
|
|
|
Args:
|
|
payments: List of Payment objects
|
|
|
|
Returns:
|
|
tuple: (BytesIO object with Excel file, filename)
|
|
"""
|
|
wb = Workbook()
|
|
ws = wb.active
|
|
ws.title = "Payments"
|
|
|
|
# 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 = [
|
|
'Payment ID', 'Invoice Number', 'Client Name', 'Amount', 'Currency',
|
|
'Gateway Fee', 'Net Amount', 'Payment Date', 'Method', 'Reference',
|
|
'Status', 'Received By', 'Gateway Transaction ID', 'Notes', '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, payment in enumerate(payments, 2):
|
|
data = [
|
|
payment.id,
|
|
payment.invoice.invoice_number if payment.invoice else 'N/A',
|
|
payment.invoice.client_name if payment.invoice else 'N/A',
|
|
float(payment.amount or 0),
|
|
payment.currency or 'EUR',
|
|
float(payment.gateway_fee or 0),
|
|
float(payment.net_amount or payment.amount or 0),
|
|
payment.payment_date.strftime('%Y-%m-%d') if payment.payment_date else '',
|
|
payment.method or 'N/A',
|
|
payment.reference or '',
|
|
payment.status or 'completed',
|
|
payment.receiver.display_name if payment.receiver else 'N/A',
|
|
payment.gateway_transaction_id or '',
|
|
payment.notes or '',
|
|
(convert_app_datetime_to_user(payment.created_at).strftime('%Y-%m-%d %H:%M') if payment.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 number columns
|
|
if col_num in [4, 6, 7]: # Money columns
|
|
if isinstance(value, (int, float)):
|
|
cell.number_format = '#,##0.00'
|
|
|
|
# Auto-adjust column widths
|
|
for col_idx, col in enumerate(ws.columns, 1):
|
|
max_length = 0
|
|
# Get column letter - use column index (1-based) to avoid MergedCell issues
|
|
column = get_column_letter(col_idx)
|
|
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(payments) + 2
|
|
ws.cell(row=last_row + 1, column=1, value="Summary")
|
|
ws.cell(row=last_row + 1, column=1).font = Font(bold=True)
|
|
|
|
total_amount = sum(float(p.amount or 0) for p in payments)
|
|
total_fees = sum(float(p.gateway_fee or 0) for p in payments if p.gateway_fee)
|
|
total_net = sum(float(p.net_amount or p.amount or 0) for p in payments)
|
|
completed_count = sum(1 for p in payments if p.status == 'completed')
|
|
|
|
ws.cell(row=last_row + 2, column=1, value="Total Amount:")
|
|
ws.cell(row=last_row + 2, column=2, value=total_amount).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 3, column=1, value="Total Gateway Fees:")
|
|
ws.cell(row=last_row + 3, column=2, value=total_fees).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 4, column=1, value="Total Net Amount:")
|
|
ws.cell(row=last_row + 4, column=2, value=total_net).number_format = '#,##0.00'
|
|
ws.cell(row=last_row + 5, column=1, value="Total Payments:")
|
|
ws.cell(row=last_row + 5, column=2, value=len(payments))
|
|
ws.cell(row=last_row + 6, column=1, value="Completed Payments:")
|
|
ws.cell(row=last_row + 6, column=2, value=completed_count)
|
|
|
|
# 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'payments_list_{timestamp}.xlsx'
|
|
|
|
return output, filename
|
|
|