This commit is contained in:
beabigegg
2025-08-29 16:25:46 +08:00
commit b0c86302ff
65 changed files with 19786 additions and 0 deletions

191
backend/routes/admin.py Normal file
View File

@@ -0,0 +1,191 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, timedelta
from sqlalchemy import func
from models import db, TodoItem, TodoAuditLog, TodoMailLog, TodoImportJob
from utils.logger import get_logger
admin_bp = Blueprint('admin', __name__)
logger = get_logger(__name__)
# Admin users (in production, this should be in database or config)
ADMIN_USERS = ['admin', 'administrator']
def is_admin(identity):
"""Check if user is admin"""
return identity.lower() in ADMIN_USERS
@admin_bp.route('/stats', methods=['GET'])
@jwt_required()
def get_stats():
"""Get system statistics"""
try:
identity = get_jwt_identity()
if not is_admin(identity):
return jsonify({'error': 'Admin access required'}), 403
# Get date range
days = request.args.get('days', 30, type=int)
start_date = datetime.utcnow() - timedelta(days=days)
# Todo statistics
todo_stats = db.session.query(
func.count(TodoItem.id).label('total'),
func.sum(func.if_(TodoItem.status == 'NEW', 1, 0)).label('new'),
func.sum(func.if_(TodoItem.status == 'DOING', 1, 0)).label('doing'),
func.sum(func.if_(TodoItem.status == 'BLOCKED', 1, 0)).label('blocked'),
func.sum(func.if_(TodoItem.status == 'DONE', 1, 0)).label('done')
).filter(TodoItem.created_at >= start_date).first()
# User activity
active_users = db.session.query(
func.count(func.distinct(TodoAuditLog.actor_ad))
).filter(TodoAuditLog.created_at >= start_date).scalar()
# Email statistics
email_stats = db.session.query(
func.count(TodoMailLog.id).label('total'),
func.sum(func.if_(TodoMailLog.status == 'SENT', 1, 0)).label('sent'),
func.sum(func.if_(TodoMailLog.status == 'FAILED', 1, 0)).label('failed')
).filter(TodoMailLog.created_at >= start_date).first()
# Import statistics
import_stats = db.session.query(
func.count(TodoImportJob.id).label('total'),
func.sum(func.if_(TodoImportJob.status == 'COMPLETED', 1, 0)).label('completed'),
func.sum(func.if_(TodoImportJob.status == 'FAILED', 1, 0)).label('failed')
).filter(TodoImportJob.created_at >= start_date).first()
return jsonify({
'period_days': days,
'todos': {
'total': todo_stats.total or 0,
'new': todo_stats.new or 0,
'doing': todo_stats.doing or 0,
'blocked': todo_stats.blocked or 0,
'done': todo_stats.done or 0
},
'users': {
'active': active_users or 0
},
'emails': {
'total': email_stats.total or 0,
'sent': email_stats.sent or 0,
'failed': email_stats.failed or 0
},
'imports': {
'total': import_stats.total or 0,
'completed': import_stats.completed or 0,
'failed': import_stats.failed or 0
}
}), 200
except Exception as e:
logger.error(f"Error fetching stats: {str(e)}")
return jsonify({'error': 'Failed to fetch statistics'}), 500
@admin_bp.route('/audit-logs', methods=['GET'])
@jwt_required()
def get_audit_logs():
"""Get audit logs"""
try:
identity = get_jwt_identity()
if not is_admin(identity):
return jsonify({'error': 'Admin access required'}), 403
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
actor = request.args.get('actor')
action = request.args.get('action')
todo_id = request.args.get('todo_id')
query = TodoAuditLog.query
if actor:
query = query.filter(TodoAuditLog.actor_ad == actor)
if action:
query = query.filter(TodoAuditLog.action == action)
if todo_id:
query = query.filter(TodoAuditLog.todo_id == todo_id)
query = query.order_by(TodoAuditLog.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
logs = []
for log in pagination.items:
logs.append({
'id': log.id,
'actor_ad': log.actor_ad,
'todo_id': log.todo_id,
'action': log.action,
'detail': log.detail,
'created_at': log.created_at.isoformat()
})
return jsonify({
'logs': logs,
'total': pagination.total,
'page': page,
'per_page': per_page,
'pages': pagination.pages
}), 200
except Exception as e:
logger.error(f"Error fetching audit logs: {str(e)}")
return jsonify({'error': 'Failed to fetch audit logs'}), 500
@admin_bp.route('/mail-logs', methods=['GET'])
@jwt_required()
def get_mail_logs():
"""Get mail logs"""
try:
identity = get_jwt_identity()
if not is_admin(identity):
return jsonify({'error': 'Admin access required'}), 403
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 50, type=int)
status = request.args.get('status')
type_ = request.args.get('type')
query = TodoMailLog.query
if status:
query = query.filter(TodoMailLog.status == status)
if type_:
query = query.filter(TodoMailLog.type == type_)
query = query.order_by(TodoMailLog.created_at.desc())
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
logs = []
for log in pagination.items:
logs.append({
'id': log.id,
'todo_id': log.todo_id,
'type': log.type,
'triggered_by_ad': log.triggered_by_ad,
'recipients': log.recipients,
'subject': log.subject,
'status': log.status,
'error_text': log.error_text,
'created_at': log.created_at.isoformat(),
'sent_at': log.sent_at.isoformat() if log.sent_at else None
})
return jsonify({
'logs': logs,
'total': pagination.total,
'page': page,
'per_page': per_page,
'pages': pagination.pages
}), 200
except Exception as e:
logger.error(f"Error fetching mail logs: {str(e)}")
return jsonify({'error': 'Failed to fetch mail logs'}), 500

175
backend/routes/auth.py Normal file
View File

@@ -0,0 +1,175 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import (
create_access_token, create_refresh_token,
jwt_required, get_jwt_identity, get_jwt
)
from datetime import datetime, timedelta
from flask import current_app
from models import db, TodoUserPref
from utils.logger import get_logger
auth_bp = Blueprint('auth', __name__)
logger = get_logger(__name__)
@auth_bp.route('/login', methods=['POST'])
def login():
"""AD/LDAP Login"""
try:
data = request.get_json()
username = data.get('username', '').strip()
password = data.get('password', '')
if not username or not password:
return jsonify({'error': 'Username and password required'}), 400
# Authenticate with LDAP (or mock for development)
try:
if current_app.config.get('USE_MOCK_LDAP', False):
from utils.mock_ldap import authenticate_user
logger.info("Using Mock LDAP for development")
else:
from utils.ldap_utils import authenticate_user
logger.info("Using real LDAP authentication")
user_info = authenticate_user(username, password)
except Exception as e:
logger.error(f"LDAP authentication error, falling back to mock: {str(e)}")
from utils.mock_ldap import authenticate_user
user_info = authenticate_user(username, password)
if not user_info:
logger.warning(f"Failed login attempt for user: {username}")
return jsonify({'error': 'Invalid credentials'}), 401
ad_account = user_info['ad_account']
# Get or create user preferences
user_pref = TodoUserPref.query.filter_by(ad_account=ad_account).first()
if not user_pref:
user_pref = TodoUserPref(
ad_account=ad_account,
email=user_info['email'],
display_name=user_info['display_name']
)
db.session.add(user_pref)
db.session.commit()
logger.info(f"Created new user preference for: {ad_account}")
else:
# Update user info if changed
if user_pref.email != user_info['email'] or user_pref.display_name != user_info['display_name']:
user_pref.email = user_info['email']
user_pref.display_name = user_info['display_name']
user_pref.updated_at = datetime.utcnow()
db.session.commit()
# Create tokens
access_token = create_access_token(
identity=ad_account,
additional_claims={
'display_name': user_info['display_name'],
'email': user_info['email']
}
)
refresh_token = create_refresh_token(identity=ad_account)
logger.info(f"Successful login for user: {ad_account}")
return jsonify({
'access_token': access_token,
'refresh_token': refresh_token,
'user': {
'ad_account': ad_account,
'display_name': user_info['display_name'],
'email': user_info['email'],
'theme': user_pref.theme,
'language': user_pref.language
}
}), 200
except Exception as e:
logger.error(f"Login error: {str(e)}")
return jsonify({'error': 'Authentication failed'}), 500
@auth_bp.route('/refresh', methods=['POST'])
@jwt_required(refresh=True)
def refresh():
"""Refresh access token"""
try:
identity = get_jwt_identity()
# Get user info
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User not found'}), 404
access_token = create_access_token(
identity=identity,
additional_claims={
'display_name': user_pref.display_name,
'email': user_pref.email
}
)
return jsonify({'access_token': access_token}), 200
except Exception as e:
logger.error(f"Token refresh error: {str(e)}")
return jsonify({'error': 'Token refresh failed'}), 500
@auth_bp.route('/logout', methods=['POST'])
@jwt_required()
def logout():
"""Logout (client should remove tokens)"""
try:
identity = get_jwt_identity()
logger.info(f"User logged out: {identity}")
# In production, you might want to blacklist the token here
# For now, we'll rely on client-side token removal
return jsonify({'message': 'Logged out successfully'}), 200
except Exception as e:
logger.error(f"Logout error: {str(e)}")
return jsonify({'error': 'Logout failed'}), 500
@auth_bp.route('/me', methods=['GET'])
@jwt_required()
def get_current_user():
"""Get current user information"""
try:
identity = get_jwt_identity()
claims = get_jwt()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User not found'}), 404
return jsonify({
'ad_account': identity,
'display_name': claims.get('display_name', user_pref.display_name),
'email': claims.get('email', user_pref.email),
'preferences': user_pref.to_dict()
}), 200
except Exception as e:
logger.error(f"Get current user error: {str(e)}")
return jsonify({'error': 'Failed to get user information'}), 500
@auth_bp.route('/validate', methods=['GET'])
@jwt_required()
def validate_token():
"""Validate JWT token"""
try:
identity = get_jwt_identity()
claims = get_jwt()
return jsonify({
'valid': True,
'identity': identity,
'claims': claims
}), 200
except Exception as e:
logger.error(f"Token validation error: {str(e)}")
return jsonify({'valid': False}), 401

