# -*- coding: utf-8 -*- from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app, jsonify, abort from flask_login import login_required, current_user from datetime import datetime, timedelta from utils.timezone import taiwan_now, format_taiwan_time from models import TempSpec, db, Upload, SpecHistory from utils import editor_or_admin_required, add_history_log, admin_required, send_email, process_recipients from ldap_utils import get_ldap_group_members import os import shutil import jwt import requests from werkzeug.utils import secure_filename from docxtpl import DocxTemplate temp_spec_bp = Blueprint('temp_spec', __name__) BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) # 移除 @login_required @temp_spec_bp.before_request def before_request(): """在處理此藍圖中的任何請求之前,確保使用者已登入。""" # 移除全域的登入驗證,改為在各個路由單獨設定 pass def _generate_next_spec_code(): """ 產生下一個暫時規範編號。 規則: PE + 民國年(3碼) + 月份(2碼) + 流水號(2碼) """ now = taiwan_now() roc_year = now.year - 1911 prefix = f"PE{roc_year}{now.strftime('%m')}" latest_spec = TempSpec.query.filter( TempSpec.spec_code.startswith(prefix) ).order_by(TempSpec.spec_code.desc()).first() if latest_spec: last_seq = int(latest_spec.spec_code[-2:]) new_seq = last_seq + 1 else: new_seq = 1 return f"{prefix}{new_seq:02d}" def get_file_uri(filename): """產生 Flask 應用程式可以存取到該檔案的 URL""" return url_for('static', filename=f"generated/{filename}", _external=True) @temp_spec_bp.route('/create', methods=['GET', 'POST']) @editor_or_admin_required def create_temp_spec(): if request.method == 'POST': spec_code = _generate_next_spec_code() form_data = request.form now = taiwan_now() # 1. 在資料庫中建立紀錄 spec = TempSpec( spec_code=spec_code, title=form_data['theme'], applicant=form_data['applicant'], status='pending_approval', created_at=now, start_date=now.date(), end_date=(now + timedelta(days=30)).date() ) db.session.add(spec) db.session.flush() # 2. 準備要填入 Word 範本的 context context = { 'serial_number': spec_code, 'theme': form_data.get('theme', ''), 'package': form_data.get('package', ''), 'lot_number': form_data.get('lot_number', ''), 'equipment_type': form_data.get('equipment_type', ''), 'applicant': form_data.get('applicant', ''), 'applicant_phone': form_data.get('applicant_phone', ''), 'start_date': now.strftime('%Y-%m-%d'), 'end_date': (now + timedelta(days=30)).strftime('%Y-%m-%d'), } # 3. 處理勾選框邏輯 selected_stations = request.form.getlist('station') station_keys = ['probing', 'dicing', 'diebond', 'wirebond', 'solder', 'molding', 'degate', 'deflash', 'plating', 'trimform', 'marking', 'tmtt', 'other'] for key in station_keys: context[f's_{key}'] = '■' if key in selected_stations else '□' selected_tccs_level = form_data.get('tccs_level') level_keys = ['l1', 'l2', 'l3', 'l4'] for key in level_keys: context[f't_{key}'] = '■' if key == selected_tccs_level else '□' selected_tccs_4m = form_data.get('tccs_4m') m_keys = ['man', 'machine', 'material', 'method', 'env'] for key in m_keys: context[f't_{key}'] = '■' if key == selected_tccs_4m else '□' # 4. 渲染 Word 範本 generated_folder = os.path.join(current_app.static_folder, 'generated') os.makedirs(generated_folder, exist_ok=True) template_path = os.path.join(BASE_DIR, 'template_with_placeholders.docx') new_file_path = os.path.join(generated_folder, f"{spec_code}.docx") if not os.path.exists(template_path): flash('找不到 Word 範本檔案 (template_with_placeholders.docx)!', 'danger') db.session.rollback() return redirect(url_for('temp_spec.spec_list')) doc = DocxTemplate(template_path) doc.render(context) doc.save(new_file_path) # 5. 提交資料庫並重新導向 add_history_log(spec.id, '建立', f"建立暫時規範草稿: {spec.spec_code}") db.session.commit() return redirect(url_for('temp_spec.edit_spec', spec_id=spec.id)) # GET 請求:顯示建立表單 return render_template('create_temp_spec_form.html') @temp_spec_bp.route('/edit/') @editor_or_admin_required def edit_spec(spec_id): spec = TempSpec.query.get_or_404(spec_id) doc_filename = f"{spec.spec_code}.docx" doc_physical_path = os.path.join(current_app.static_folder, 'generated', doc_filename) if not os.path.exists(doc_physical_path): flash(f'找不到文件檔案: {doc_filename}', 'danger') return redirect(url_for('temp_spec.spec_list')) # --- START: 修正文件下載與回呼的 URL --- # 1. 產生標準的文件 URL 和回呼 URL doc_url = get_file_uri(doc_filename) callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True) # 2. 修正容器間通訊的 URL,使用正確的容器名稱 if '127.0.0.1' in doc_url or 'localhost' in doc_url: # 在 Docker Compose 環境中,OnlyOffice 應該透過 nginx 存取 Flask 應用 doc_url = doc_url.replace('127.0.0.1:12013', 'panjit-tempspec-nginx:80').replace('localhost:12013', 'panjit-tempspec-nginx:80') doc_url = doc_url.replace('127.0.0.1', 'panjit-tempspec-nginx').replace('localhost', 'panjit-tempspec-nginx') callback_url = callback_url.replace('127.0.0.1:12013', 'panjit-tempspec-nginx:80').replace('localhost:12013', 'panjit-tempspec-nginx:80') callback_url = callback_url.replace('127.0.0.1', 'panjit-tempspec-nginx').replace('localhost', 'panjit-tempspec-nginx') # --- END: 修正文件下載與回呼的 URL --- oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] # 生成唯一的文件密鑰,包含更新時間戳 file_key = f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}" payload = { "document": { "fileType": "docx", "key": file_key, "title": doc_filename, "url": doc_url # <-- 使用修正後的 doc_url }, "documentType": "word", "editorConfig": { "callbackUrl": callback_url, # <-- 使用修正後的回呼 URL "user": { "id": str(current_user.id), "name": current_user.username }, "customization": { "autosave": True, "forcesave": True, "chat": False, "comments": True, "help": False }, "mode": "edit" } } token = jwt.encode(payload, oo_secret, algorithm='HS256') config = payload.copy() config['token'] = token return render_template( 'onlyoffice_editor.html', spec=spec, config=config, onlyoffice_url=current_app.config['ONLYOFFICE_URL'] ) # 這個路由不需要登入驗證,因為是 ONLYOFFICE Server 在呼叫它 @temp_spec_bp.route('/onlyoffice-callback/', methods=['POST']) def onlyoffice_callback(spec_id): data = request.json status = data.get('status') # 記錄所有回調狀態以便調試 current_app.logger.info(f"OnlyOffice callback for spec {spec_id}: status={status}, data={data}") # OnlyOffice 狀態說明: # 0 - 文件未找到 # 1 - 文件編輯中 # 2 - 文件準備保存 # 3 - 文件保存中 # 4 - 文件已關閉,無變更 # 6 - 文件編輯中,但已強制保存 # 7 - 發生錯誤 if status in [2, 6]: # 文件需要保存或已強制保存 try: if 'url' not in data: current_app.logger.error(f"OnlyOffice callback missing URL for spec {spec_id}") return jsonify({"error": 1, "message": "Missing document URL"}) # 驗證 JWT Token (如果有) token = data.get('token') if token: try: oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] jwt.decode(token, oo_secret, algorithms=['HS256']) except jwt.InvalidTokenError: current_app.logger.error(f"Invalid JWT token in OnlyOffice callback for spec {spec_id}") return jsonify({"error": 1, "message": "Invalid token"}) # 修正 OnlyOffice 回調中的 URL 以供容器間通信使用 download_url = data['url'] # 將外部訪問 URL 轉換為容器間通信 URL download_url = download_url.replace('localhost:12015', 'panjit-tempspec-onlyoffice:80') download_url = download_url.replace('127.0.0.1:12015', 'panjit-tempspec-onlyoffice:80') current_app.logger.info(f"Downloading updated document from: {data['url']} -> {download_url}") response = requests.get(download_url, timeout=30) response.raise_for_status() # 保存文件 spec = TempSpec.query.get_or_404(spec_id) doc_filename = f"{spec.spec_code}.docx" file_path = os.path.join(current_app.static_folder, 'generated', doc_filename) # 確保目錄存在 os.makedirs(os.path.dirname(file_path), exist_ok=True) with open(file_path, 'wb') as f: f.write(response.content) current_app.logger.info(f"Successfully saved updated document for spec {spec_id} to {file_path}") # 更新資料庫中的修改時間 spec.updated_at = taiwan_now() db.session.commit() except requests.RequestException as e: current_app.logger.error(f"Failed to download document from OnlyOffice for spec {spec_id}: {e}") return jsonify({"error": 1, "message": f"Download failed: {str(e)}"}) except Exception as e: current_app.logger.error(f"OnlyOffice callback error for spec {spec_id}: {e}") return jsonify({"error": 1, "message": str(e)}) elif status == 1: current_app.logger.info(f"Document {spec_id} is being edited") elif status == 4: current_app.logger.info(f"Document {spec_id} closed without changes") elif status == 7: current_app.logger.error(f"OnlyOffice error for document {spec_id}") return jsonify({"error": 1, "message": "OnlyOffice reported an error"}) return jsonify({"error": 0}) # --- 其他既有路由 --- @temp_spec_bp.route('/list') @login_required # 補上登入驗證 def spec_list(): page = request.args.get('page', 1, type=int) query = request.args.get('query', '') status_filter = request.args.get('status', '') specs_query = TempSpec.query if query: search_term = f"%{query}%" specs_query = specs_query.filter( db.or_( TempSpec.spec_code.ilike(search_term), TempSpec.title.ilike(search_term) ) ) if status_filter: specs_query = specs_query.filter(TempSpec.status == status_filter) pagination = specs_query.order_by(TempSpec.created_at.desc()).paginate( page=page, per_page=15, error_out=False ) specs = pagination.items # --- START: 新增的程式碼 --- # 取得今天的日期,並傳給模板 from datetime import date today = date.today() # --- END: 新增的程式碼 --- return render_template( 'spec_list.html', specs=specs, pagination=pagination, query=query, status=status_filter, today=today # <-- 將 today 傳遞到模板 ) @temp_spec_bp.route('/activate/', methods=['GET', 'POST']) @admin_required def activate_spec(spec_id): spec = TempSpec.query.get_or_404(spec_id) if request.method == 'POST': uploaded_file = request.files.get('signed_file') if not uploaded_file or uploaded_file.filename == '': flash('您必須上傳一個檔案。', 'danger') return redirect(url_for('temp_spec.activate_spec', spec_id=spec.id)) filename = secure_filename(f"{spec.spec_code}_signed_{taiwan_now().strftime('%Y%m%d%H%M%S')}.pdf") upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) os.makedirs(upload_folder, exist_ok=True) file_path = os.path.join(upload_folder, filename) uploaded_file.save(file_path) new_upload = Upload( temp_spec_id=spec.id, filename=filename, upload_time=taiwan_now() ) db.session.add(new_upload) spec.status = 'active' # 儲存通知郵件清單到資料庫 recipients_str = request.form.get('recipients') if recipients_str: spec.notification_emails = recipients_str.strip() add_history_log(spec.id, '啟用', f"上傳已簽核檔案 '{filename}'") db.session.commit() flash(f"規範 '{spec.spec_code}' 已生效!", 'success') # --- Start of Dynamic Email Notification --- if recipients_str: recipients = process_recipients(recipients_str) if recipients: subject = f"[暫規通知] 規範 '{spec.spec_code}' 已正式生效" # Using f-strings and triple quotes for a readable HTML body body = f"""

