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

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