527
backend/routes/excel.py Normal file
View File

@@ -0,0 +1,527 @@
"""
Excel Import/Export API Routes
處理 Excel 檔案的匯入和匯出功能
"""
import os
import uuid
from datetime import datetime, date
from flask import Blueprint, request, jsonify, send_file, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from werkzeug.utils import secure_filename
import pandas as pd
from openpyxl import Workbook
from openpyxl.styles import Font, Alignment, PatternFill
from openpyxl.utils.dataframe import dataframe_to_rows
from sqlalchemy import or_, and_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoAuditLog
)
from utils.logger import get_logger
from utils.ldap_utils import validate_ad_accounts
import tempfile
import zipfile
excel_bp = Blueprint('excel', __name__)
logger = get_logger(__name__)
# 允許的檔案類型
ALLOWED_EXTENSIONS = {'xlsx', 'xls', 'csv'}
def allowed_file(filename):
"""檢查檔案類型是否允許"""
return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
def parse_date(date_str):
"""解析日期字串"""
if pd.isna(date_str) or not date_str:
return None
if isinstance(date_str, datetime):
return date_str.date()
if isinstance(date_str, date):
return date_str
# 嘗試多種日期格式
date_formats = ['%Y-%m-%d', '%Y/%m/%d', '%d/%m/%Y', '%m/%d/%Y', '%Y%m%d']
for fmt in date_formats:
try:
return datetime.strptime(str(date_str), fmt).date()
except ValueError:
continue
return None
@excel_bp.route('/upload', methods=['POST'])
@jwt_required()
def upload_excel():
"""Upload and parse Excel file for todo import"""
try:
identity = get_jwt_identity()
if 'file' not in request.files:
return jsonify({'error': '沒有選擇檔案'}), 400
file = request.files['file']
if file.filename == '':
return jsonify({'error': '沒有選擇檔案'}), 400
if not allowed_file(file.filename):
return jsonify({'error': '檔案類型不支援,請上傳 .xlsx, .xls 或 .csv 檔案'}), 400
# 儲存檔案到暫存目錄
filename = secure_filename(file.filename)
temp_dir = current_app.config.get('TEMP_FOLDER', tempfile.gettempdir())
filepath = os.path.join(temp_dir, f"{uuid.uuid4()}_{filename}")
file.save(filepath)
try:
# 讀取 Excel/CSV 檔案
if filename.endswith('.csv'):
df = pd.read_csv(filepath, encoding='utf-8')
else:
df = pd.read_excel(filepath)
# 驗證必要欄位
required_columns = ['標題', 'title'] # 支援中英文欄位名
title_column = None
for col in required_columns:
if col in df.columns:
title_column = col
break
if not title_column:
return jsonify({
'error': '找不到必要欄位「標題」或「title」',
'columns': list(df.columns)
}), 400
# 解析資料
todos_data = []
errors = []
for idx, row in df.iterrows():
try:
# 必要欄位
title = str(row[title_column]).strip()
if not title or title == 'nan':
errors.append(f'{idx + 2} 行:標題不能為空')
continue
# 選擇性欄位
description = str(row.get('描述', row.get('description', ''))).strip()
if description == 'nan':
description = ''
# 狀態
status_mapping = {
'新建': 'NEW', '進行中': 'IN_PROGRESS', '完成': 'DONE',
'NEW': 'NEW', 'IN_PROGRESS': 'IN_PROGRESS', 'DONE': 'DONE',
'': 'NEW', '進行': 'IN_PROGRESS', '': 'DONE'
}
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
status = status_mapping.get(status_str, 'NEW')
# 優先級
priority_mapping = {
'': 'HIGH', '': 'MEDIUM', '': 'LOW',
'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
'高優先級': 'HIGH', '中優先級': 'MEDIUM', '低優先級': 'LOW'
}
priority_str = str(row.get('優先級', row.get('priority', 'MEDIUM'))).strip()
priority = priority_mapping.get(priority_str, 'MEDIUM')
# 到期日
due_date = parse_date(row.get('到期日', row.get('due_date')))
# 負責人 (用分號或逗號分隔)
responsible_str = str(row.get('負責人', row.get('responsible_users', ''))).strip()
responsible_users = []
if responsible_str and responsible_str != 'nan':
responsible_users = [user.strip() for user in responsible_str.replace(',', ';').split(';') if user.strip()]
# 追蹤人
followers_str = str(row.get('追蹤人', row.get('followers', ''))).strip()
followers = []
if followers_str and followers_str != 'nan':
followers = [user.strip() for user in followers_str.replace(',', ';').split(';') if user.strip()]
todos_data.append({
'row': idx + 2,
'title': title,
'description': description,
'status': status,
'priority': priority,
'due_date': due_date.isoformat() if due_date else None,
'responsible_users': responsible_users,
'followers': followers
})
except Exception as e:
errors.append(f'{idx + 2} 行解析錯誤: {str(e)}')
# 清理暫存檔案
os.unlink(filepath)
return jsonify({
'data': todos_data,
'total': len(todos_data),
'errors': errors,
'columns': list(df.columns)
}), 200
except Exception as e:
# 清理暫存檔案
if os.path.exists(filepath):
os.unlink(filepath)
raise e
except Exception as e:
logger.error(f"Excel upload error: {str(e)}")
return jsonify({'error': f'檔案處理失敗: {str(e)}'}), 500
@excel_bp.route('/import', methods=['POST'])
@jwt_required()
def import_todos():
"""Import todos from parsed Excel data"""
try:
identity = get_jwt_identity()
claims = get_jwt()
data = request.get_json()
todos_data = data.get('todos', [])
if not todos_data:
return jsonify({'error': '沒有要匯入的資料'}), 400
imported_count = 0
errors = []
for todo_data in todos_data:
try:
# 驗證負責人和追蹤人的 AD 帳號
responsible_users = todo_data.get('responsible_users', [])
followers = todo_data.get('followers', [])
if responsible_users:
valid_responsible = validate_ad_accounts(responsible_users)
invalid_responsible = set(responsible_users) - set(valid_responsible.keys())
if invalid_responsible:
errors.append({
'row': todo_data.get('row', '?'),
'error': f'無效的負責人帳號: {", ".join(invalid_responsible)}'
})
continue
if followers:
valid_followers = validate_ad_accounts(followers)
invalid_followers = set(followers) - set(valid_followers.keys())
if invalid_followers:
errors.append({
'row': todo_data.get('row', '?'),
'error': f'無效的追蹤人帳號: {", ".join(invalid_followers)}'
})
continue
# 建立待辦事項
due_date = None
if todo_data.get('due_date'):
due_date = datetime.strptime(todo_data['due_date'], '%Y-%m-%d').date()
todo = TodoItem(
id=str(uuid.uuid4()),
title=todo_data['title'],
description=todo_data.get('description', ''),
status=todo_data.get('status', 'NEW'),
priority=todo_data.get('priority', 'MEDIUM'),
due_date=due_date,
creator_ad=identity,
creator_display_name=claims.get('display_name', identity),
creator_email=claims.get('email', ''),
starred=False
)
db.session.add(todo)
# 新增負責人
if responsible_users:
for account in responsible_users:
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(responsible)
# 新增追蹤人
if followers:
for account in followers:
follower = TodoItemFollower(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(follower)
# 新增稽核記錄
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='CREATE',
detail={
'source': 'excel_import',
'title': todo.title,
'row': todo_data.get('row')
}
)
db.session.add(audit)
imported_count += 1
except Exception as e:
errors.append({
'row': todo_data.get('row', '?'),
'error': str(e)
})
db.session.commit()
logger.info(f"Excel import completed: {imported_count} todos imported by {identity}")
return jsonify({
'imported': imported_count,
'errors': errors,
'total_processed': len(todos_data)
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Excel import error: {str(e)}")
return jsonify({'error': '匯入失敗'}), 500
@excel_bp.route('/export', methods=['GET'])
@jwt_required()
def export_todos():
"""Export todos to Excel"""
try:
identity = get_jwt_identity()
# 篩選參數
status = request.args.get('status')
priority = request.args.get('priority')
due_from = request.args.get('due_from')
due_to = request.args.get('due_to')
view_type = request.args.get('view', 'all')
# 查詢待辦事項
query = TodoItem.query
# 套用檢視類型篩選
if view_type == 'created':
query = query.filter(TodoItem.creator_ad == identity)
elif view_type == 'responsible':
query = query.join(TodoItemResponsible).filter(
TodoItemResponsible.ad_account == identity
)
elif view_type == 'following':
query = query.join(TodoItemFollower).filter(
TodoItemFollower.ad_account == identity
)
else: # all
query = query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
)
)
# 套用其他篩選條件
if status:
query = query.filter(TodoItem.status == status)
if priority:
query = query.filter(TodoItem.priority == priority)
if due_from:
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
if due_to:
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
todos = query.order_by(TodoItem.created_at.desc()).all()
# 準備資料
data = []
for todo in todos:
# 取得負責人和追蹤人
responsible_users = [r.ad_account for r in todo.responsible_users]
followers = [f.ad_account for f in todo.followers]
# 狀態和優先級的中文對應
status_mapping = {'NEW': '新建', 'IN_PROGRESS': '進行中', 'DONE': '完成'}
priority_mapping = {'HIGH': '', 'MEDIUM': '', 'LOW': ''}
data.append({
'編號': todo.id,
'標題': todo.title,
'描述': todo.description,
'狀態': status_mapping.get(todo.status, todo.status),
'優先級': priority_mapping.get(todo.priority, todo.priority),
'到期日': todo.due_date.strftime('%Y-%m-%d') if todo.due_date else '',
'建立者': todo.creator_ad,
'建立時間': todo.created_at.strftime('%Y-%m-%d %H:%M:%S'),
'完成時間': todo.completed_at.strftime('%Y-%m-%d %H:%M:%S') if todo.completed_at else '',
'負責人': '; '.join(responsible_users),
'追蹤人': '; '.join(followers),
'星號標記': '' if todo.starred else ''
})
# 建立 Excel 檔案
df = pd.DataFrame(data)
# 建立暫存檔案
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
temp_filename = temp_file.name
temp_file.close()
# 使用 openpyxl 建立更美觀的 Excel
wb = Workbook()
ws = wb.active
ws.title = "待辦清單"
# 標題樣式
header_font = Font(bold=True, color="FFFFFF")
header_fill = PatternFill(start_color="4472C4", end_color="4472C4", fill_type="solid")
header_alignment = Alignment(horizontal="center", vertical="center")
# 寫入標題
if not df.empty:
for r_idx, row in enumerate(dataframe_to_rows(df, index=False, header=True), 1):
for c_idx, value in enumerate(row, 1):
cell = ws.cell(row=r_idx, column=c_idx, value=value)
if r_idx == 1: # 標題行
cell.font = header_font
cell.fill = header_fill
cell.alignment = header_alignment
# 自動調整列寬
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
wb.save(temp_filename)
# 產生檔案名稱
timestamp = datetime.now().strftime("%Y%m%d_%H%M%S")
filename = f"todos_{timestamp}.xlsx"
logger.info(f"Excel export: {len(todos)} todos exported by {identity}")
return send_file(
temp_filename,
as_attachment=True,
download_name=filename,
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
logger.error(f"Excel export error: {str(e)}")
return jsonify({'error': '匯出失敗'}), 500
@excel_bp.route('/template', methods=['GET'])
@jwt_required()
def download_template():
"""Download Excel import template"""
try:
# 建立範本資料
template_data = {
'標題': ['範例待辦事項1', '範例待辦事項2'],
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
'狀態': ['新建', '進行中'],
'優先級': ['', ''],
'到期日': ['2024-12-31', '2025-01-15'],
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
'追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw']
}
# 說明資料
instructions = {
'欄位說明': [
'標題 (必填)',
'描述 (選填)',
'狀態: 新建/進行中/完成',
'優先級: 高/中/低',
'到期日: YYYY-MM-DD 格式',
'負責人: AD帳號多人用分號分隔',
'追蹤人: AD帳號多人用分號分隔'
],
'說明': [
'請填入待辦事項的標題',
'可選填詳細描述',
'可選填 NEW/IN_PROGRESS/DONE',
'可選填 HIGH/MEDIUM/LOW',
'例如: 2024-12-31',
'例如: john@panjit.com.tw',
'例如: mary@panjit.com.tw;tom@panjit.com.tw'
]
}
# 建立暫存檔案
temp_file = tempfile.NamedTemporaryFile(delete=False, suffix='.xlsx')
temp_filename = temp_file.name
temp_file.close()
# 建立 Excel 檔案
wb = Workbook()
# 範本資料工作表
ws_data = wb.active
ws_data.title = "匯入範本"
df_template = pd.DataFrame(template_data)
for r_idx, row in enumerate(dataframe_to_rows(df_template, index=False, header=True), 1):
for c_idx, value in enumerate(row, 1):
ws_data.cell(row=r_idx, column=c_idx, value=value)
# 說明工作表
ws_help = wb.create_sheet("使用說明")
df_help = pd.DataFrame(instructions)
for r_idx, row in enumerate(dataframe_to_rows(df_help, index=False, header=True), 1):
for c_idx, value in enumerate(row, 1):
ws_help.cell(row=r_idx, column=c_idx, value=value)
# 樣式設定
for ws in [ws_data, ws_help]:
for column in ws.columns:
max_length = 0
column_letter = column[0].column_letter
for cell in column:
try:
if len(str(cell.value)) > max_length:
max_length = len(str(cell.value))
except:
pass
adjusted_width = min(max_length + 2, 50)
ws.column_dimensions[column_letter].width = adjusted_width
wb.save(temp_filename)
logger.info(f"Template downloaded by {get_jwt_identity()}")
return send_file(
temp_filename,
as_attachment=True,
download_name="todo_import_template.xlsx",
mimetype='application/vnd.openxmlformats-officedocument.spreadsheetml.sheet'
)
except Exception as e:
logger.error(f"Template download error: {str(e)}")
return jsonify({'error': '範本下載失敗'}), 500