您好,

暫時規範 {spec.spec_code} - {spec.title} 已由管理員啟用,並正式生效。

詳細資訊請登入系統查看。

生效日期: {spec.start_date.strftime('%Y-%m-%d')}
結束日期: {spec.end_date.strftime('%Y-%m-%d')}

申請人: {spec.applicant}

此為系統自動發送的通知郵件,請勿直接回覆。

""" send_email(recipients, subject, body) current_app.logger.info(f"Sent activation notification for spec {spec.spec_code} to {len(recipients)} recipients.") # --- End of Dynamic Email Notification --- return redirect(url_for('temp_spec.spec_list')) return render_template('activate_spec.html', spec=spec) @temp_spec_bp.route('/terminate/', methods=['GET', 'POST']) @editor_or_admin_required def terminate_spec(spec_id): spec = TempSpec.query.get_or_404(spec_id) if request.method == 'POST': reason = request.form.get('reason') if not reason: flash('請填寫提早結束的原因。', 'danger') return redirect(url_for('temp_spec.terminate_spec', spec_id=spec.id)) spec.status = 'terminated' spec.termination_reason = reason spec.end_date = taiwan_now().date() add_history_log(spec.id, '終止', f"原因: {reason}") # --- Start of Dynamic Email Notification --- # 優先使用表單提交的收件者,如果沒有則使用資料庫中儲存的 recipients_str = request.form.get('recipients') if not recipients_str and spec.notification_emails: recipients_str = spec.notification_emails if recipients_str: recipients = process_recipients(recipients_str) if recipients: subject = f"[暫規通知] 規範 '{spec.spec_code}' 已提早終止" body = f"""

