Files
TODO_list_system/backend/routes/excel.py
beabigegg 00061adeb7 6th
2025-09-01 16:42:41 +08:00

516 lines
20 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

"""
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', '進行中': 'DOING', '完成': 'DONE', '阻塞': 'BLOCKED',
'NEW': 'NEW', 'DOING': 'DOING', 'DONE': 'DONE', 'BLOCKED': 'BLOCKED',
'': 'NEW', '進行': 'DOING', '': 'DONE', '': 'BLOCKED'
}
status_str = str(row.get('狀態', row.get('status', 'NEW'))).strip()
status = status_mapping.get(status_str, 'NEW')
# 優先級
priority_mapping = {
'緊急': 'URGENT', '': 'HIGH', '': 'MEDIUM', '': 'LOW',
'URGENT': 'URGENT', 'HIGH': 'HIGH', 'MEDIUM': 'MEDIUM', 'LOW': 'LOW',
'緊急優先級': 'URGENT', '高優先級': '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()]
# 公開設定
is_public_str = str(row.get('公開設定', row.get('is_public', ''))).strip().lower()
is_public = is_public_str in ['', 'yes', 'true', '1', 'y'] if is_public_str and is_public_str != 'nan' else False
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': [], # Excel模板中沒有followers欄位初始化為空陣列
'is_public': is_public
})
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', [])
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
# 建立待辦事項
due_date = None
if todo_data.get('due_date'):
due_date = datetime.strptime(todo_data['due_date'], '%Y-%m-%d').date()
# 處理公開設定
is_public = False # 預設為非公開
if todo_data.get('is_public'):
is_public_str = str(todo_data['is_public']).strip().lower()
is_public = is_public_str in ['', 'yes', 'true', '1', 'y']
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,
is_public=is_public
)
db.session.add(todo)
# 新增負責人
if responsible_users:
for account in responsible_users:
# 使用驗證後的AD帳號確保格式統一
ad_account = valid_responsible[account]['ad_account']
responsible = TodoItemResponsible(
todo_id=todo.id,
ad_account=ad_account,
added_by=identity
)
db.session.add(responsible)
# 因為匯入的待辦事項預設為非公開,所以不支援追蹤人功能
# 新增稽核記錄
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': '新建', 'DOING': '進行中', 'DONE': '完成', 'BLOCKED': '阻塞'}
priority_mapping = {'URGENT': '緊急', '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'],
'描述': ['這是第一個範例的詳細描述', '這是第二個範例的詳細描述'],
'狀態': ['新建', '進行中'],
'優先級': ['', ''],
'到期日': ['2025-12-31', '2026-01-15'],
'負責人': ['user1@panjit.com.tw', 'user2@panjit.com.tw'],
'公開設定': ['', '']
}
# 說明資料
instructions = {
'欄位說明': [
'標題 (必填)',
'描述 (選填)',
'狀態: 新建/進行中/完成/阻塞',
'優先級: 緊急/高/中/低',
'到期日: YYYY-MM-DD 格式',
'負責人: AD帳號多人用分號分隔',
'公開設定: 是/否,決定其他人是否能看到此任務'
],
'說明': [
'請填入待辦事項的標題',
'可選填詳細描述',
'可選填 NEW/DOING/DONE/BLOCKED',
'可選填 URGENT/HIGH/MEDIUM/LOW',
'例如: 2024-12-31',
'例如: john@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