Refactor and enhance export functionality with Team Hours support

- Fix missing import statements for CSV/Excel export functionality
- Refactor export code into modular helper functions for better maintainability
- Add comprehensive Team Hours export feature with CSV and Excel support
- Enhance export UI styling with modern gradients and hover effects
- Add role-based access control for team export functionality
- Include date range filtering and team leader inclusion options
- Add proper error handling and user feedback for export operations
- Update dependencies to include pandas and xlsxwriter
- Fix JavaScript scope issues for export button functionality

🤖 Generated with [Claude Code](https://claude.ai/code)

Co-Authored-By: Claude <noreply@anthropic.com>
This commit is contained in:
Jens Luedicke
2025-06-29 16:24:55 +02:00
parent be2abbc008
commit 38a51c3eed
4 changed files with 483 additions and 72 deletions

366
app.py
View File

@@ -1,8 +1,11 @@
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, session, g, Response, send_file
from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role from models import db, TimeEntry, WorkConfig, User, SystemSettings, Team, Role
import logging import logging
from datetime import datetime, time, timedelta from datetime import datetime, time, timedelta
import os import os
import csv
import io
import pandas as pd
from sqlalchemy import func from sqlalchemy import func
from functools import wraps from functools import wraps
from flask_mail import Mail, Message from flask_mail import Mail, Message
@@ -1296,43 +1299,196 @@ def team_hours_data():
'start_date': start_date.strftime('%Y-%m-%d'), 'start_date': start_date.strftime('%Y-%m-%d'),
'end_date': end_date.strftime('%Y-%m-%d') 'end_date': end_date.strftime('%Y-%m-%d')
}) })
=======
@app.route('/export') @app.route('/export')
def export(): def export():
return render_template('export.html', title='Export Data') return render_template('export.html', title='Export Data')
@app.route('/download_export') def get_date_range(period, start_date_str=None, end_date_str=None):
def download_export(): """Get start and end date based on period or custom date range."""
# Get parameters today = datetime.now().date()
export_format = request.args.get('format', 'csv')
period = request.args.get('period')
# Handle date range
if period: if period:
# Quick export options
today = datetime.now().date()
if period == 'today': if period == 'today':
start_date = today return today, today
end_date = today
elif period == 'week': elif period == 'week':
start_date = today - timedelta(days=today.weekday()) start_date = today - timedelta(days=today.weekday())
end_date = today return start_date, today
elif period == 'month': elif period == 'month':
start_date = today.replace(day=1) start_date = today.replace(day=1)
end_date = today return start_date, today
elif period == 'all': elif period == 'all':
# Get the earliest entry date
earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first() earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first()
start_date = earliest_entry.arrival_time.date() if earliest_entry else today start_date = earliest_entry.arrival_time.date() if earliest_entry else today
end_date = today return start_date, today
else: else:
# Custom date range # Custom date range
try: try:
start_date = datetime.strptime(request.args.get('start_date'), '%Y-%m-%d').date() start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(request.args.get('end_date'), '%Y-%m-%d').date() end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
return start_date, end_date
except (ValueError, TypeError): except (ValueError, TypeError):
flash('Invalid date format. Please use YYYY-MM-DD format.') raise ValueError('Invalid date format')
return redirect(url_for('export'))
def format_duration(seconds):
"""Format duration in seconds to HH:MM:SS format."""
if seconds is None:
return '00:00:00'
hours = seconds // 3600
minutes = (seconds % 3600) // 60
seconds = seconds % 60
return f"{hours:d}:{minutes:02d}:{seconds:02d}"
def prepare_export_data(entries):
"""Prepare time entries data for export."""
data = []
for entry in entries:
row = {
'Date': entry.arrival_time.strftime('%Y-%m-%d'),
'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'),
'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active',
'Work Duration (HH:MM:SS)': format_duration(entry.duration) if entry.duration is not None else 'In progress',
'Break Duration (HH:MM:SS)': format_duration(entry.total_break_duration),
'Work Duration (seconds)': entry.duration if entry.duration is not None else 0,
'Break Duration (seconds)': entry.total_break_duration if entry.total_break_duration is not None else 0
}
data.append(row)
return data
def export_to_csv(data, filename):
"""Export data to CSV format."""
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment;filename={filename}.csv'}
)
def export_to_excel(data, filename):
"""Export data to Excel format with formatting."""
df = pd.DataFrame(data)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name='TimeTrack Data', index=False)
# Auto-adjust columns' width
worksheet = writer.sheets['TimeTrack Data']
for i, col in enumerate(df.columns):
column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2
worksheet.set_column(i, i, column_width)
output.seek(0)
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=f"{filename}.xlsx"
)
def prepare_team_hours_export_data(team, team_data, date_range):
"""Prepare team hours data for export."""
export_data = []
for member_data in team_data:
user = member_data['user']
daily_hours = member_data['daily_hours']
# Create base row with member info
row = {
'Team': team['name'],
'Member': user['username'],
'Email': user['email'],
'Total Hours': member_data['total_hours']
}
# Add daily hours columns
for date_str in date_range:
formatted_date = datetime.strptime(date_str, '%Y-%m-%d').strftime('%m/%d/%Y')
row[formatted_date] = daily_hours.get(date_str, 0.0)
export_data.append(row)
return export_data
def export_team_hours_to_csv(data, filename):
"""Export team hours data to CSV format."""
if not data:
return None
output = io.StringIO()
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
return Response(
output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment;filename={filename}.csv'}
)
def export_team_hours_to_excel(data, filename, team_name):
"""Export team hours data to Excel format with formatting."""
if not data:
return None
df = pd.DataFrame(data)
output = io.BytesIO()
with pd.ExcelWriter(output, engine='xlsxwriter') as writer:
df.to_excel(writer, sheet_name=f'{team_name} Hours', index=False)
# Get the workbook and worksheet objects
workbook = writer.book
worksheet = writer.sheets[f'{team_name} Hours']
# Create formats
header_format = workbook.add_format({
'bold': True,
'text_wrap': True,
'valign': 'top',
'fg_color': '#4CAF50',
'font_color': 'white',
'border': 1
})
# Auto-adjust columns' width and apply formatting
for i, col in enumerate(df.columns):
column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2
worksheet.set_column(i, i, column_width)
# Apply header formatting
worksheet.write(0, i, col, header_format)
output.seek(0)
return send_file(
output,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet',
as_attachment=True,
download_name=f"{filename}.xlsx"
)
@app.route('/download_export')
def download_export():
"""Handle export download requests."""
export_format = request.args.get('format', 'csv')
period = request.args.get('period')
try:
start_date, end_date = get_date_range(
period,
request.args.get('start_date'),
request.args.get('end_date')
)
except ValueError:
flash('Invalid date format. Please use YYYY-MM-DD format.')
return redirect(url_for('export'))
# Query entries within the date range # Query entries within the date range
start_datetime = datetime.combine(start_date, time.min) start_datetime = datetime.combine(start_date, time.min)
@@ -1347,58 +1503,140 @@ def download_export():
flash('No entries found for the selected date range.') flash('No entries found for the selected date range.')
return redirect(url_for('export')) return redirect(url_for('export'))
# Prepare data for export # Prepare data and filename
data = [] data = prepare_export_data(entries)
for entry in entries:
row = {
'Date': entry.arrival_time.strftime('%Y-%m-%d'),
'Arrival Time': entry.arrival_time.strftime('%H:%M:%S'),
'Departure Time': entry.departure_time.strftime('%H:%M:%S') if entry.departure_time else 'Active',
'Work Duration (HH:MM:SS)': f"{entry.duration//3600:d}:{(entry.duration%3600)//60:02d}:{entry.duration%60:02d}" if entry.duration is not None else 'In progress',
'Break Duration (HH:MM:SS)': f"{entry.total_break_duration//3600:d}:{(entry.total_break_duration%3600)//60:02d}:{entry.total_break_duration%60:02d}" if entry.total_break_duration is not None else '00:00:00',
'Work Duration (seconds)': entry.duration if entry.duration is not None else 0,
'Break Duration (seconds)': entry.total_break_duration if entry.total_break_duration is not None else 0
}
data.append(row)
# Generate filename
filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}" filename = f"timetrack_export_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
# Export based on format # Export based on format
if export_format == 'csv': if export_format == 'csv':
output = io.StringIO() return export_to_csv(data, filename)
writer = csv.DictWriter(output, fieldnames=data[0].keys())
writer.writeheader()
writer.writerows(data)
response = Response(
output.getvalue(),
mimetype='text/csv',
headers={'Content-Disposition': f'attachment;filename={filename}.csv'}
)
return response
elif export_format == 'excel': elif export_format == 'excel':
# Convert to DataFrame and export as Excel return export_to_excel(data, filename)
df = pd.DataFrame(data) else:
output = io.BytesIO() flash('Invalid export format.')
with pd.ExcelWriter(output, engine='xlsxwriter') as writer: return redirect(url_for('export'))
df.to_excel(writer, sheet_name='TimeTrack Data', index=False)
# Auto-adjust columns' width @app.route('/download_team_hours_export')
worksheet = writer.sheets['TimeTrack Data'] @login_required
for i, col in enumerate(df.columns): @role_required(Role.TEAM_LEADER)
column_width = max(df[col].astype(str).map(len).max(), len(col)) + 2 def download_team_hours_export():
worksheet.set_column(i, i, column_width) """Handle team hours export download requests."""
export_format = request.args.get('format', 'csv')
output.seek(0) # Get the current user's team
team = Team.query.get(g.user.team_id)
return send_file( if not team:
output, flash('You are not assigned to any team.')
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet', return redirect(url_for('team_hours'))
as_attachment=True,
download_name=f"{filename}.xlsx" # Get date range from query parameters or use current week as default
) today = datetime.now().date()
start_of_week = today - timedelta(days=today.weekday())
end_of_week = start_of_week + timedelta(days=6)
start_date_str = request.args.get('start_date', start_of_week.strftime('%Y-%m-%d'))
end_date_str = request.args.get('end_date', end_of_week.strftime('%Y-%m-%d'))
include_self = request.args.get('include_self', 'false') == 'true'
try:
start_date = datetime.strptime(start_date_str, '%Y-%m-%d').date()
end_date = datetime.strptime(end_date_str, '%Y-%m-%d').date()
except ValueError:
flash('Invalid date format.')
return redirect(url_for('team_hours'))
# Get all team members
team_members = User.query.filter_by(team_id=team.id).all()
# Prepare data structure for team members' hours
team_data = []
for member in team_members:
# Skip if the member is the current user (team leader) and include_self is False
if member.id == g.user.id and not include_self:
continue
# Get time entries for this member in the date range
entries = TimeEntry.query.filter(
TimeEntry.user_id == member.id,
TimeEntry.arrival_time >= datetime.combine(start_date, time.min),
TimeEntry.arrival_time <= datetime.combine(end_date, time.max)
).order_by(TimeEntry.arrival_time).all()
# Calculate daily and total hours
daily_hours = {}
total_seconds = 0
for entry in entries:
if entry.duration: # Only count completed entries
entry_date = entry.arrival_time.date()
date_str = entry_date.strftime('%Y-%m-%d')
if date_str not in daily_hours:
daily_hours[date_str] = 0
daily_hours[date_str] += entry.duration
total_seconds += entry.duration
# Convert seconds to hours for display
for date_str in daily_hours:
daily_hours[date_str] = round(daily_hours[date_str] / 3600, 2) # Convert to hours
total_hours = round(total_seconds / 3600, 2) # Convert to hours
# Add member data to team data
team_data.append({
'user': {
'id': member.id,
'username': member.username,
'email': member.email
},
'daily_hours': daily_hours,
'total_hours': total_hours
})
if not team_data:
flash('No team member data found for the selected date range.')
return redirect(url_for('team_hours'))
# Generate a list of dates in the range
date_range = []
current_date = start_date
while current_date <= end_date:
date_range.append(current_date.strftime('%Y-%m-%d'))
current_date += timedelta(days=1)
# Prepare data for export
team_info = {
'id': team.id,
'name': team.name,
'description': team.description
}
export_data = prepare_team_hours_export_data(team_info, team_data, date_range)
# Generate filename
filename = f"{team.name.replace(' ', '_')}_hours_{start_date.strftime('%Y%m%d')}_to_{end_date.strftime('%Y%m%d')}"
# Export based on format
if export_format == 'csv':
response = export_team_hours_to_csv(export_data, filename)
if response:
return response
else:
flash('Error generating CSV export.')
return redirect(url_for('team_hours'))
elif export_format == 'excel':
response = export_team_hours_to_excel(export_data, filename, team.name)
if response:
return response
else:
flash('Error generating Excel export.')
return redirect(url_for('team_hours'))
else:
flash('Invalid export format.')
return redirect(url_for('team_hours'))
if __name__ == '__main__': if __name__ == '__main__':
app.run(debug=True) app.run(debug=True)