您好,

暫時規範 {spec.spec_code} - {spec.title} 已被提早終止。

終止日期: {spec.end_date.strftime('%Y-%m-%d')}

申請人: {spec.applicant}

終止原因: {reason}

詳細資訊請登入系統查看。

此為系統自動發送的通知郵件,請勿直接回覆。

""" send_email(recipients, subject, body) current_app.logger.info(f"Sent termination notification for spec {spec.spec_code} to {len(recipients)} recipients.") # --- End of Dynamic Email Notification --- db.session.commit() flash(f"規範 '{spec.spec_code}' 已被提早終止。", 'warning') return redirect(url_for('temp_spec.spec_list')) # 將儲存的郵件清單傳遞給模板 return render_template('terminate_spec.html', spec=spec, saved_emails=spec.notification_emails) @temp_spec_bp.route('/download_initial_word/') @login_required def download_initial_word(spec_id): spec = TempSpec.query.get_or_404(spec_id) if current_user.role not in ['editor', 'admin']: flash('權限不足,無法下載 Word 檔案。', 'danger') abort(403) generated_folder = os.path.join(current_app.static_folder, 'generated') word_path = os.path.join(generated_folder, f"{spec.spec_code}.docx") if not os.path.exists(word_path): flash('找不到最初產生的 Word 檔案,可能已被刪除或移動。', 'danger') return redirect(url_for('temp_spec.spec_list')) return send_file(word_path, as_attachment=True) @temp_spec_bp.route('/download_signed/') @login_required # 補上登入驗證 def download_signed_pdf(spec_id): latest_upload = Upload.query.filter_by(temp_spec_id=spec_id).order_by(Upload.upload_time.desc()).first() if not latest_upload: flash('找不到任何已上傳的簽核檔案。', 'danger') return redirect(url_for('temp_spec.spec_list')) upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) return send_file(os.path.join(upload_folder, latest_upload.filename), as_attachment=True) @temp_spec_bp.route('/extend/', methods=['GET', 'POST']) @editor_or_admin_required def extend_spec(spec_id): spec = TempSpec.query.get_or_404(spec_id) # 檢查展延次數限制(最多展延2次) if spec.extension_count >= 2: flash('此暫時規範已達展延次數上限(2次),無法再次展延。總效期已達90天上限。', 'danger') return redirect(url_for('temp_spec.spec_list')) if request.method == 'POST': new_end_date_str = request.form.get('new_end_date') uploaded_file = request.files.get('new_file') if not uploaded_file or uploaded_file.filename == '': flash('您必須上傳新的佐證檔案才能展延。', 'danger') return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id)) if not new_end_date_str: flash('請選擇新的結束日期', 'danger') return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id)) spec.end_date = datetime.strptime(new_end_date_str, '%Y-%m-%d').date() spec.extension_count += 1 spec.status = 'active' filename = secure_filename(f"{spec.spec_code}_extension_{spec.extension_count}_{taiwan_now().strftime('%Y%m%d')}.pdf") upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) os.makedirs(upload_folder, exist_ok=True) file_path = os.path.join(upload_folder, filename) uploaded_file.save(file_path) new_upload = Upload( temp_spec_id=spec.id, filename=filename, upload_time=taiwan_now() ) db.session.add(new_upload) details = f"展延結束日期至 {spec.end_date.strftime('%Y-%m-%d')}" details += f",並上傳新檔案 '{new_upload.filename}'" add_history_log(spec.id, '展延', details) # --- Start of Dynamic Email Notification --- # 優先使用表單提交的收件者,如果沒有則使用資料庫中儲存的 recipients_str = request.form.get('recipients') if not recipients_str and spec.notification_emails: recipients_str = spec.notification_emails # 如果使用者有更新郵件清單,儲存回資料庫 if recipients_str: spec.notification_emails = recipients_str.strip() if recipients_str: recipients = process_recipients(recipients_str) if recipients: subject = f"[暫規通知] 規範 '{spec.spec_code}' 已展延" body = f"""

