Add Export to CSV or Excel.
This commit is contained in:
110
app.py
110
app.py
@@ -1,8 +1,11 @@
|
|||||||
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash
|
from flask import Flask, render_template, request, redirect, url_for, jsonify, flash, send_file, Response
|
||||||
from models import db, TimeEntry, WorkConfig
|
from models import db, TimeEntry, WorkConfig
|
||||||
from datetime import datetime, time
|
|
||||||
import os
|
import os
|
||||||
from sqlalchemy import func
|
from sqlalchemy import func
|
||||||
|
import csv
|
||||||
|
import io
|
||||||
|
import pandas as pd
|
||||||
|
from datetime import datetime, time, timedelta
|
||||||
|
|
||||||
app = Flask(__name__)
|
app = Flask(__name__)
|
||||||
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db'
|
app.config['SQLALCHEMY_DATABASE_URI'] = 'sqlite:///timetrack.db'
|
||||||
@@ -292,5 +295,108 @@ def resume_entry(entry_id):
|
|||||||
'total_break_duration': entry_to_resume.total_break_duration
|
'total_break_duration': entry_to_resume.total_break_duration
|
||||||
})
|
})
|
||||||
|
|
||||||
|
@app.route('/export')
|
||||||
|
def export():
|
||||||
|
return render_template('export.html', title='Export Data')
|
||||||
|
|
||||||
|
@app.route('/download_export')
|
||||||
|
def download_export():
|
||||||
|
# Get parameters
|
||||||
|
export_format = request.args.get('format', 'csv')
|
||||||
|
period = request.args.get('period')
|
||||||
|
|
||||||
|
# Handle date range
|
||||||
|
if period:
|
||||||
|
# Quick export options
|
||||||
|
today = datetime.now().date()
|
||||||
|
if period == 'today':
|
||||||
|
start_date = today
|
||||||
|
end_date = today
|
||||||
|
elif period == 'week':
|
||||||
|
start_date = today - timedelta(days=today.weekday())
|
||||||
|
end_date = today
|
||||||
|
elif period == 'month':
|
||||||
|
start_date = today.replace(day=1)
|
||||||
|
end_date = today
|
||||||
|
elif period == 'all':
|
||||||
|
# Get the earliest entry date
|
||||||
|
earliest_entry = TimeEntry.query.order_by(TimeEntry.arrival_time).first()
|
||||||
|
start_date = earliest_entry.arrival_time.date() if earliest_entry else today
|
||||||
|
end_date = today
|
||||||
|
else:
|
||||||
|
# Custom date range
|
||||||
|
try:
|
||||||
|
start_date = datetime.strptime(request.args.get('start_date'), '%Y-%m-%d').date()
|
||||||
|
end_date = datetime.strptime(request.args.get('end_date'), '%Y-%m-%d').date()
|
||||||
|
except (ValueError, TypeError):
|
||||||
|
flash('Invalid date format. Please use YYYY-MM-DD format.')
|
||||||
|
return redirect(url_for('export'))
|
||||||
|
|
||||||
|
# Query entries within the date range
|
||||||
|
start_datetime = datetime.combine(start_date, time.min)
|
||||||
|
end_datetime = datetime.combine(end_date, time.max)
|
||||||
|
|
||||||
|
entries = TimeEntry.query.filter(
|
||||||
|
TimeEntry.arrival_time >= start_datetime,
|
||||||
|
TimeEntry.arrival_time <= end_datetime
|
||||||
|
).order_by(TimeEntry.arrival_time).all()
|
||||||
|
|
||||||
|
if not entries:
|
||||||
|
flash('No entries found for the selected date range.')
|
||||||
|
return redirect(url_for('export'))
|
||||||
|
|
||||||
|
# Prepare 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)': 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')}"
|
||||||
|
|
||||||
|
# Export based on format
|
||||||
|
if export_format == 'csv':
|
||||||
|
output = io.StringIO()
|
||||||
|
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':
|
||||||
|
# Convert to DataFrame and export as Excel
|
||||||
|
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"
|
||||||
|
)
|
||||||
|
|
||||||
if __name__ == '__main__':
|
if __name__ == '__main__':
|
||||||
app.run(debug=True)
|
app.run(debug=True)
|
||||||
@@ -6,4 +6,6 @@ itsdangerous==2.0.1
|
|||||||
click==8.0.1
|
click==8.0.1
|
||||||
Flask-SQLAlchemy==2.5.1
|
Flask-SQLAlchemy==2.5.1
|
||||||
SQLAlchemy==1.4.23
|
SQLAlchemy==1.4.23
|
||||||
python-dotenv==0.19.0
|
python-dotenv==0.19.0
|
||||||
|
pandas
|
||||||
|
xlsxwriter
|
||||||
@@ -69,19 +69,20 @@ main {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.form-group {
|
.form-group {
|
||||||
margin-bottom: 15px;
|
margin-bottom: 1rem;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group label {
|
.form-group label {
|
||||||
display: block;
|
display: block;
|
||||||
margin-bottom: 5px;
|
margin-bottom: 0.5rem;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
}
|
}
|
||||||
|
|
||||||
.form-group input,
|
.form-group input,
|
||||||
.form-group textarea {
|
.form-group textarea,
|
||||||
|
.form-group select {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
padding: 8px;
|
padding: 0.5rem;
|
||||||
border: 1px solid #ddd;
|
border: 1px solid #ddd;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
}
|
}
|
||||||
@@ -96,12 +97,16 @@ button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
.btn {
|
.btn {
|
||||||
padding: 8px 16px;
|
display: inline-block;
|
||||||
|
background-color: #4CAF50;
|
||||||
|
color: white;
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
text-decoration: none;
|
||||||
border: none;
|
border: none;
|
||||||
border-radius: 4px;
|
border-radius: 4px;
|
||||||
cursor: pointer;
|
cursor: pointer;
|
||||||
background-color: #4CAF50;
|
font-size: 1rem;
|
||||||
color: white;
|
text-align: center;
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn:hover {
|
.btn:hover {
|
||||||
@@ -413,4 +418,30 @@ input[type="time"]::-webkit-datetime-edit {
|
|||||||
color: #666;
|
color: #666;
|
||||||
margin-top: 4px;
|
margin-top: 4px;
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.export-options {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
|
||||||
|
gap: 2rem;
|
||||||
|
margin: 2rem 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section {
|
||||||
|
background-color: #f9f9f9;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 5px;
|
||||||
|
box-shadow: 0 2px 4px rgba(0,0,0,0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.export-section h3 {
|
||||||
|
color: #4CAF50;
|
||||||
|
margin-top: 0;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.quick-export-buttons {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|||||||
48
templates/export.html
Normal file
48
templates/export.html
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
{% extends "layout.html" %}
|
||||||
|
|
||||||
|
{% block content %}
|
||||||
|
<div class="timetrack-container">
|
||||||
|
<h2>Export Time Entries</h2>
|
||||||
|
|
||||||
|
<div class="export-options">
|
||||||
|
<div class="export-section">
|
||||||
|
<h3>Date Range</h3>
|
||||||
|
<form action="{{ url_for('download_export') }}" method="get">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="start_date">Start Date:</label>
|
||||||
|
<input type="date" id="start_date" name="start_date" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="end_date">End Date:</label>
|
||||||
|
<input type="date" id="end_date" name="end_date" required>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="format">Export Format:</label>
|
||||||
|
<select id="format" name="format">
|
||||||
|
<option value="csv">CSV</option>
|
||||||
|
<option value="excel">Excel</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button type="submit" class="btn">Generate Export</button>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="export-section">
|
||||||
|
<h3>Quick Export</h3>
|
||||||
|
<div class="quick-export-buttons">
|
||||||
|
<a href="{{ url_for('download_export', period='today', format='csv') }}" class="btn">Today (CSV)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='today', format='excel') }}" class="btn">Today (Excel)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='week', format='csv') }}" class="btn">This Week (CSV)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='week', format='excel') }}" class="btn">This Week (Excel)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='month', format='csv') }}" class="btn">This Month (CSV)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='month', format='excel') }}" class="btn">This Month (Excel)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='all', format='csv') }}" class="btn">All Time (CSV)</a>
|
||||||
|
<a href="{{ url_for('download_export', period='all', format='excel') }}" class="btn">All Time (Excel)</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{% endblock %}
|
||||||
@@ -3,6 +3,9 @@
|
|||||||
{% block content %}
|
{% block content %}
|
||||||
<div class="timetrack-container">
|
<div class="timetrack-container">
|
||||||
<h2>Complete Time Entry History</h2>
|
<h2>Complete Time Entry History</h2>
|
||||||
|
<div class="export-button-container">
|
||||||
|
<a href="{{ url_for('export') }}" class="btn">Export Data</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="history-section">
|
<div class="history-section">
|
||||||
{% if entries %}
|
{% if entries %}
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
<ul>
|
<ul>
|
||||||
<li><a href="{{ url_for('home') }}">Home</a></li>
|
<li><a href="{{ url_for('home') }}">Home</a></li>
|
||||||
<li><a href="{{ url_for('history') }}">Complete History</a></li>
|
<li><a href="{{ url_for('history') }}">Complete History</a></li>
|
||||||
|
<li><a href="{{ url_for('export') }}">Export Data</a></li>
|
||||||
<li><a href="{{ url_for('config') }}" {% if title == 'Configuration' %}class="active"{% endif %}>Configuration</a></li>
|
<li><a href="{{ url_for('config') }}" {% if title == 'Configuration' %}class="active"{% endif %}>Configuration</a></li>
|
||||||
<li><a href="{{ url_for('about') }}">About</a></li>
|
<li><a href="{{ url_for('about') }}">About</a></li>
|
||||||
</ul>
|
</ul>
|
||||||
|
|||||||
Reference in New Issue
Block a user