""" 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()] # 追蹤人 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': '新建', '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'], '追蹤人': ['user3@panjit.com.tw;user4@panjit.com.tw', 'user5@panjit.com.tw'] } # 說明資料 instructions = { '欄位說明': [ '標題 (必填)', '描述 (選填)', '狀態: 新建/進行中/完成/阻塞', '優先級: 緊急/高/中/低', '到期日: YYYY-MM-DD 格式', '負責人: AD帳號,多人用分號分隔', '追蹤人: AD帳號,多人用分號分隔' ], '說明': [ '請填入待辦事項的標題', '可選填詳細描述', '可選填 NEW/DOING/DONE/BLOCKED', '可選填 URGENT/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