您好,

暫時規範 {spec.spec_code} - {spec.title} 已成功展延。

新的結束日期為: {spec.end_date.strftime('%Y-%m-%d')}

申請人: {spec.applicant}

詳細資訊請登入系統查看。

此為系統自動發送的通知郵件,請勿直接回覆。

""" send_email(recipients, subject, body) current_app.logger.info(f"Sent extension notification for spec {spec.spec_code} to {len(recipients)} recipients.") # --- End of Dynamic Email Notification --- db.session.commit() flash(f"規範 '{spec.spec_code}' 已成功展延!", 'success') return redirect(url_for('temp_spec.spec_list')) default_new_end_date = spec.end_date + timedelta(days=30) # 將儲存的郵件清單傳遞給模板 return render_template('extend_spec.html', spec=spec, default_new_end_date=default_new_end_date, saved_emails=spec.notification_emails) @temp_spec_bp.route('/history/') @login_required # 補上登入驗證 def spec_history(spec_id): spec = TempSpec.query.get_or_404(spec_id) history = SpecHistory.query.filter_by(spec_id=spec_id).order_by(SpecHistory.timestamp.desc()).all() return render_template('spec_history.html', spec=spec, history=history) @temp_spec_bp.route('/delete/', methods=['POST']) @admin_required def delete_spec(spec_id): spec = TempSpec.query.get_or_404(spec_id) spec_code = spec.spec_code files_to_delete = [] generated_folder = os.path.join(current_app.static_folder, 'generated') files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx")) upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) for upload_record in spec.uploads: files_to_delete.append(os.path.join(upload_folder, upload_record.filename)) for f_path in files_to_delete: try: if os.path.exists(f_path): os.remove(f_path) except Exception as e: current_app.logger.error(f"刪除檔案失敗: {f_path}, 原因: {e}") db.session.delete(spec) db.session.commit() flash(f"規範 '{spec_code}' 及其所有相關檔案已成功刪除。", 'success') return redirect(url_for('temp_spec.spec_list'))