# -*- 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 models import TempSpec, db, Upload, SpecHistory from utils import editor_or_admin_required, add_history_log, admin_required, send_email 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 = datetime.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 = datetime.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 中的 localhost 替換為 Docker 可存取的地址 if '127.0.0.1' in doc_url or 'localhost' in doc_url: # 同時修正 doc_url 和 callback_url doc_url = doc_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal') callback_url = callback_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal') # --- END: 修正文件下載與回呼的 URL --- oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] payload = { "document": { "fileType": "docx", "key": f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}", "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 } } } 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 if data.get('status') == 2: try: response = requests.get(data['url'], timeout=10) 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) with open(file_path, 'wb') as f: f.write(response.content) except Exception as e: current_app.logger.error(f"ONLYOFFICE callback error for spec {spec_id}: {e}") return jsonify({"error": 1, "message": str(e)}) 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_{datetime.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=datetime.now() ) db.session.add(new_upload) spec.status = 'active' add_history_log(spec.id, '啟用', f"上傳已簽核檔案 '{filename}'") db.session.commit() flash(f"規範 '{spec.spec_code}' 已生效!", 'success') # --- Start of Email Notification Example --- # Get recipient list from a predefined LDAP group # NOTE: 'TempSpec_Approvers' is an example group name. Replace with the actual group name. recipients = get_ldap_group_members('TempSpec_Approvers') 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')}

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

""" send_email(recipients, subject, body) else: # Log a warning if no recipients were found, but don't block the main process current_app.logger.warning(f"Could not find recipients in LDAP group 'TempSpec_Approvers' for spec {spec.id}.") # --- End of Email Notification Example --- 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 = datetime.today().date() add_history_log(spec.id, '終止', f"原因: {reason}") 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) @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) 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}_{datetime.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=datetime.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) 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) @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'))