""" 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