from fastapi import APIRouter, HTTPException, Depends from fastapi.responses import StreamingResponse from openpyxl import Workbook, load_workbook from openpyxl.styles import Font, Alignment, Border, Side import io import os from ..database import get_db_cursor from ..config import settings from ..models import TokenPayload from .auth import get_current_user, is_admin router = APIRouter() # Base directory for resolving relative paths BASE_DIR = os.path.join(os.path.dirname(__file__), "..", "..") def fill_template_workbook( wb: Workbook, meeting: dict, conclusions: list, actions: list ) -> Workbook: """Fill Excel template with meeting data using cell coordinates.""" ws = wb.active # Fill meeting metadata using cell coordinates # D3: 會議主題 ws["D3"] = meeting.get("subject", "") # D4: 會議時間 meeting_time = meeting.get("meeting_time") if meeting_time: ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if hasattr(meeting_time, 'strftime') else str(meeting_time) # D5: 會議主席 ws["D5"] = meeting.get("chairperson", "") # F4: 會議地點 ws["F4"] = meeting.get("location", "") # F5: 記錄人員 ws["F5"] = meeting.get("recorder", "") # D6: 會議參與人員 ws["D6"] = meeting.get("attendees", "") # C8: 會議編號 ws["C8"] = meeting.get("meeting_number", "") # D8: 會議結論 (多條用換行合併) if conclusions: conclusion_texts = [c.get("content", "") for c in conclusions] ws["D8"] = "\n".join(conclusion_texts) # Enable text wrap for multi-line content ws["D8"].alignment = Alignment(wrap_text=True, vertical="top") # Fill action items starting from row 10 # C: 編號, D: 內容, F: 負責人, G: 預計完成日期, H: 執行現況 start_row = 10 for i, action in enumerate(actions): row = start_row + i # C column: 代辦事項編號 ws[f"C{row}"] = action.get("system_code", "") # D column: 事項內容 ws[f"D{row}"] = action.get("content", "") # F column: 負責人 ws[f"F{row}"] = action.get("owner", "") # G column: 預計完成日期 due_date = action.get("due_date") if due_date: ws[f"G{row}"] = due_date.strftime("%Y-%m-%d") if hasattr(due_date, 'strftime') else str(due_date) else: ws[f"G{row}"] = "" # H column: 執行現況 (status) ws[f"H{row}"] = action.get("status", "") return wb def create_default_workbook(meeting: dict, conclusions: list, actions: list) -> Workbook: """Create Excel workbook with meeting data (fallback when no template).""" wb = Workbook() ws = wb.active ws.title = "Meeting Record" # Styles header_font = Font(bold=True, size=14) label_font = Font(bold=True) thin_border = Border( left=Side(style="thin"), right=Side(style="thin"), top=Side(style="thin"), bottom=Side(style="thin"), ) # Title ws.merge_cells("A1:H1") ws["A1"] = "會議紀錄" ws["A1"].font = Font(bold=True, size=16) ws["A1"].alignment = Alignment(horizontal="center") # Metadata section ws["C3"] = "會議主題:" ws["D3"] = meeting.get("subject", "") ws["C4"] = "會議時間:" meeting_time = meeting.get("meeting_time") ws["D4"] = meeting_time.strftime("%Y-%m-%d %H:%M") if meeting_time and hasattr(meeting_time, 'strftime') else str(meeting_time or "") ws["C5"] = "會議主席:" ws["D5"] = meeting.get("chairperson", "") ws["E4"] = "會議地點:" ws["F4"] = meeting.get("location", "") ws["E5"] = "記錄人員:" ws["F5"] = meeting.get("recorder", "") ws["C6"] = "參與人員:" ws["D6"] = meeting.get("attendees", "") # Meeting number and conclusions ws["C8"] = meeting.get("meeting_number", "") ws["C8"].font = label_font if conclusions: conclusion_texts = [c.get("content", "") for c in conclusions] ws["D8"] = "\n".join(conclusion_texts) ws["D8"].alignment = Alignment(wrap_text=True, vertical="top") # Action Items header row = 9 headers = ["編號", "事項內容", "", "負責人", "預計完成日期", "執行現況"] cols = ["C", "D", "E", "F", "G", "H"] for col, header in zip(cols, headers): ws[f"{col}{row}"] = header ws[f"{col}{row}"].font = label_font ws[f"{col}{row}"].border = thin_border # Action Items data start_row = 10 for i, action in enumerate(actions): r = start_row + i ws[f"C{r}"] = action.get("system_code", "") ws[f"C{r}"].border = thin_border ws[f"D{r}"] = action.get("content", "") ws[f"D{r}"].border = thin_border ws[f"F{r}"] = action.get("owner", "") ws[f"F{r}"].border = thin_border due_date = action.get("due_date") ws[f"G{r}"] = due_date.strftime("%Y-%m-%d") if due_date and hasattr(due_date, 'strftime') else str(due_date or "") ws[f"G{r}"].border = thin_border ws[f"H{r}"] = action.get("status", "") ws[f"H{r}"].border = thin_border # Adjust column widths ws.column_dimensions["C"].width = 18 ws.column_dimensions["D"].width = 40 ws.column_dimensions["E"].width = 5 ws.column_dimensions["F"].width = 15 ws.column_dimensions["G"].width = 15 ws.column_dimensions["H"].width = 15 return wb @router.get("/meetings/{meeting_id}/export") async def export_meeting( meeting_id: int, current_user: TokenPayload = Depends(get_current_user) ): """Export meeting to Excel file using template.""" with get_db_cursor() as cursor: cursor.execute( "SELECT * FROM meeting_records WHERE meeting_id = %s", (meeting_id,) ) meeting = cursor.fetchone() if not meeting: raise HTTPException(status_code=404, detail="Meeting not found") # Check access if not is_admin(current_user): if ( meeting["created_by"] != current_user.email and meeting["recorder"] != current_user.email and current_user.email not in (meeting["attendees"] or "") ): raise HTTPException(status_code=403, detail="Access denied") # Get conclusions cursor.execute( "SELECT * FROM meeting_conclusions WHERE meeting_id = %s", (meeting_id,) ) conclusions = cursor.fetchall() # Get action items cursor.execute( "SELECT * FROM meeting_action_items WHERE meeting_id = %s", (meeting_id,) ) actions = cursor.fetchall() # Get directory paths from config template_dir = settings.get_template_dir(BASE_DIR) record_dir = settings.get_record_dir(BASE_DIR) # Check for template file template_path = os.path.join(template_dir, "meeting_template.xlsx") if os.path.exists(template_path): # Load and fill template wb = load_workbook(template_path) wb = fill_template_workbook(wb, meeting, conclusions, actions) else: # Use default template generation wb = create_default_workbook(meeting, conclusions, actions) # Generate filename with meeting number meeting_number = meeting.get("meeting_number", "") if meeting_number: filename = f"{meeting_number}.xlsx" else: filename = f"meeting_{meeting.get('uuid', meeting_id)}.xlsx" # Ensure record directory exists os.makedirs(record_dir, exist_ok=True) # Save to record directory record_path = os.path.join(record_dir, filename) wb.save(record_path) # Save to bytes buffer for download buffer = io.BytesIO() wb.save(buffer) buffer.seek(0) return StreamingResponse( buffer, media_type="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet", headers={"Content-Disposition": f'attachment; filename="{filename}"'}, )