View File

@@ -9,3 +9,6 @@ SQLAlchemy==1.4.23
python-dotenv==0.19.0 python-dotenv==0.19.0
pyotp==2.6.0 pyotp==2.6.0
qrcode[pil]==7.3.1 qrcode[pil]==7.3.1
pandas==1.5.3
xlsxwriter==3.1.2
Flask-Mail==0.9.1

View File

@@ -708,29 +708,146 @@ input[type="time"]::-webkit-datetime-edit {
align-items: center; align-items: center;
gap: 5px; gap: 5px;
} }
=======
/* Export Page Styling */
.export-options { .export-options {
display: grid; display: grid;
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr)); grid-template-columns: repeat(auto-fit, minmax(320px, 1fr));
gap: 2rem; gap: 2rem;
margin: 2rem 0; margin: 2rem 0;
} }
.export-section { .export-section {
background-color: #f9f9f9; background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 1.5rem; padding: 2rem;
border-radius: 5px; border-radius: 12px;
box-shadow: 0 2px 4px rgba(0,0,0,0.1); box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
border: 1px solid #e9ecef;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.export-section:hover {
transform: translateY(-2px);
box-shadow: 0 6px 20px rgba(0, 0, 0, 0.15);
} }
.export-section h3 { .export-section h3 {
color: #4CAF50; color: #4CAF50;
margin-top: 0; margin-top: 0;
margin-bottom: 1rem; margin-bottom: 1.5rem;
font-size: 1.3rem;
font-weight: 600;
border-bottom: 2px solid #4CAF50;
padding-bottom: 0.5rem;
} }
.quick-export-buttons { .quick-export-buttons {
display: grid; display: grid;
grid-template-columns: 1fr 1fr; grid-template-columns: 1fr 1fr;
gap: 0.5rem; gap: 0.75rem;
margin-top: 1rem;
}
.quick-export-buttons .btn {
padding: 0.75rem 1rem;
font-size: 0.9rem;
border-radius: 8px;
transition: all 0.2s ease;
font-weight: 500;
}
.quick-export-buttons .btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.3);
}
.export-button-container {
text-align: center;
margin: 2rem 0;
}
.export-button-container .btn {
background: linear-gradient(135deg, #4CAF50 0%, #45a049 100%);
color: white;
padding: 1rem 2rem;
font-size: 1.1rem;
border-radius: 8px;
text-decoration: none;
display: inline-block;
transition: all 0.2s ease;
font-weight: 600;
box-shadow: 0 2px 10px rgba(76, 175, 80, 0.3);
}
.export-button-container .btn:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(76, 175, 80, 0.4);
background: linear-gradient(135deg, #45a049 0%, #4CAF50 100%);
}
/* Custom date range form styling */
.export-section .form-group {
margin-bottom: 1rem;
}
.export-section .form-group label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #333;
}
.export-section .form-group input,
.export-section .form-group select {
width: 100%;
padding: 0.75rem;
border: 2px solid #e9ecef;
border-radius: 6px;
font-size: 1rem;
transition: border-color 0.2s ease;
}
.export-section .form-group input:focus,
.export-section .form-group select:focus {
outline: none;
border-color: #4CAF50;
box-shadow: 0 0 0 3px rgba(76, 175, 80, 0.1);
}
/* Team Hours Export Styling */
#export-buttons {
background: linear-gradient(135deg, #f8f9fa 0%, #e9ecef 100%);
padding: 1.5rem;
border-radius: 12px;
box-shadow: 0 4px 15px rgba(0, 0, 0, 0.1);
border: 1px solid #e9ecef;
margin: 2rem 0;
text-align: center;
}
#export-buttons h4 {
color: #4CAF50;
margin-bottom: 1rem;
font-size: 1.2rem;
font-weight: 600;
}
#export-buttons .quick-export-buttons {
display: flex;
gap: 1rem;
justify-content: center;
}
#export-buttons .quick-export-buttons .btn {
padding: 0.75rem 1.5rem;
font-size: 1rem;
border-radius: 8px;
transition: all 0.2s ease;
font-weight: 500;
min-width: 140px;
}
#export-buttons .quick-export-buttons .btn:hover {
transform: translateY(-1px);
box-shadow: 0 3px 10px rgba(76, 175, 80, 0.3);
} }

