mirror of
https://github.com/DRYTRIX/TimeTracker.git
synced 2026-01-06 11:40:52 -06:00
465 lines
16 KiB
Python
465 lines
16 KiB
Python
from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file
|
|
from flask_login import login_required, current_user
|
|
from app import db
|
|
from app.models import User, Project, TimeEntry, Settings, Task
|
|
from datetime import datetime, timedelta
|
|
import csv
|
|
import io
|
|
import pytz
|
|
|
|
reports_bp = Blueprint('reports', __name__)
|
|
|
|
@reports_bp.route('/reports')
|
|
@login_required
|
|
def reports():
|
|
"""Main reports page"""
|
|
# Aggregate totals (scope by user unless admin)
|
|
totals_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter(
|
|
TimeEntry.end_time.isnot(None)
|
|
)
|
|
billable_query = db.session.query(db.func.sum(TimeEntry.duration_seconds)).filter(
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.billable == True
|
|
)
|
|
|
|
entries_query = TimeEntry.query.filter(TimeEntry.end_time.isnot(None))
|
|
|
|
if not current_user.is_admin:
|
|
totals_query = totals_query.filter(TimeEntry.user_id == current_user.id)
|
|
billable_query = billable_query.filter(TimeEntry.user_id == current_user.id)
|
|
entries_query = entries_query.filter(TimeEntry.user_id == current_user.id)
|
|
|
|
total_seconds = totals_query.scalar() or 0
|
|
billable_seconds = billable_query.scalar() or 0
|
|
|
|
summary = {
|
|
'total_hours': round(total_seconds / 3600, 2),
|
|
'billable_hours': round(billable_seconds / 3600, 2),
|
|
'active_projects': Project.query.filter_by(status='active').count(),
|
|
'total_users': User.query.filter_by(is_active=True).count(),
|
|
}
|
|
|
|
recent_entries = entries_query.order_by(TimeEntry.start_time.desc()).limit(10).all()
|
|
|
|
return render_template('reports/index.html', summary=summary, recent_entries=recent_entries)
|
|
|
|
@reports_bp.route('/reports/project')
|
|
@login_required
|
|
def project_report():
|
|
"""Project-based time report"""
|
|
project_id = request.args.get('project_id', type=int)
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
user_id = request.args.get('user_id', type=int)
|
|
|
|
# Get projects for filter
|
|
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
|
users = User.query.filter_by(is_active=True).order_by(User.username).all()
|
|
|
|
# Parse dates
|
|
if not start_date:
|
|
start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
|
|
if not end_date:
|
|
end_date = datetime.utcnow().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1)
|
|
except ValueError:
|
|
flash('Invalid date format', 'error')
|
|
return render_template('reports/project_report.html', projects=projects, users=users)
|
|
|
|
# Get time entries
|
|
query = TimeEntry.query.filter(
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_dt,
|
|
TimeEntry.start_time <= end_dt
|
|
)
|
|
|
|
if project_id:
|
|
query = query.filter(TimeEntry.project_id == project_id)
|
|
|
|
if user_id:
|
|
query = query.filter(TimeEntry.user_id == user_id)
|
|
|
|
entries = query.order_by(TimeEntry.start_time.desc()).all()
|
|
|
|
# Aggregate by project for template expectations
|
|
projects_map = {}
|
|
for entry in entries:
|
|
project = entry.project
|
|
if not project:
|
|
continue
|
|
if project.id not in projects_map:
|
|
projects_map[project.id] = {
|
|
'id': project.id,
|
|
'name': project.name,
|
|
'client': project.client,
|
|
'description': project.description,
|
|
'billable': project.billable,
|
|
'hourly_rate': float(project.hourly_rate) if project.hourly_rate else None,
|
|
'total_hours': 0.0,
|
|
'billable_hours': 0.0,
|
|
'billable_amount': 0.0,
|
|
'user_totals': {}
|
|
}
|
|
agg = projects_map[project.id]
|
|
hours = entry.duration_hours
|
|
agg['total_hours'] += hours
|
|
if entry.billable and project.billable:
|
|
agg['billable_hours'] += hours
|
|
if project.hourly_rate:
|
|
agg['billable_amount'] += hours * float(project.hourly_rate)
|
|
# per-user totals
|
|
username = entry.user.display_name if entry.user else 'Unknown'
|
|
agg['user_totals'][username] = agg['user_totals'].get(username, 0.0) + hours
|
|
|
|
# Finalize structures
|
|
projects_data = []
|
|
total_hours = 0.0
|
|
billable_hours = 0.0
|
|
total_billable_amount = 0.0
|
|
for agg in projects_map.values():
|
|
total_hours += agg['total_hours']
|
|
billable_hours += agg['billable_hours']
|
|
total_billable_amount += agg['billable_amount']
|
|
agg['total_hours'] = round(agg['total_hours'], 1)
|
|
agg['billable_hours'] = round(agg['billable_hours'], 1)
|
|
agg['billable_amount'] = round(agg['billable_amount'], 2)
|
|
agg['user_totals'] = [
|
|
{'username': username, 'hours': round(hours, 1)}
|
|
for username, hours in agg['user_totals'].items()
|
|
]
|
|
projects_data.append(agg)
|
|
|
|
# Summary section expected by template
|
|
summary = {
|
|
'total_hours': round(total_hours, 1),
|
|
'billable_hours': round(billable_hours, 1),
|
|
'total_billable_amount': round(total_billable_amount, 2),
|
|
'projects_count': len(projects_data),
|
|
}
|
|
|
|
return render_template('reports/project_report.html',
|
|
projects=projects,
|
|
users=users,
|
|
entries=entries,
|
|
projects_data=projects_data,
|
|
summary=summary,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
selected_project=project_id,
|
|
selected_user=user_id)
|
|
|
|
@reports_bp.route('/reports/user')
|
|
@login_required
|
|
def user_report():
|
|
"""User-based time report"""
|
|
user_id = request.args.get('user_id', type=int)
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
project_id = request.args.get('project_id', type=int)
|
|
|
|
# Get users for filter
|
|
users = User.query.filter_by(is_active=True).order_by(User.username).all()
|
|
projects = Project.query.filter_by(status='active').order_by(Project.name).all()
|
|
|
|
# Parse dates
|
|
if not start_date:
|
|
start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
|
|
if not end_date:
|
|
end_date = datetime.utcnow().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1)
|
|
except ValueError:
|
|
flash('Invalid date format', 'error')
|
|
return render_template('reports/user_report.html', users=users, projects=projects)
|
|
|
|
# Get time entries
|
|
query = TimeEntry.query.filter(
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_dt,
|
|
TimeEntry.start_time <= end_dt
|
|
)
|
|
|
|
if user_id:
|
|
query = query.filter(TimeEntry.user_id == user_id)
|
|
|
|
if project_id:
|
|
query = query.filter(TimeEntry.project_id == project_id)
|
|
|
|
entries = query.order_by(TimeEntry.start_time.desc()).all()
|
|
|
|
# Calculate totals
|
|
total_hours = sum(entry.duration_hours for entry in entries)
|
|
billable_hours = sum(entry.duration_hours for entry in entries if entry.billable)
|
|
|
|
# Group by user
|
|
user_totals = {}
|
|
projects_set = set()
|
|
users_set = set()
|
|
for entry in entries:
|
|
if entry.project:
|
|
projects_set.add(entry.project.id)
|
|
if entry.user:
|
|
users_set.add(entry.user.id)
|
|
username = entry.user.display_name if entry.user else 'Unknown'
|
|
if username not in user_totals:
|
|
user_totals[username] = {
|
|
'hours': 0,
|
|
'billable_hours': 0,
|
|
'entries': []
|
|
}
|
|
user_totals[username]['hours'] += entry.duration_hours
|
|
if entry.billable:
|
|
user_totals[username]['billable_hours'] += entry.duration_hours
|
|
user_totals[username]['entries'].append(entry)
|
|
|
|
summary = {
|
|
'total_hours': round(total_hours, 1),
|
|
'billable_hours': round(billable_hours, 1),
|
|
'users_count': len(users_set),
|
|
'projects_count': len(projects_set),
|
|
}
|
|
|
|
return render_template('reports/user_report.html',
|
|
users=users,
|
|
projects=projects,
|
|
entries=entries,
|
|
user_totals=user_totals,
|
|
summary=summary,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
selected_user=user_id,
|
|
selected_project=project_id)
|
|
|
|
@reports_bp.route('/reports/export/csv')
|
|
@login_required
|
|
def export_csv():
|
|
"""Export time entries as CSV"""
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
user_id = request.args.get('user_id', type=int)
|
|
project_id = request.args.get('project_id', type=int)
|
|
|
|
# Parse dates
|
|
if not start_date:
|
|
start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
|
|
if not end_date:
|
|
end_date = datetime.utcnow().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1)
|
|
except ValueError:
|
|
flash('Invalid date format', 'error')
|
|
return redirect(url_for('reports.reports'))
|
|
|
|
# Get time entries
|
|
query = TimeEntry.query.filter(
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_dt,
|
|
TimeEntry.start_time <= end_dt
|
|
)
|
|
|
|
if user_id:
|
|
query = query.filter(TimeEntry.user_id == user_id)
|
|
|
|
if project_id:
|
|
query = query.filter(TimeEntry.project_id == project_id)
|
|
|
|
entries = query.order_by(TimeEntry.start_time.desc()).all()
|
|
|
|
# Get settings for delimiter
|
|
settings = Settings.get_settings()
|
|
delimiter = settings.export_delimiter
|
|
|
|
# Create CSV
|
|
output = io.StringIO()
|
|
writer = csv.writer(output, delimiter=delimiter)
|
|
|
|
# Write header
|
|
writer.writerow([
|
|
'ID', 'User', 'Project', 'Client', 'Start Time', 'End Time',
|
|
'Duration (hours)', 'Duration (formatted)', 'Notes', 'Tags',
|
|
'Source', 'Billable', 'Created At'
|
|
])
|
|
|
|
# Write data
|
|
for entry in entries:
|
|
writer.writerow([
|
|
entry.id,
|
|
entry.user.display_name,
|
|
entry.project.name,
|
|
entry.project.client,
|
|
entry.start_time.isoformat(),
|
|
entry.end_time.isoformat() if entry.end_time else '',
|
|
entry.duration_hours,
|
|
entry.duration_formatted,
|
|
entry.notes or '',
|
|
entry.tags or '',
|
|
entry.source,
|
|
'Yes' if entry.billable else 'No',
|
|
entry.created_at.isoformat()
|
|
])
|
|
|
|
output.seek(0)
|
|
|
|
# Create filename
|
|
filename = f'timetracker_export_{start_date}_to_{end_date}.csv'
|
|
|
|
return send_file(
|
|
io.BytesIO(output.getvalue().encode('utf-8')),
|
|
mimetype='text/csv',
|
|
as_attachment=True,
|
|
download_name=filename
|
|
)
|
|
|
|
@reports_bp.route('/reports/summary')
|
|
@login_required
|
|
def summary_report():
|
|
"""Summary report with key metrics"""
|
|
# Get date range
|
|
end_date = datetime.utcnow()
|
|
start_date = end_date - timedelta(days=30)
|
|
|
|
# Get total hours for different periods
|
|
today_hours = TimeEntry.get_total_hours_for_period(
|
|
start_date=end_date.date(),
|
|
user_id=current_user.id if not current_user.is_admin else None
|
|
)
|
|
|
|
week_hours = TimeEntry.get_total_hours_for_period(
|
|
start_date=end_date.date() - timedelta(days=7),
|
|
user_id=current_user.id if not current_user.is_admin else None
|
|
)
|
|
|
|
month_hours = TimeEntry.get_total_hours_for_period(
|
|
start_date=start_date.date(),
|
|
user_id=current_user.id if not current_user.is_admin else None
|
|
)
|
|
|
|
# Get top projects
|
|
if current_user.is_admin:
|
|
# For admins, show all projects
|
|
projects = Project.query.filter_by(status='active').all()
|
|
else:
|
|
# For users, show only their projects
|
|
project_ids = db.session.query(TimeEntry.project_id).filter(
|
|
TimeEntry.user_id == current_user.id
|
|
).distinct().all()
|
|
project_ids = [pid[0] for pid in project_ids]
|
|
projects = Project.query.filter(Project.id.in_(project_ids)).all()
|
|
|
|
# Sort projects by total hours
|
|
project_stats = []
|
|
for project in projects:
|
|
hours = TimeEntry.get_total_hours_for_period(
|
|
start_date=start_date.date(),
|
|
project_id=project.id,
|
|
user_id=current_user.id if not current_user.is_admin else None
|
|
)
|
|
if hours > 0:
|
|
project_stats.append({
|
|
'project': project,
|
|
'hours': hours
|
|
})
|
|
|
|
project_stats.sort(key=lambda x: x['hours'], reverse=True)
|
|
|
|
return render_template('reports/summary.html',
|
|
today_hours=today_hours,
|
|
week_hours=week_hours,
|
|
month_hours=month_hours,
|
|
project_stats=project_stats[:10]) # Top 10 projects
|
|
|
|
|
|
@reports_bp.route('/reports/tasks')
|
|
@login_required
|
|
def task_report():
|
|
"""Report of finished tasks within a project, including hours spent per task"""
|
|
project_id = request.args.get('project_id', type=int)
|
|
user_id = request.args.get('user_id', type=int)
|
|
start_date = request.args.get('start_date')
|
|
end_date = request.args.get('end_date')
|
|
|
|
# Filters data
|
|
projects = Project.query.order_by(Project.name).all()
|
|
users = User.query.filter_by(is_active=True).order_by(User.username).all()
|
|
|
|
# Default date range: last 30 days
|
|
if not start_date:
|
|
start_date = (datetime.utcnow() - timedelta(days=30)).strftime('%Y-%m-%d')
|
|
if not end_date:
|
|
end_date = datetime.utcnow().strftime('%Y-%m-%d')
|
|
|
|
try:
|
|
start_dt = datetime.strptime(start_date, '%Y-%m-%d')
|
|
end_dt = datetime.strptime(end_date, '%Y-%m-%d') + timedelta(days=1) - timedelta(seconds=1)
|
|
except ValueError:
|
|
flash('Invalid date format', 'error')
|
|
return render_template('reports/task_report.html', projects=projects, users=users)
|
|
|
|
# Base tasks query: finished tasks
|
|
tasks_query = Task.query.filter(Task.status == 'done')
|
|
|
|
if project_id:
|
|
tasks_query = tasks_query.filter(Task.project_id == project_id)
|
|
|
|
# Filter by completion window intersects [start_dt, end_dt]
|
|
tasks_query = tasks_query.filter(Task.completed_at.isnot(None))
|
|
tasks_query = tasks_query.filter(Task.completed_at >= start_dt, Task.completed_at <= end_dt)
|
|
|
|
# Optional: only tasks that have time entries by a specific user
|
|
if user_id:
|
|
tasks_query = tasks_query.join(TimeEntry, TimeEntry.task_id == Task.id).filter(TimeEntry.user_id == user_id)
|
|
|
|
tasks = tasks_query.order_by(Task.completed_at.desc()).all()
|
|
|
|
# Compute hours per task (sum of entry durations; respect user/project filters and date range)
|
|
task_rows = []
|
|
total_hours = 0.0
|
|
for task in tasks:
|
|
te_query = TimeEntry.query.filter(
|
|
TimeEntry.task_id == task.id,
|
|
TimeEntry.end_time.isnot(None),
|
|
TimeEntry.start_time >= start_dt,
|
|
TimeEntry.start_time <= end_dt
|
|
)
|
|
if project_id:
|
|
te_query = te_query.filter(TimeEntry.project_id == project_id)
|
|
if user_id:
|
|
te_query = te_query.filter(TimeEntry.user_id == user_id)
|
|
|
|
entries = te_query.all()
|
|
hours = sum(e.duration_hours for e in entries)
|
|
total_hours += hours
|
|
|
|
task_rows.append({
|
|
'task': task,
|
|
'project': task.project,
|
|
'assignee': task.assigned_user,
|
|
'completed_at': task.completed_at,
|
|
'hours': round(hours, 2),
|
|
'entries_count': len(entries),
|
|
})
|
|
|
|
summary = {
|
|
'tasks_count': len(task_rows),
|
|
'total_hours': round(total_hours, 2),
|
|
}
|
|
|
|
return render_template(
|
|
'reports/task_report.html',
|
|
projects=projects,
|
|
users=users,
|
|
tasks=task_rows,
|
|
summary=summary,
|
|
start_date=start_date,
|
|
end_date=end_date,
|
|
selected_project=project_id,
|
|
selected_user=user_id,
|
|
)
|