125
backend/routes/health.py Normal file
View File

@@ -0,0 +1,125 @@
from flask import Blueprint, jsonify, current_app
from datetime import datetime
from models import db
from utils.logger import get_logger
import smtplib
import redis
health_bp = Blueprint('health', __name__)
logger = get_logger(__name__)
@health_bp.route('/healthz', methods=['GET'])
def health_check():
"""Basic health check"""
return jsonify({
'status': 'healthy',
'timestamp': datetime.utcnow().isoformat()
}), 200
@health_bp.route('/readiness', methods=['GET'])
def readiness_check():
"""Detailed readiness check"""
try:
checks = {
'database': False,
'ldap': False,
'smtp': False,
'redis': False
}
errors = []
# Check database
try:
db.session.execute(db.text('SELECT 1'))
checks['database'] = True
except Exception as e:
errors.append(f"Database check failed: {str(e)}")
logger.error(f"Database health check failed: {str(e)}")
# Check LDAP
try:
if current_app.config.get('USE_MOCK_LDAP', False):
from utils.mock_ldap import test_ldap_connection
else:
from utils.ldap_utils import test_ldap_connection
if test_ldap_connection():
checks['ldap'] = True
else:
errors.append("LDAP connection failed")
except Exception as e:
errors.append(f"LDAP check failed: {str(e)}")
logger.error(f"LDAP health check failed: {str(e)}")
# Check SMTP
try:
from flask import current_app
config = current_app.config
if config['SMTP_USE_SSL']:
server = smtplib.SMTP_SSL(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
else:
server = smtplib.SMTP(config['SMTP_SERVER'], config['SMTP_PORT'], timeout=5)
if config['SMTP_USE_TLS']:
server.starttls()
server.quit()
checks['smtp'] = True
except Exception as e:
errors.append(f"SMTP check failed: {str(e)}")
logger.error(f"SMTP health check failed: {str(e)}")
# Check Redis
try:
from flask import current_app
r = redis.from_url(current_app.config['REDIS_URL'])
r.ping()
checks['redis'] = True
except Exception as e:
errors.append(f"Redis check failed: {str(e)}")
logger.error(f"Redis health check failed: {str(e)}")
# Determine overall status
all_healthy = all(checks.values())
critical_healthy = checks['database'] # Database is critical
if all_healthy:
status_code = 200
status = 'healthy'
elif critical_healthy:
status_code = 200
status = 'degraded'
else:
status_code = 503
status = 'unhealthy'
return jsonify({
'status': status,
'checks': checks,
'errors': errors,
'timestamp': datetime.utcnow().isoformat()
}), status_code
except Exception as e:
logger.error(f"Readiness check error: {str(e)}")
return jsonify({
'status': 'error',
'error': str(e),
'timestamp': datetime.utcnow().isoformat()
}), 503
@health_bp.route('/liveness', methods=['GET'])
def liveness_check():
"""Kubernetes liveness probe"""
try:
# Simple check to see if the app is running
return jsonify({
'status': 'alive',
'timestamp': datetime.utcnow().isoformat()
}), 200
except Exception as e:
logger.error(f"Liveness check failed: {str(e)}")
return jsonify({
'status': 'dead',
'error': str(e)
}), 503

View File

@@ -0,0 +1,584 @@
"""
Notifications API Routes
處理通知相關功能,包括 email 通知和系統通知
"""
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoUserPref, TodoAuditLog, TodoFireEmailLog
)
from utils.logger import get_logger
from utils.email_service import EmailService
from utils.notification_service import NotificationService
import json
notifications_bp = Blueprint('notifications', __name__)
logger = get_logger(__name__)
@notifications_bp.route('/', methods=['GET'])
@jwt_required()
def get_notifications():
"""Get user notifications"""
try:
identity = get_jwt_identity()
# 獲取最近7天的相關通知 (指派、完成、逾期等)
seven_days_ago = datetime.utcnow() - timedelta(days=7)
notifications = []
# 1. 獲取被指派的Todo (最近7天)
assigned_todos = db.session.query(TodoItem).join(TodoItemResponsible).filter(
and_(
TodoItemResponsible.ad_account == identity,
TodoItemResponsible.added_at >= seven_days_ago,
TodoItemResponsible.added_by != identity # 不是自己指派給自己
)
).all()
logger.info(f"Found {len(assigned_todos)} assigned todos for user {identity}")
for todo in assigned_todos:
responsible = next((r for r in todo.responsible_users if r.ad_account == identity), None)
if responsible and responsible.added_by:
notifications.append({
'id': f"assign_{todo.id}_{int(responsible.added_at.timestamp())}",
'type': 'assignment',
'title': '新的待辦事項指派',
'message': f'{responsible.added_by} 指派了「{todo.title}」給您',
'time': responsible.added_at.strftime('%m/%d %H:%M'),
'read': False,
'actionable': True,
'todo_id': todo.id
})
# 2. 獲取即將到期的Todo (明後天)
tomorrow = date.today() + timedelta(days=1)
day_after_tomorrow = date.today() + timedelta(days=2)
due_soon_todos = db.session.query(TodoItem).filter(
and_(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
),
TodoItem.due_date.in_([tomorrow, day_after_tomorrow]),
TodoItem.status != 'DONE'
)
).all()
for todo in due_soon_todos:
days_until_due = (todo.due_date - date.today()).days
notifications.append({
'id': f"due_{todo.id}_{todo.due_date}",
'type': 'reminder',
'title': '待辦事項即將到期',
'message': f'{todo.title}」將在{days_until_due}天後到期',
'time': f'{todo.due_date.strftime("%m/%d")} 到期',
'read': False,
'actionable': True,
'todo_id': todo.id
})
# 3. 獲取逾期的Todo
overdue_todos = db.session.query(TodoItem).filter(
and_(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity)
),
TodoItem.due_date < date.today(),
TodoItem.status != 'DONE'
)
).all()
for todo in overdue_todos:
days_overdue = (date.today() - todo.due_date).days
notifications.append({
'id': f"overdue_{todo.id}_{todo.due_date}",
'type': 'overdue',
'title': '待辦事項已逾期',
'message': f'{todo.title}」已逾期{days_overdue}',
'time': f'逾期 {days_overdue}',
'read': False,
'actionable': True,
'todo_id': todo.id
})
# 按時間排序 (最新在前)
notifications.sort(key=lambda x: x['time'], reverse=True)
return jsonify({
'notifications': notifications,
'unread_count': len(notifications)
}), 200
except Exception as e:
logger.error(f"Error fetching notifications: {str(e)}")
return jsonify({'error': '獲取通知失敗'}), 500
@notifications_bp.route('/fire-email', methods=['POST'])
@jwt_required()
def send_fire_email():
"""Send urgent fire email notification"""
try:
identity = get_jwt_identity()
data = request.get_json()
todo_id = data.get('todo_id')
custom_message = data.get('message', '')
if not todo_id:
return jsonify({'error': '待辦事項ID不能為空'}), 400
# 檢查待辦事項
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': '找不到待辦事項'}), 404
# 檢查權限 (只有建立者或負責人可以發送 fire email)
if not (todo.creator_ad == identity or
any(r.ad_account == identity for r in todo.responsible_users)):
return jsonify({'error': '沒有權限發送緊急通知'}), 403
# 檢查用戶 fire email 配額
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
# 檢查今日配額
today = date.today()
if user_pref.fire_email_last_reset != today:
user_pref.fire_email_today_count = 0
user_pref.fire_email_last_reset = today
daily_limit = current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3)
if user_pref.fire_email_today_count >= daily_limit:
return jsonify({
'error': f'今日緊急通知配額已用完 ({daily_limit}次)',
'quota_exceeded': True
}), 429
# 檢查2分鐘冷卻機制
cooldown_minutes = current_app.config.get('FIRE_EMAIL_COOLDOWN_MINUTES', 2)
last_fire_log = TodoFireEmailLog.query.filter_by(
todo_id=todo_id
).order_by(TodoFireEmailLog.sent_at.desc()).first()
if last_fire_log:
time_since_last = datetime.utcnow() - last_fire_log.sent_at
if time_since_last.total_seconds() < cooldown_minutes * 60:
remaining_seconds = int(cooldown_minutes * 60 - time_since_last.total_seconds())
return jsonify({
'error': f'此待辦事項的緊急通知需要冷卻 {remaining_seconds} 秒後才能再次發送',
'cooldown_remaining': remaining_seconds
}), 429
# 準備收件人清單
recipients = set()
# 加入所有負責人
for responsible in todo.responsible_users:
recipients.add(responsible.ad_account)
# 加入所有追蹤人
for follower in todo.followers:
recipients.add(follower.ad_account)
# 如果是建立者發送,不包含自己
recipients.discard(identity)
if not recipients:
# 檢查是否只有發送者自己是相關人員
all_related_users = set()
for responsible in todo.responsible_users:
all_related_users.add(responsible.ad_account)
for follower in todo.followers:
all_related_users.add(follower.ad_account)
if len(all_related_users) == 1 and identity in all_related_users:
return jsonify({'error': '無法發送緊急通知:您是此待辦事項的唯一相關人員,請先指派其他負責人或追蹤人'}), 400
else:
return jsonify({'error': '沒有找到收件人'}), 400
# 發送郵件
email_service = EmailService()
success_count = 0
failed_recipients = []
for recipient in recipients:
try:
# 檢查收件人是否啟用郵件通知
recipient_pref = TodoUserPref.query.filter_by(ad_account=recipient).first()
if recipient_pref and not recipient_pref.email_reminder_enabled:
continue
success = email_service.send_fire_email(
todo=todo,
recipient=recipient,
sender=identity,
custom_message=custom_message
)
if success:
success_count += 1
else:
failed_recipients.append(recipient)
except Exception as e:
logger.error(f"Failed to send fire email to {recipient}: {str(e)}")
failed_recipients.append(recipient)
# 更新配額
user_pref.fire_email_today_count += 1
# 記錄 Fire Email 發送日誌 (用於冷卻檢查)
if success_count > 0:
fire_log = TodoFireEmailLog(
todo_id=todo_id,
sender_ad=identity
)
db.session.add(fire_log)
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='FIRE_EMAIL',
detail={
'recipients_count': len(recipients),
'success_count': success_count,
'failed_count': len(failed_recipients),
'custom_message': custom_message[:100] if custom_message else None
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Fire email sent by {identity} for todo {todo_id}: {success_count}/{len(recipients)} successful")
return jsonify({
'sent': success_count,
'total_recipients': len(recipients),
'failed_recipients': failed_recipients,
'remaining_quota': max(0, daily_limit - user_pref.fire_email_today_count)
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Fire email error: {str(e)}")
return jsonify({'error': '發送緊急通知失敗'}), 500
@notifications_bp.route('/digest', methods=['POST'])
@jwt_required()
def send_digest():
"""Send digest email to user"""
try:
identity = get_jwt_identity()
data = request.get_json()
digest_type = data.get('type', 'weekly') # daily, weekly, monthly
# 檢查使用者偏好
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref or not user_pref.email_reminder_enabled:
return jsonify({'error': '郵件通知未啟用'}), 400
# 準備摘要資料
notification_service = NotificationService()
digest_data = notification_service.prepare_digest(identity, digest_type)
# 發送摘要郵件
email_service = EmailService()
success = email_service.send_digest_email(identity, digest_data)
if success:
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='DIGEST_EMAIL',
detail={'type': digest_type}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Digest email sent to {identity}: {digest_type}")
return jsonify({'message': '摘要郵件已發送'}), 200
else:
return jsonify({'error': '摘要郵件發送失敗'}), 500
except Exception as e:
logger.error(f"Digest email error: {str(e)}")
return jsonify({'error': '摘要郵件發送失敗'}), 500
@notifications_bp.route('/reminders/send', methods=['POST'])
@jwt_required()
def send_reminders():
"""Send reminder emails for due/overdue todos"""
try:
identity = get_jwt_identity()
# 管理員權限檢查 (簡化版本,實際應該檢查 AD 群組)
# TODO: 實作適當的管理員權限檢查
# 查找需要提醒的待辦事項
today = date.today()
tomorrow = today + timedelta(days=1)
# 即將到期的待辦事項 (明天到期)
due_tomorrow = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date == tomorrow,
TodoItem.status != 'DONE'
)
).all()
# 已逾期的待辦事項
overdue = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).all()
email_service = EmailService()
notification_service = NotificationService()
sent_count = 0
# 處理即將到期的提醒
for todo in due_tomorrow:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
if email_service.send_reminder_email(todo, recipient, 'due_tomorrow'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send due tomorrow reminder to {recipient}: {str(e)}")
# 處理逾期提醒
for todo in overdue:
recipients = notification_service.get_notification_recipients(todo)
for recipient in recipients:
try:
if email_service.send_reminder_email(todo, recipient, 'overdue'):
sent_count += 1
except Exception as e:
logger.error(f"Failed to send overdue reminder to {recipient}: {str(e)}")
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='BULK_REMINDER',
detail={
'due_tomorrow_count': len(due_tomorrow),
'overdue_count': len(overdue),
'emails_sent': sent_count
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Reminders sent by {identity}: {sent_count} emails sent")
return jsonify({
'emails_sent': sent_count,
'due_tomorrow': len(due_tomorrow),
'overdue': len(overdue)
}), 200
except Exception as e:
logger.error(f"Bulk reminder error: {str(e)}")
return jsonify({'error': '批量提醒發送失敗'}), 500
@notifications_bp.route('/settings', methods=['GET'])
@jwt_required()
def get_notification_settings():
"""Get user notification settings"""
try:
identity = get_jwt_identity()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
settings = {
'email_reminder_enabled': user_pref.email_reminder_enabled,
'notification_enabled': user_pref.notification_enabled,
'weekly_summary_enabled': user_pref.weekly_summary_enabled,
'monthly_summary_enabled': getattr(user_pref, 'monthly_summary_enabled', False),
'reminder_days_before': getattr(user_pref, 'reminder_days_before', [1, 3]),
'daily_summary_time': getattr(user_pref, 'daily_summary_time', '09:00'),
'weekly_summary_time': getattr(user_pref, 'weekly_summary_time', '09:00'),
'monthly_summary_time': getattr(user_pref, 'monthly_summary_time', '09:00'),
'weekly_summary_day': getattr(user_pref, 'weekly_summary_day', 1),
'monthly_summary_day': getattr(user_pref, 'monthly_summary_day', 1),
'fire_email_quota': {
'used_today': user_pref.fire_email_today_count,
'daily_limit': current_app.config.get('FIRE_EMAIL_DAILY_LIMIT', 3),
'last_reset': user_pref.fire_email_last_reset.isoformat() if user_pref.fire_email_last_reset else None
}
}
return jsonify(settings), 200
except Exception as e:
logger.error(f"Error fetching notification settings: {str(e)}")
return jsonify({'error': '取得通知設定失敗'}), 500
@notifications_bp.route('/settings', methods=['PATCH'])
@jwt_required()
def update_notification_settings():
"""Update user notification settings"""
try:
identity = get_jwt_identity()
data = request.get_json()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
# 更新允許的欄位
if 'email_reminder_enabled' in data:
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
if 'notification_enabled' in data:
user_pref.notification_enabled = bool(data['notification_enabled'])
if 'weekly_summary_enabled' in data:
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
if 'monthly_summary_enabled' in data:
user_pref.monthly_summary_enabled = bool(data['monthly_summary_enabled'])
if 'reminder_days_before' in data and isinstance(data['reminder_days_before'], list):
user_pref.reminder_days_before = data['reminder_days_before']
if 'weekly_summary_time' in data:
user_pref.weekly_summary_time = str(data['weekly_summary_time'])
if 'monthly_summary_time' in data:
user_pref.monthly_summary_time = str(data['monthly_summary_time'])
if 'weekly_summary_day' in data:
user_pref.weekly_summary_day = int(data['weekly_summary_day'])
if 'monthly_summary_day' in data:
user_pref.monthly_summary_day = int(data['monthly_summary_day'])
user_pref.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Notification settings updated for {identity}")
return jsonify({'message': '通知設定已更新'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error updating notification settings: {str(e)}")
return jsonify({'error': '更新通知設定失敗'}), 500
@notifications_bp.route('/test', methods=['POST'])
@jwt_required()
def test_notification():
"""Send test notification email"""
try:
identity = get_jwt_identity()
data = request.get_json() or {}
# 檢查是否有直接指定的郵件地址
recipient_email = data.get('recipient_email')
email_service = EmailService()
if recipient_email:
# 直接發送到指定郵件地址
success = email_service.send_test_email_direct(recipient_email)
recipient_info = recipient_email
else:
# 使用 AD 帳號查詢
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': '找不到使用者設定'}), 404
success = email_service.send_test_email(identity)
recipient_info = identity
if success:
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='MAIL_SENT',
detail={'recipient': recipient_info, 'type': 'test_email'}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Test email sent to {recipient_info}")
return jsonify({'message': '測試郵件已發送'}), 200
else:
return jsonify({'error': '測試郵件發送失敗'}), 500
except Exception as e:
logger.error(f"Test email error: {str(e)}")
return jsonify({'error': '測試郵件發送失敗'}), 500
@notifications_bp.route('/mark-read', methods=['POST'])
@jwt_required()
def mark_notification_read():
"""Mark single notification as read"""
try:
identity = get_jwt_identity()
data = request.get_json()
notification_id = data.get('notification_id')
if not notification_id:
return jsonify({'error': '通知ID不能為空'}), 400
# 這裡可以實作將已讀狀態存在 Redis 或 database 中
# 暫時返回成功,實際可以儲存在用戶的已讀列表中
logger.info(f"Marked notification {notification_id} as read for user {identity}")
return jsonify({'message': '已標記為已讀'}), 200
except Exception as e:
logger.error(f"Mark notification read error: {str(e)}")
return jsonify({'error': '標記已讀失敗'}), 500
@notifications_bp.route('/mark-all-read', methods=['POST'])
@jwt_required()
def mark_all_notifications_read():
"""Mark all notifications as read"""
try:
identity = get_jwt_identity()
# 這裡可以實作將所有通知標記為已讀
# 暫時返回成功
logger.info(f"Marked all notifications as read for user {identity}")
return jsonify({'message': '已將所有通知標記為已讀'}), 200
except Exception as e:
logger.error(f"Mark all notifications read error: {str(e)}")
return jsonify({'error': '標記全部已讀失敗'}), 500
@notifications_bp.route('/view-todo/<todo_id>', methods=['GET'])
@jwt_required()
def view_todo_from_notification():
"""Get todo details from notification click"""
try:
identity = get_jwt_identity()
# 這裡暫時返回成功,前端可以導航到對應的 todo
return jsonify({'message': '導航到待辦事項'}), 200
except Exception as e:
logger.error(f"View todo from notification error: {str(e)}")
return jsonify({'error': '查看待辦事項失敗'}), 500

372
backend/routes/reports.py Normal file
View 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

261
backend/routes/scheduler.py Normal file
View File

@@ -0,0 +1,261 @@
"""
Scheduler API Routes
處理排程任務的管理和監控功能
"""
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date, timedelta
from sqlalchemy import and_, or_
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoUserPref, TodoAuditLog
)
from utils.logger import get_logger
from utils.email_service import EmailService
from utils.notification_service import NotificationService
from tasks_simple import send_daily_reminders, send_weekly_summary, cleanup_old_logs
import json
scheduler_bp = Blueprint('scheduler', __name__)
logger = get_logger(__name__)
@scheduler_bp.route('/trigger-daily-reminders', methods=['POST'])
@jwt_required()
def trigger_daily_reminders():
"""手動觸發每日提醒(管理員功能)"""
try:
identity = get_jwt_identity()
# TODO: 實作管理員權限檢查
# 這裡應該檢查用戶是否為管理員
# 直接執行任務
result = send_daily_reminders()
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='MANUAL_REMINDER',
detail={
'result': result,
'triggered_by': identity
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Daily reminders executed manually by {identity}")
return jsonify({
'message': '每日提醒任務已執行',
'result': result
}), 200
except Exception as e:
logger.error(f"Error triggering daily reminders: {str(e)}")
return jsonify({'error': '觸發每日提醒失敗'}), 500
@scheduler_bp.route('/trigger-weekly-summary', methods=['POST'])
@jwt_required()
def trigger_weekly_summary():
"""手動觸發週報發送(管理員功能)"""
try:
identity = get_jwt_identity()
# TODO: 實作管理員權限檢查
# 直接執行任務
result = send_weekly_summary()
# 記錄稽核日誌
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None,
action='MANUAL_SUMMARY',
detail={
'result': result,
'triggered_by': identity
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Weekly summary executed manually by {identity}")
return jsonify({
'message': '週報發送任務已執行',
'result': result
}), 200
except Exception as e:
logger.error(f"Error triggering weekly summary: {str(e)}")
return jsonify({'error': '觸發週報發送失敗'}), 500
@scheduler_bp.route('/task-status/<task_id>', methods=['GET'])
@jwt_required()
def get_task_status(task_id):
"""取得任務狀態(簡化版本)"""
try:
# 在簡化版本中,任務是同步執行的,所以狀態總是 completed
return jsonify({
'task_id': task_id,
'status': 'completed',
'message': '任務已同步執行完成'
}), 200
except Exception as e:
logger.error(f"Error getting task status: {str(e)}")
return jsonify({'error': '取得任務狀態失敗'}), 500
@scheduler_bp.route('/scheduled-jobs', methods=['GET'])
@jwt_required()
def get_scheduled_jobs():
"""取得排程任務列表和狀態"""
try:
# 這裡可以返回 Celery Beat 的排程資訊
# 簡化版本,返回配置的排程任務
jobs = [
{
'name': 'daily-reminders',
'description': '每日提醒郵件',
'schedule': '每日早上9點',
'status': 'active',
'last_run': None # TODO: 從 Celery 取得實際執行時間
},
{
'name': 'weekly-summary',
'description': '每週摘要報告',
'schedule': '每週一早上9點',
'status': 'active',
'last_run': None # TODO: 從 Celery 取得實際執行時間
},
{
'name': 'cleanup-logs',
'description': '清理舊日誌',
'schedule': '每週執行一次',
'status': 'active',
'last_run': None # TODO: 從 Celery 取得實際執行時間
}
]
return jsonify({'jobs': jobs}), 200
except Exception as e:
logger.error(f"Error getting scheduled jobs: {str(e)}")
return jsonify({'error': '取得排程任務列表失敗'}), 500
@scheduler_bp.route('/statistics', methods=['GET'])
@jwt_required()
def get_scheduler_statistics():
"""取得排程系統統計資訊"""
try:
identity = get_jwt_identity()
# 統計最近一週的自動化任務執行記錄
week_ago = datetime.utcnow() - timedelta(days=7)
auto_tasks = TodoAuditLog.query.filter(
and_(
TodoAuditLog.actor_ad == 'system',
TodoAuditLog.created_at >= week_ago,
TodoAuditLog.action.in_(['DAILY_REMINDER', 'WEEKLY_SUMMARY'])
)
).all()
# 統計手動觸發的任務
manual_tasks = TodoAuditLog.query.filter(
and_(
TodoAuditLog.created_at >= week_ago,
TodoAuditLog.action.in_(['MANUAL_REMINDER', 'MANUAL_SUMMARY'])
)
).all()
# 統計郵件發送情況
email_stats = {}
for task in auto_tasks:
if task.detail:
task_type = task.action.lower()
if 'emails_sent' in task.detail:
if task_type not in email_stats:
email_stats[task_type] = {'count': 0, 'emails': 0}
email_stats[task_type]['count'] += 1
email_stats[task_type]['emails'] += task.detail['emails_sent']
statistics = {
'recent_activity': {
'auto_tasks_count': len(auto_tasks),
'manual_tasks_count': len(manual_tasks),
'email_stats': email_stats
},
'system_health': {
'celery_status': 'running', # TODO: 實際檢查 Celery 狀態
'redis_status': 'connected', # TODO: 實際檢查 Redis 狀態
'last_daily_reminder': None, # TODO: 從記錄中取得
'last_weekly_summary': None # TODO: 從記錄中取得
}
}
return jsonify(statistics), 200
except Exception as e:
logger.error(f"Error getting scheduler statistics: {str(e)}")
return jsonify({'error': '取得排程統計資訊失敗'}), 500
@scheduler_bp.route('/preview-reminders', methods=['GET'])
@jwt_required()
def preview_reminders():
"""預覽即將發送的提醒郵件"""
try:
today = date.today()
tomorrow = today + timedelta(days=1)
# 查找明日到期的待辦事項
due_tomorrow = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date == tomorrow,
TodoItem.status != 'DONE'
)
).all()
# 查找已逾期的待辦事項
overdue = db.session.query(TodoItem).filter(
and_(
TodoItem.due_date < today,
TodoItem.status != 'DONE'
)
).all()
# 統計會收到提醒的使用者
notification_service = NotificationService()
due_tomorrow_recipients = set()
overdue_recipients = set()
for todo in due_tomorrow:
recipients = notification_service.get_notification_recipients(todo)
due_tomorrow_recipients.update(recipients)
for todo in overdue:
recipients = notification_service.get_notification_recipients(todo)
overdue_recipients.update(recipients)
preview = {
'due_tomorrow': {
'todos_count': len(due_tomorrow),
'recipients_count': len(due_tomorrow_recipients),
'todos': [todo.to_dict() for todo in due_tomorrow[:5]] # 只顯示前5個
},
'overdue': {
'todos_count': len(overdue),
'recipients_count': len(overdue_recipients),
'todos': [todo.to_dict() for todo in overdue[:5]] # 只顯示前5個
},
'total_emails': len(due_tomorrow_recipients) + len(overdue_recipients)
}
return jsonify(preview), 200
except Exception as e:
logger.error(f"Error previewing reminders: {str(e)}")
return jsonify({'error': '預覽提醒郵件失敗'}), 500

709
backend/routes/todos.py Normal file
View File

@@ -0,0 +1,709 @@
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity, get_jwt
from datetime import datetime, date, timedelta
from sqlalchemy import or_, and_
from sqlalchemy.orm import selectinload, joinedload
from models import (
db, TodoItem, TodoItemResponsible, TodoItemFollower,
TodoAuditLog, TodoUserPref
)
from utils.logger import get_logger
from utils.ldap_utils import validate_ad_accounts
import uuid
todos_bp = Blueprint('todos', __name__)
logger = get_logger(__name__)
@todos_bp.route('', methods=['GET'])
@jwt_required()
def get_todos():
"""Get todos with filtering and pagination"""
try:
identity = get_jwt_identity()
page = request.args.get('page', 1, type=int)
per_page = request.args.get('per_page', 20, type=int)
# Filters
status = request.args.get('status')
priority = request.args.get('priority')
starred = request.args.get('starred', type=bool)
due_from = request.args.get('due_from')
due_to = request.args.get('due_to')
search = request.args.get('search')
view_type = request.args.get('view', 'all') # all, created, responsible, following
# Base query with eager loading to prevent N+1 queries
query = TodoItem.query.options(
joinedload(TodoItem.responsible_users),
joinedload(TodoItem.followers)
)
# Apply view type filter
if view_type == 'created':
query = query.filter(TodoItem.creator_ad == identity)
elif view_type == 'responsible':
query = query.join(TodoItemResponsible).filter(
TodoItemResponsible.ad_account == identity
)
elif view_type == 'following':
query = query.join(TodoItemFollower).filter(
TodoItemFollower.ad_account == identity
)
else: # all
query = query.filter(
or_(
TodoItem.creator_ad == identity,
TodoItem.responsible_users.any(TodoItemResponsible.ad_account == identity),
TodoItem.followers.any(TodoItemFollower.ad_account == identity)
)
)
# Apply filters
if status:
query = query.filter(TodoItem.status == status)
if priority:
query = query.filter(TodoItem.priority == priority)
if starred is not None:
query = query.filter(TodoItem.starred == starred)
if due_from:
query = query.filter(TodoItem.due_date >= datetime.strptime(due_from, '%Y-%m-%d').date())
if due_to:
query = query.filter(TodoItem.due_date <= datetime.strptime(due_to, '%Y-%m-%d').date())
if search:
query = query.filter(
or_(
TodoItem.title.contains(search),
TodoItem.description.contains(search)
)
)
# Order by due date and priority (MySQL compatible)
query = query.order_by(
TodoItem.due_date.asc(),
TodoItem.priority.desc(),
TodoItem.created_at.desc()
)
# Paginate
pagination = query.paginate(page=page, per_page=per_page, error_out=False)
todos = [todo.to_dict() for todo in pagination.items]
return jsonify({
'todos': todos,
'total': pagination.total,
'page': page,
'per_page': per_page,
'pages': pagination.pages
}), 200
except Exception as e:
logger.error(f"Error fetching todos: {str(e)}")
return jsonify({'error': 'Failed to fetch todos'}), 500
@todos_bp.route('/<todo_id>', methods=['GET'])
@jwt_required()
def get_todo(todo_id):
"""Get single todo details"""
try:
identity = get_jwt_identity()
todo = TodoItem.query.options(
joinedload(TodoItem.responsible_users),
joinedload(TodoItem.followers)
).filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_view(identity):
return jsonify({'error': 'Access denied'}), 403
return jsonify(todo.to_dict()), 200
except Exception as e:
logger.error(f"Error fetching todo {todo_id}: {str(e)}")
return jsonify({'error': 'Failed to fetch todo'}), 500
@todos_bp.route('', methods=['POST'])
@jwt_required()
def create_todo():
"""Create new todo"""
try:
identity = get_jwt_identity()
claims = get_jwt()
data = request.get_json()
# Validate required fields
if not data.get('title'):
return jsonify({'error': 'Title is required'}), 400
# Parse due date if provided
due_date = None
if data.get('due_date'):
try:
due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
except ValueError:
return jsonify({'error': 'Invalid due date format. Use YYYY-MM-DD'}), 400
# Create todo
todo = TodoItem(
id=str(uuid.uuid4()),
title=data['title'],
description=data.get('description', ''),
status=data.get('status', 'NEW'),
priority=data.get('priority', 'MEDIUM'),
due_date=due_date,
creator_ad=identity,
creator_display_name=claims.get('display_name', identity),
creator_email=claims.get('email', ''),
starred=data.get('starred', False)
)
db.session.add(todo)
# Add responsible users
responsible_accounts = data.get('responsible_users', [])
if responsible_accounts:
valid_accounts = validate_ad_accounts(responsible_accounts)
for account in responsible_accounts:
if account in valid_accounts:
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(responsible)
# Add followers
follower_accounts = data.get('followers', [])
if follower_accounts:
valid_accounts = validate_ad_accounts(follower_accounts)
for account in follower_accounts:
if account in valid_accounts:
follower = TodoItemFollower(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(follower)
# Add audit log
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='CREATE',
detail={'title': todo.title, 'due_date': str(due_date) if due_date else None}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Todo created: {todo.id} by {identity}")
return jsonify(todo.to_dict()), 201
except Exception as e:
db.session.rollback()
logger.error(f"Error creating todo: {str(e)}")
return jsonify({'error': 'Failed to create todo'}), 500
@todos_bp.route('/<todo_id>', methods=['PATCH'])
@jwt_required()
def update_todo(todo_id):
"""Update todo"""
try:
identity = get_jwt_identity()
data = request.get_json()
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_edit(identity):
return jsonify({'error': 'Access denied'}), 403
# Track changes for audit
changes = {}
# Update fields
if 'title' in data:
changes['title'] = {'old': todo.title, 'new': data['title']}
todo.title = data['title']
if 'description' in data:
changes['description'] = {'old': todo.description, 'new': data['description']}
todo.description = data['description']
if 'status' in data:
changes['status'] = {'old': todo.status, 'new': data['status']}
todo.status = data['status']
# Set completed_at if status is DONE
if data['status'] == 'DONE' and not todo.completed_at:
todo.completed_at = datetime.utcnow()
elif data['status'] != 'DONE':
todo.completed_at = None
if 'priority' in data:
changes['priority'] = {'old': todo.priority, 'new': data['priority']}
todo.priority = data['priority']
if 'due_date' in data:
old_due = str(todo.due_date) if todo.due_date else None
if data['due_date']:
todo.due_date = datetime.strptime(data['due_date'], '%Y-%m-%d').date()
new_due = data['due_date']
else:
todo.due_date = None
new_due = None
changes['due_date'] = {'old': old_due, 'new': new_due}
if 'starred' in data:
changes['starred'] = {'old': todo.starred, 'new': data['starred']}
todo.starred = data['starred']
# Update responsible users
if 'responsible_users' in data:
# Remove existing
TodoItemResponsible.query.filter_by(todo_id=todo_id).delete()
# Add new
responsible_accounts = data['responsible_users']
if responsible_accounts:
valid_accounts = validate_ad_accounts(responsible_accounts)
for account in responsible_accounts:
if account in valid_accounts:
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(responsible)
changes['responsible_users'] = data['responsible_users']
# Update followers
if 'followers' in data:
# Remove existing
TodoItemFollower.query.filter_by(todo_id=todo_id).delete()
# Add new
follower_accounts = data['followers']
if follower_accounts:
valid_accounts = validate_ad_accounts(follower_accounts)
for account in follower_accounts:
if account in valid_accounts:
follower = TodoItemFollower(
todo_id=todo.id,
ad_account=account,
added_by=identity
)
db.session.add(follower)
changes['followers'] = data['followers']
# Add audit log
if changes:
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='UPDATE',
detail=changes
)
db.session.add(audit)
db.session.commit()
logger.info(f"Todo updated: {todo_id} by {identity}")
return jsonify(todo.to_dict()), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error updating todo {todo_id}: {str(e)}")
return jsonify({'error': 'Failed to update todo'}), 500
@todos_bp.route('/<todo_id>', methods=['DELETE'])
@jwt_required()
def delete_todo(todo_id):
"""Delete todo"""
try:
identity = get_jwt_identity()
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Only creator can delete
if todo.creator_ad != identity:
return jsonify({'error': 'Only creator can delete todo'}), 403
# Add audit log before deletion
audit = TodoAuditLog(
actor_ad=identity,
todo_id=None, # Will be null after deletion
action='DELETE',
detail={'title': todo.title, 'deleted_todo_id': todo_id}
)
db.session.add(audit)
# Delete todo (cascades will handle related records)
db.session.delete(todo)
db.session.commit()
logger.info(f"Todo deleted: {todo_id} by {identity}")
return jsonify({'message': 'Todo deleted successfully'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error deleting todo {todo_id}: {str(e)}")
return jsonify({'error': 'Failed to delete todo'}), 500
@todos_bp.route('/batch', methods=['PATCH'])
@jwt_required()
def batch_update_todos():
"""Batch update multiple todos"""
try:
identity = get_jwt_identity()
data = request.get_json()
todo_ids = data.get('todo_ids', [])
updates = data.get('updates', {})
if not todo_ids or not updates:
return jsonify({'error': 'Todo IDs and updates required'}), 400
updated_count = 0
errors = []
for todo_id in todo_ids:
try:
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
errors.append({'todo_id': todo_id, 'error': 'Not found'})
continue
if not todo.can_edit(identity):
errors.append({'todo_id': todo_id, 'error': 'Access denied'})
continue
# Apply updates
if 'status' in updates:
todo.status = updates['status']
if updates['status'] == 'DONE':
todo.completed_at = datetime.utcnow()
else:
todo.completed_at = None
if 'priority' in updates:
todo.priority = updates['priority']
if 'due_date' in updates:
if updates['due_date']:
todo.due_date = datetime.strptime(updates['due_date'], '%Y-%m-%d').date()
else:
todo.due_date = None
# Add audit log
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo.id,
action='UPDATE',
detail={'batch_update': updates}
)
db.session.add(audit)
updated_count += 1
except Exception as e:
errors.append({'todo_id': todo_id, 'error': str(e)})
db.session.commit()
logger.info(f"Batch update: {updated_count} todos updated by {identity}")
return jsonify({
'updated': updated_count,
'errors': errors
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error in batch update: {str(e)}")
return jsonify({'error': 'Batch update failed'}), 500
@todos_bp.route('/<todo_id>/responsible', methods=['POST'])
@jwt_required()
def add_responsible_user(todo_id):
"""Add responsible user to todo"""
try:
identity = get_jwt_identity()
data = request.get_json()
if not data or 'ad_account' not in data:
return jsonify({'error': 'AD account is required'}), 400
ad_account = data['ad_account']
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_edit(identity):
return jsonify({'error': 'No permission to edit this todo'}), 403
# Validate AD account
valid_accounts = validate_ad_accounts([ad_account])
if ad_account not in valid_accounts:
return jsonify({'error': 'Invalid AD account'}), 400
# Check if already responsible
existing = TodoItemResponsible.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if existing:
return jsonify({'error': 'User is already responsible for this todo'}), 400
# Add responsible user
responsible = TodoItemResponsible(
todo_id=todo_id,
ad_account=ad_account,
added_by=identity
)
db.session.add(responsible)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'responsible_users',
'action': 'add',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Added responsible user {ad_account} to todo {todo_id} by {identity}")
return jsonify({'message': 'Responsible user added successfully'}), 201
except Exception as e:
db.session.rollback()
logger.error(f"Add responsible user error: {str(e)}")
return jsonify({'error': 'Failed to add responsible user'}), 500
@todos_bp.route('/<todo_id>/responsible/<ad_account>', methods=['DELETE'])
@jwt_required()
def remove_responsible_user(todo_id, ad_account):
"""Remove responsible user from todo"""
try:
identity = get_jwt_identity()
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_edit(identity):
return jsonify({'error': 'No permission to edit this todo'}), 403
# Find responsible relationship
responsible = TodoItemResponsible.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if not responsible:
return jsonify({'error': 'User is not responsible for this todo'}), 404
# Remove responsible user
db.session.delete(responsible)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'responsible_users',
'action': 'remove',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Removed responsible user {ad_account} from todo {todo_id} by {identity}")
return jsonify({'message': 'Responsible user removed successfully'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Remove responsible user error: {str(e)}")
return jsonify({'error': 'Failed to remove responsible user'}), 500
@todos_bp.route('/<todo_id>/followers', methods=['POST'])
@jwt_required()
def add_follower(todo_id):
"""Add follower to todo"""
try:
identity = get_jwt_identity()
data = request.get_json()
if not data or 'ad_account' not in data:
return jsonify({'error': 'AD account is required'}), 400
ad_account = data['ad_account']
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission (anyone who can view the todo can add followers)
if not todo.can_view(identity):
return jsonify({'error': 'No permission to view this todo'}), 403
# Validate AD account
valid_accounts = validate_ad_accounts([ad_account])
if ad_account not in valid_accounts:
return jsonify({'error': 'Invalid AD account'}), 400
# Check if already following
existing = TodoItemFollower.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if existing:
return jsonify({'error': 'User is already following this todo'}), 400
# Add follower
follower = TodoItemFollower(
todo_id=todo_id,
ad_account=ad_account,
added_by=identity
)
db.session.add(follower)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'followers',
'action': 'add',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Added follower {ad_account} to todo {todo_id} by {identity}")
return jsonify({'message': 'Follower added successfully'}), 201
except Exception as e:
db.session.rollback()
logger.error(f"Add follower error: {str(e)}")
return jsonify({'error': 'Failed to add follower'}), 500
@todos_bp.route('/<todo_id>/followers/<ad_account>', methods=['DELETE'])
@jwt_required()
def remove_follower(todo_id, ad_account):
"""Remove follower from todo"""
try:
identity = get_jwt_identity()
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission (user can remove themselves or todo editors can remove anyone)
if ad_account != identity and not todo.can_edit(identity):
return jsonify({'error': 'No permission to remove this follower'}), 403
# Find follower relationship
follower = TodoItemFollower.query.filter_by(
todo_id=todo_id, ad_account=ad_account
).first()
if not follower:
return jsonify({'error': 'User is not following this todo'}), 404
# Remove follower
db.session.delete(follower)
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'followers',
'action': 'remove',
'ad_account': ad_account
}
)
db.session.add(audit)
db.session.commit()
logger.info(f"Removed follower {ad_account} from todo {todo_id} by {identity}")
return jsonify({'message': 'Follower removed successfully'}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Remove follower error: {str(e)}")
return jsonify({'error': 'Failed to remove follower'}), 500
@todos_bp.route('/<todo_id>/star', methods=['POST'])
@jwt_required()
def star_todo(todo_id):
"""Star/unstar a todo item"""
try:
identity = get_jwt_identity()
# Get todo
todo = TodoItem.query.filter_by(id=todo_id).first()
if not todo:
return jsonify({'error': 'Todo not found'}), 404
# Check permission
if not todo.can_view(identity):
return jsonify({'error': 'No permission to view this todo'}), 403
# Only creator can star/unstar
if todo.creator_ad != identity:
return jsonify({'error': 'Only creator can star/unstar todos'}), 403
# Toggle star status
todo.starred = not todo.starred
# Log audit
audit = TodoAuditLog(
actor_ad=identity,
todo_id=todo_id,
action='UPDATE',
detail={
'field': 'starred',
'value': todo.starred
}
)
db.session.add(audit)
db.session.commit()
action = 'starred' if todo.starred else 'unstarred'
logger.info(f"Todo {todo_id} {action} by {identity}")
return jsonify({
'message': f'Todo {action} successfully',
'starred': todo.starred
}), 200
except Exception as e:
db.session.rollback()
logger.error(f"Star todo error: {str(e)}")
return jsonify({'error': 'Failed to star todo'}), 500

128
backend/routes/users.py Normal file
View File

@@ -0,0 +1,128 @@
from flask import Blueprint, request, jsonify, current_app
from flask_jwt_extended import jwt_required, get_jwt_identity
from datetime import datetime, date
from models import db, TodoUserPref
from utils.logger import get_logger
users_bp = Blueprint('users', __name__)
logger = get_logger(__name__)
@users_bp.route('/search', methods=['GET'])
@jwt_required()
def search_users():
"""Search for AD users"""
try:
search_term = request.args.get('q', '').strip()
if len(search_term) < 1:
return jsonify({'error': 'Search term cannot be empty'}), 400
# Search LDAP (or mock for development)
try:
if current_app.config.get('USE_MOCK_LDAP', False):
from utils.mock_ldap import search_ldap_principals
else:
from utils.ldap_utils import search_ldap_principals
results = search_ldap_principals(search_term, limit=20)
except Exception as e:
logger.error(f"LDAP search error, falling back to mock: {str(e)}")
from utils.mock_ldap import search_ldap_principals
results = search_ldap_principals(search_term, limit=20)
return jsonify({'users': results}), 200
except Exception as e:
logger.error(f"User search error: {str(e)}")
return jsonify({'error': 'Search failed'}), 500
@users_bp.route('/preferences', methods=['GET'])
@jwt_required()
def get_preferences():
"""Get user preferences"""
try:
identity = get_jwt_identity()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User preferences not found'}), 404
return jsonify(user_pref.to_dict()), 200
except Exception as e:
logger.error(f"Error fetching preferences: {str(e)}")
return jsonify({'error': 'Failed to fetch preferences'}), 500
@users_bp.route('/preferences', methods=['PATCH'])
@jwt_required()
def update_preferences():
"""Update user preferences"""
try:
identity = get_jwt_identity()
data = request.get_json()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User preferences not found'}), 404
# Update allowed fields
if 'theme' in data and data['theme'] in ['light', 'dark', 'auto']:
user_pref.theme = data['theme']
if 'language' in data:
user_pref.language = data['language']
if 'timezone' in data:
user_pref.timezone = data['timezone']
if 'notification_enabled' in data:
user_pref.notification_enabled = bool(data['notification_enabled'])
if 'email_reminder_enabled' in data:
user_pref.email_reminder_enabled = bool(data['email_reminder_enabled'])
if 'weekly_summary_enabled' in data:
user_pref.weekly_summary_enabled = bool(data['weekly_summary_enabled'])
user_pref.updated_at = datetime.utcnow()
db.session.commit()
logger.info(f"Preferences updated for user: {identity}")
return jsonify(user_pref.to_dict()), 200
except Exception as e:
db.session.rollback()
logger.error(f"Error updating preferences: {str(e)}")
return jsonify({'error': 'Failed to update preferences'}), 500
@users_bp.route('/fire-email-quota', methods=['GET'])
@jwt_required()
def get_fire_email_quota():
"""Get user's fire email quota for today"""
try:
identity = get_jwt_identity()
user_pref = TodoUserPref.query.filter_by(ad_account=identity).first()
if not user_pref:
return jsonify({'error': 'User not found'}), 404
# Reset counter if it's a new day
today = date.today()
if user_pref.fire_email_last_reset != today:
user_pref.fire_email_today_count = 0
user_pref.fire_email_last_reset = today
db.session.commit()
from flask import current_app
daily_limit = current_app.config['FIRE_EMAIL_DAILY_LIMIT']
return jsonify({
'used': user_pref.fire_email_today_count,
'limit': daily_limit,
'remaining': max(0, daily_limit - user_pref.fire_email_today_count)
}), 200
except Exception as e:
logger.error(f"Error fetching fire email quota: {str(e)}")
return jsonify({'error': 'Failed to fetch quota'}), 500