View File

@@ -23,6 +23,15 @@
</form> </form>
</div> </div>
<!-- Export Buttons -->
<div class="export-button-container" id="export-buttons" style="display: none;">
<h4>Export Team Hours</h4>
<div class="quick-export-buttons">
<button class="btn" onclick="exportTeamHours('csv')">Export as CSV</button>
<button class="btn" onclick="exportTeamHours('excel')">Export as Excel</button>
</div>
</div>
<div class="team-hours-container"> <div class="team-hours-container">
<div id="loading">Loading team data...</div> <div id="loading">Loading team data...</div>
<div id="team-info" style="display: none;"> <div id="team-info" style="display: none;">
@@ -171,6 +180,7 @@
// Populate detailed entries // Populate detailed entries
document.getElementById('team-hours-table').style.display = 'block'; document.getElementById('team-hours-table').style.display = 'block';
document.getElementById('export-buttons').style.display = 'block';
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
} }
@@ -179,6 +189,49 @@
document.getElementById('error-message').style.display = 'block'; document.getElementById('error-message').style.display = 'block';
document.getElementById('loading').style.display = 'none'; document.getElementById('loading').style.display = 'none';
} }
}); });
// Export function (global scope)
function exportTeamHours(format) {
console.log('Export function called with format:', format);
try {
// Get current filter values
const startDate = document.getElementById('start-date').value;
const endDate = document.getElementById('end-date').value;
const includeSelf = document.getElementById('include-self').checked;
console.log('Filter values:', { startDate, endDate, includeSelf });
// Validate required fields
if (!startDate || !endDate) {
alert('Please select both start and end dates before exporting.');
return;
}
// Build export URL with query parameters
const exportUrl = `/download_team_hours_export?format=${format}&start_date=${startDate}&end_date=${endDate}&include_self=${includeSelf}`;
console.log('Export URL:', exportUrl);
// Show loading indicator
const exportButtons = document.getElementById('export-buttons');
const originalHTML = exportButtons.innerHTML;
exportButtons.innerHTML = '<h4>Generating export...</h4><p>Please wait...</p>';
// Trigger download
window.location.href = exportUrl;
// Restore buttons after a short delay
setTimeout(() => {
exportButtons.innerHTML = originalHTML;
}, 2000);
} catch (error) {
console.error('Error in exportTeamHours:', error);
alert('An error occurred while trying to export. Please try again.');
}
}
</script> </script>
{% endblock %} {% endblock %}