527 lines
20 KiB
Python
527 lines
20 KiB
Python
"""
|
||
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 |