1ST
This commit is contained in:
372
backend/routes/reports.py
Normal file
372
backend/routes/reports.py
Normal file
@@ -0,0 +1,372 @@
|
||||
"""
|
||||
Reports API Routes
|
||||
提供待辦清單的統計報表和分析
|
||||
"""
|
||||
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_jwt_extended import jwt_required, get_jwt_identity
|
||||
from datetime import datetime, date, timedelta
|
||||
from sqlalchemy import func, and_, or_
|
||||
from models import (
|
||||
db, TodoItem, TodoItemResponsible, TodoItemFollower,
|
||||
TodoAuditLog, TodoUserPref
|
||||
)
|
||||
from utils.logger import get_logger
|
||||
import calendar
|
||||
|
||||
reports_bp = Blueprint('reports', __name__)
|
||||
logger = get_logger(__name__)
|
||||
|
||||
@reports_bp.route('/summary', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_summary():
|
||||
"""Get user's todo summary"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Count todos by status for current user
|
||||
query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
|
||||
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
total = query.count()
|
||||
completed = query.filter(TodoItem.status == 'DONE').count()
|
||||
in_progress = query.filter(TodoItem.status == 'IN_PROGRESS').count()
|
||||
new = query.filter(TodoItem.status == 'NEW').count()
|
||||
|
||||
# Overdue todos
|
||||
today = date.today()
|
||||
overdue = query.filter(
|
||||
and_(
|
||||
TodoItem.due_date < today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Due today
|
||||
due_today = query.filter(
|
||||
and_(
|
||||
TodoItem.due_date == today,
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Due this week
|
||||
week_end = today + timedelta(days=7)
|
||||
due_this_week = query.filter(
|
||||
and_(
|
||||
TodoItem.due_date.between(today, week_end),
|
||||
TodoItem.status != 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Priority distribution
|
||||
high_priority = query.filter(TodoItem.priority == 'HIGH').count()
|
||||
medium_priority = query.filter(TodoItem.priority == 'MEDIUM').count()
|
||||
low_priority = query.filter(TodoItem.priority == 'LOW').count()
|
||||
|
||||
# Completion rate
|
||||
completion_rate = (completed / total * 100) if total > 0 else 0
|
||||
|
||||
return jsonify({
|
||||
'summary': {
|
||||
'total': total,
|
||||
'completed': completed,
|
||||
'in_progress': in_progress,
|
||||
'new': new,
|
||||
'overdue': overdue,
|
||||
'due_today': due_today,
|
||||
'due_this_week': due_this_week,
|
||||
'completion_rate': round(completion_rate, 1)
|
||||
},
|
||||
'priority_distribution': {
|
||||
'high': high_priority,
|
||||
'medium': medium_priority,
|
||||
'low': low_priority
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching summary: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch summary'}), 500
|
||||
|
||||
@reports_bp.route('/activity', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_activity():
|
||||
"""Get user's activity over time"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
days = request.args.get('days', 30, type=int)
|
||||
|
||||
# Get date range
|
||||
end_date = date.today()
|
||||
start_date = end_date - timedelta(days=days-1)
|
||||
|
||||
# Query audit logs for the user
|
||||
logs = db.session.query(
|
||||
func.date(TodoAuditLog.timestamp).label('date'),
|
||||
func.count(TodoAuditLog.id).label('count'),
|
||||
TodoAuditLog.action
|
||||
).filter(
|
||||
and_(
|
||||
TodoAuditLog.actor_ad == identity,
|
||||
func.date(TodoAuditLog.timestamp) >= start_date
|
||||
)
|
||||
).group_by(
|
||||
func.date(TodoAuditLog.timestamp),
|
||||
TodoAuditLog.action
|
||||
).all()
|
||||
|
||||
# Organize by date and action
|
||||
activity_data = {}
|
||||
for log in logs:
|
||||
date_str = log.date.isoformat()
|
||||
if date_str not in activity_data:
|
||||
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
|
||||
activity_data[date_str][log.action] = log.count
|
||||
|
||||
# Fill in missing dates
|
||||
current_date = start_date
|
||||
while current_date <= end_date:
|
||||
date_str = current_date.isoformat()
|
||||
if date_str not in activity_data:
|
||||
activity_data[date_str] = {'CREATE': 0, 'UPDATE': 0, 'DELETE': 0}
|
||||
current_date += timedelta(days=1)
|
||||
|
||||
return jsonify({
|
||||
'activity': activity_data,
|
||||
'period': {
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': end_date.isoformat(),
|
||||
'days': days
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching activity: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch activity'}), 500
|
||||
|
||||
@reports_bp.route('/productivity', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_productivity():
|
||||
"""Get productivity metrics"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get date ranges
|
||||
today = date.today()
|
||||
week_start = today - timedelta(days=today.weekday())
|
||||
month_start = today.replace(day=1)
|
||||
|
||||
# Base query for user's todos
|
||||
base_query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
# Today's completions
|
||||
today_completed = base_query.filter(
|
||||
and_(
|
||||
func.date(TodoItem.completed_at) == today,
|
||||
TodoItem.status == 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# This week's completions
|
||||
week_completed = base_query.filter(
|
||||
and_(
|
||||
func.date(TodoItem.completed_at) >= week_start,
|
||||
TodoItem.status == 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# This month's completions
|
||||
month_completed = base_query.filter(
|
||||
and_(
|
||||
func.date(TodoItem.completed_at) >= month_start,
|
||||
TodoItem.status == 'DONE'
|
||||
)
|
||||
).count()
|
||||
|
||||
# Average completion time (for completed todos)
|
||||
completed_todos = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
TodoItem.completed_at.isnot(None)
|
||||
)
|
||||
).all()
|
||||
|
||||
avg_completion_days = 0
|
||||
if completed_todos:
|
||||
total_days = 0
|
||||
count = 0
|
||||
for todo in completed_todos:
|
||||
if todo.completed_at and todo.created_at:
|
||||
days = (todo.completed_at.date() - todo.created_at.date()).days
|
||||
total_days += days
|
||||
count += 1
|
||||
avg_completion_days = round(total_days / count, 1) if count > 0 else 0
|
||||
|
||||
# On-time completion rate (within due date)
|
||||
on_time_todos = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
TodoItem.due_date.isnot(None),
|
||||
TodoItem.completed_at.isnot(None),
|
||||
func.date(TodoItem.completed_at) <= TodoItem.due_date
|
||||
)
|
||||
).count()
|
||||
|
||||
total_due_todos = base_query.filter(
|
||||
and_(
|
||||
TodoItem.status == 'DONE',
|
||||
TodoItem.due_date.isnot(None)
|
||||
)
|
||||
).count()
|
||||
|
||||
on_time_rate = (on_time_todos / total_due_todos * 100) if total_due_todos > 0 else 0
|
||||
|
||||
return jsonify({
|
||||
'productivity': {
|
||||
'today_completed': today_completed,
|
||||
'week_completed': week_completed,
|
||||
'month_completed': month_completed,
|
||||
'avg_completion_days': avg_completion_days,
|
||||
'on_time_rate': round(on_time_rate, 1),
|
||||
'total_with_due_dates': total_due_todos,
|
||||
'on_time_count': on_time_todos
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching productivity: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch productivity metrics'}), 500
|
||||
|
||||
@reports_bp.route('/team-overview', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_team_overview():
|
||||
"""Get team overview for todos created by current user"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
|
||||
# Get todos created by current user
|
||||
created_todos = TodoItem.query.filter(TodoItem.creator_ad == identity)
|
||||
|
||||
# Get unique responsible users from these todos
|
||||
responsible_stats = db.session.query(
|
||||
TodoItemResponsible.ad_account,
|
||||
func.count(TodoItem.id).label('total'),
|
||||
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed'),
|
||||
func.sum(func.case([(TodoItem.status == 'IN_PROGRESS', 1)], else_=0)).label('in_progress'),
|
||||
func.sum(func.case([
|
||||
(and_(TodoItem.due_date < date.today(), TodoItem.status != 'DONE'), 1)
|
||||
], else_=0)).label('overdue')
|
||||
).join(
|
||||
TodoItem, TodoItemResponsible.todo_id == TodoItem.id
|
||||
).filter(
|
||||
TodoItem.creator_ad == identity
|
||||
).group_by(
|
||||
TodoItemResponsible.ad_account
|
||||
).all()
|
||||
|
||||
team_stats = []
|
||||
for stat in responsible_stats:
|
||||
completion_rate = (stat.completed / stat.total * 100) if stat.total > 0 else 0
|
||||
team_stats.append({
|
||||
'ad_account': stat.ad_account,
|
||||
'total_assigned': stat.total,
|
||||
'completed': stat.completed,
|
||||
'in_progress': stat.in_progress,
|
||||
'overdue': stat.overdue,
|
||||
'completion_rate': round(completion_rate, 1)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'team_overview': team_stats,
|
||||
'summary': {
|
||||
'total_team_members': len(team_stats),
|
||||
'total_assigned_todos': sum(stat['total_assigned'] for stat in team_stats),
|
||||
'total_completed': sum(stat['completed'] for stat in team_stats),
|
||||
'total_overdue': sum(stat['overdue'] for stat in team_stats)
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching team overview: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch team overview'}), 500
|
||||
|
||||
@reports_bp.route('/monthly-trends', methods=['GET'])
|
||||
@jwt_required()
|
||||
def get_monthly_trends():
|
||||
"""Get monthly trends for the past year"""
|
||||
try:
|
||||
identity = get_jwt_identity()
|
||||
months = request.args.get('months', 12, type=int)
|
||||
|
||||
# Calculate date range
|
||||
today = date.today()
|
||||
start_date = today.replace(day=1) - timedelta(days=30 * (months - 1))
|
||||
|
||||
# Base query
|
||||
base_query = TodoItem.query.filter(
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
)
|
||||
)
|
||||
|
||||
# Get monthly statistics
|
||||
monthly_data = db.session.query(
|
||||
func.year(TodoItem.created_at).label('year'),
|
||||
func.month(TodoItem.created_at).label('month'),
|
||||
func.count(TodoItem.id).label('created'),
|
||||
func.sum(func.case([(TodoItem.status == 'DONE', 1)], else_=0)).label('completed')
|
||||
).filter(
|
||||
and_(
|
||||
func.date(TodoItem.created_at) >= start_date,
|
||||
or_(
|
||||
TodoItem.creator_ad == identity,
|
||||
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
|
||||
)
|
||||
)
|
||||
).group_by(
|
||||
func.year(TodoItem.created_at),
|
||||
func.month(TodoItem.created_at)
|
||||
).order_by(
|
||||
func.year(TodoItem.created_at),
|
||||
func.month(TodoItem.created_at)
|
||||
).all()
|
||||
|
||||
# Format the data
|
||||
trends = []
|
||||
for data in monthly_data:
|
||||
month_name = calendar.month_name[data.month]
|
||||
completion_rate = (data.completed / data.created * 100) if data.created > 0 else 0
|
||||
|
||||
trends.append({
|
||||
'year': data.year,
|
||||
'month': data.month,
|
||||
'month_name': month_name,
|
||||
'created': data.created,
|
||||
'completed': data.completed,
|
||||
'completion_rate': round(completion_rate, 1)
|
||||
})
|
||||
|
||||
return jsonify({
|
||||
'trends': trends,
|
||||
'period': {
|
||||
'months': months,
|
||||
'start_date': start_date.isoformat(),
|
||||
'end_date': today.isoformat()
|
||||
}
|
||||
}), 200
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Error fetching monthly trends: {str(e)}")
|
||||
return jsonify({'error': 'Failed to fetch monthly trends'}), 500
|
Reference in New Issue
Block a user