1ST
This commit is contained in:
527
backend/routes/excel.py
Normal file
527
backend/routes/excel.py
Normal 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
|
Reference in New Issue
Block a user