From d1d68e66a74560ba1e9ee6e69794e29013764a31 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Tue, 29 Jul 2025 20:24:40 +0800 Subject: [PATCH] Ok --- .env.example | 9 + .gitignore | 40 ++++ README.md | 134 +++++++++++ USER_MANUAL.md | 119 ++++++++++ app.py | 48 ++++ config.py | 13 ++ init_db.py | 72 ++++++ models.py | 51 +++++ requirements.txt | 12 + routes/__init__.py | 0 routes/admin.py | 76 +++++++ routes/auth.py | 37 ++++ routes/temp_spec.py | 379 ++++++++++++++++++++++++++++++++ routes/upload.py | 29 +++ static/css/style.css | 35 +++ template_with_placeholders.docx | Bin 0 -> 27392 bytes templates/403.html | 12 + templates/404.html | 12 + templates/activate_spec.html | 27 +++ templates/base.html | 94 ++++++++ templates/create_temp_spec.html | 332 ++++++++++++++++++++++++++++ templates/extend_spec.html | 43 ++++ templates/login.html | 40 ++++ templates/spec_history.html | 34 +++ templates/spec_list.html | 143 ++++++++++++ templates/terminate_spec.html | 27 +++ templates/user_management.html | 91 ++++++++ utils.py | 160 ++++++++++++++ 28 files changed, 2069 insertions(+) create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 README.md create mode 100644 USER_MANUAL.md create mode 100644 app.py create mode 100644 config.py create mode 100644 init_db.py create mode 100644 models.py create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/admin.py create mode 100644 routes/auth.py create mode 100644 routes/temp_spec.py create mode 100644 routes/upload.py create mode 100644 static/css/style.css create mode 100644 template_with_placeholders.docx create mode 100644 templates/403.html create mode 100644 templates/404.html create mode 100644 templates/activate_spec.html create mode 100644 templates/base.html create mode 100644 templates/create_temp_spec.html create mode 100644 templates/extend_spec.html create mode 100644 templates/login.html create mode 100644 templates/spec_history.html create mode 100644 templates/spec_list.html create mode 100644 templates/terminate_spec.html create mode 100644 templates/user_management.html create mode 100644 utils.py diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..47bdfb1 --- /dev/null +++ b/.env.example @@ -0,0 +1,9 @@ +# Flask application secret key for session protection. +# IMPORTANT: Change this to a long, random string in your .env file. +# You can generate one using: python -c "import secrets; print(secrets.token_hex(24))" +SECRET_KEY='change-me-to-a-real-secret-key' + +# Database connection URL. +# Format: mysql+pymysql://:@:/ +# Example for a local MySQL server: +DATABASE_URL='mysql+pymysql://root:your_password@localhost:3306/temp_spec_db' diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..7083204 --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# --- 敏感資訊 (Sensitive Information) --- +# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。 +.env + +# --- Python 相關 (Python Related) --- +# 忽略虛擬環境目錄。 +.venv/ +venv/ + +# 忽略 Python 的位元組碼和快取檔案。 +__pycache__/ +*.pyc +*.pyo +*.pyd + +# --- 使用者上傳與系統產生的檔案 (User Uploads & Generated Files) --- +# 忽略上傳的已簽核文件 (PDFs)。 +/uploads/ + +# 忽略系統自動產生的暫時規範文件 (Word, PDF)。 +/generated/ + +# 忽略使用者在編輯器中上傳的圖片。 +/static/uploads/ + +# --- IDE / 編輯器設定 (IDE / Editor Settings) --- +# 忽略 Visual Studio Code 的本機設定。 +.vscode/ + +# --- 作業系統相關 (Operating System) --- +# 忽略 macOS 的系統檔案。 +.DS_Store + +# 忽略 Windows 的縮圖快取。 +Thumbs.db + +# --- Log 檔案 --- +# 忽略所有日誌檔案。 +*.log +logs/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..b1a86d2 --- /dev/null +++ b/README.md @@ -0,0 +1,134 @@ +# TEMP Spec System - 暫時規範管理系統 + +這是一個使用 Flask 開發的 Web 應用程式,旨在管理、追蹤和存檔暫時性的工程規範。系統支援完整的生命週期管理,從建立、審核、生效到終止,並能自動生成標準化文件。 + +## 核心功能 + +- **使用者權限管理**: 內建三種角色 (`viewer`, `editor`, `admin`),各角色擁有不同操作權限。 +- **規範生命週期**: 支援暫時規範的建立、啟用、展延、終止與刪除。 +- **文件自動生成**: 可根據 Word 模板 (`.docx`) 自動填入內容並生成 PDF 與 Word 文件。 +- **檔案管理**: 支援上傳簽核後的文件,並與對應的規範進行關聯。 +- **歷史紀錄**: 詳細記錄每一份規範的所有變更歷史,方便追蹤與稽核。 +- **內容編輯**: 支援 Markdown 語法及圖片上傳,提供豐富的內容編輯體驗。 + +--- + +## 環境要求 + +在部署此應用程式之前,請確保您的系統已安裝以下軟體: + +1. **Python**: 建議使用 `Python 3.10` 或更高版本。 +2. **MySQL**: 需要一個 MySQL 資料庫來儲存所有應用程式資料。 +3. **Microsoft Office / LibreOffice**: + - **[重要]** 本專案使用 `docx2pdf` 套件來將 Word 文件轉換為 PDF。此套件依賴於系統上安裝的 Microsoft Office (Windows) 或 LibreOffice (跨平台)。請務必確保伺服器上已安裝其中之一,否則 PDF 生成功能將會失敗。 +4. **Git**: 用於從版本控制系統下載程式碼。 + +--- + +## 安裝與設定步驟 + +請依照以下步驟來設定您的開發或生產環境: + +### 1. 下載程式碼 + +```bash +git clone +cd TEMP_spec_system +``` + +### 2. 建立並啟用虛擬環境 + +建議使用虛擬環境來隔離專案的相依套件。 + +```bash +# Windows +python -m venv .venv +.\.venv\Scripts\activate + +# macOS / Linux +python3 -m venv .venv +source .venv/bin/activate +``` + +### 3. 安裝相依套件 + +```bash +pip install -r requirements.txt +``` + +### 4. 設定環境變數 + +專案的敏感設定(如資料庫連線資訊、密鑰)是透過 `.env` 檔案管理的。 + +首先,複製範例檔案: + +```bash +# Windows +copy .env.example .env + +# macOS / Linux +cp .env.example .env +``` + +然後,編輯 `.env` 檔案,填入您的實際設定: + +```dotenv +# Flask 應用程式的密鑰,用於保護 session,請務必修改為一個隨機的長字串 +SECRET_KEY='your-super-secret-and-random-string' + +# 資料庫連線 URL +# 格式: mysql+pymysql://<使用者名稱>:<密碼>@<主機地址>:<埠號>/<資料庫名稱> +DATABASE_URL='mysql+pymysql://user:password@localhost:3306/temp_spec_db' +``` + +**注意**: 請先在您的 MySQL 中手動建立一個名為 `temp_spec_db` (或您自訂的名稱) 的資料庫。 + +### 5. 初始化資料庫 + +執行初始化腳本來建立所有需要的資料表,並產生一個預設的管理員帳號。 + +```bash +python init_db.py +``` + +腳本會提示您確認操作。輸入 `yes` 後,它會建立資料表並在終端機中顯示預設 `admin` 帳號的隨機密碼。**請務必記下此密碼**。 + +--- + +## 執行應用程式 + +### 開發模式 + +在開發環境中,您可以直接執行 `app.py`: + +```bash +python app.py +``` + +應用程式預設會在 `http://127.0.0.1:5000` 上執行。 + +### 生產環境 + +在生產環境中,**不應**使用 Flask 內建的開發伺服器。建議使用生產級的 WSGI 伺服器,例如 `Gunicorn` (Linux) 或 `Waitress` (Windows)。 + +**使用 Waitress (Windows) 的範例:** + +1. 安裝 Waitress: `pip install waitress` +2. 執行應用程式: `waitress-serve --host=0.0.0.0 --port=8000 app:app` + +--- + +## 使用者角色說明 + +- **Viewer (檢視者)**: + - 只能瀏覽和搜尋暫時規範。 + - 可以下載已生效或待生效的 PDF 文件。 +- **Editor (編輯者)**: + - 擁有 `Viewer` 的所有權限。 + - 可以建立新的暫時規範,並下載待簽核的 Word 文件。 + - 可以展延或終止已生效的規範。 +- **Admin (管理者)**: + - 擁有 `Editor` 的所有權限。 + - 可以管理使用者帳號 (新增、編輯、刪除)。 + - **可以上傳簽核後的文件,正式啟用一份規範**。 + - 可以永久刪除一份規範及其所有相關檔案。 diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..f821bd7 --- /dev/null +++ b/USER_MANUAL.md @@ -0,0 +1,119 @@ +# 系統操作說明書 (User Manual) + +歡迎使用「暫時規範管理系統」。本說明書將引導您如何操作本系統的各項功能。 + +## 1. 系統簡介 + +本系統旨在提供一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、簽核、生效到最終歸檔的完整生命週期,確保所有流程都有據可查。 + +--- + +## 2. 登入與主畫面 + +### 2.1 登入 + +請使用管理員提供的帳號和密碼,在首頁進行登入。 + +### 2.2 主畫面 (暫時規範總表) + +登入後,您會看到系統的主畫面,這裡會列出所有的暫時規範。主畫面包含以下幾個重要部分: + +- **建立新規範按鈕**: (僅 Editor/Admin 可見) 點擊此處開始建立一份新的規範。 +- **搜尋與篩選區**: + - **搜尋框**: 您可以輸入規範的「編號」或「主題」關鍵字來快速找到目標。 + - **狀態篩選器**: 您可以根據規範的狀態 (如:待生效、已生效) 來篩選列表。 +- **規範列表**: 顯示了每份規範的關鍵資訊,包括編號、主題、建立日期、結束日期和目前狀態。 +- **操作按鈕**: 針對每一份規範,您可以看到一組操作按鈕,這些按鈕會根據您的「角色」和規範的「狀態」而有所不同。 + +--- + +## 3. 核心操作流程 + +### 3.1 流程總覽 + +本系統的標準工作流程如下: + +1. **Editor** 建立一份新的暫時規範草稿。 +2. 系統自動產生一份標準格式的 **Word (.docx) 文件**。 +3. **Editor** 將此 Word 文件下載,進行線下簽核流程。 +4. 簽核完成後,將文件轉為 **PDF (.pdf) 格式**。 +5. **Admin** 登入系統,上傳簽核完成的 PDF 檔案,正式**啟用**該規範。 + +### 3.2 建立新的暫時規範 (Editor / Admin) + +1. 在主畫面點擊右上角的 **[+ 建立新規範]** 按鈕。 +2. 在「建立暫時規範」頁面,填寫所有必填欄位。 + - **內容編輯**: 「變更前」、「變更後」等欄位支援 Markdown 語法,您可以點擊工具列按鈕來插入表格、清單,或上傳圖片。 +3. 填寫完成後,點擊頁面下方的 **[預覽]** 按鈕,可以即時查看生成後 PDF 的樣式。 +4. 確認無誤後,點擊 **[產生並下載 Word]** 按鈕。 +5. 系統會自動產生一份 `.docx` 檔案供您下載。此時,這份新規範會出現在總表中,狀態為「**待生效**」。 + +### 3.3 啟用暫時規範 (僅限 Admin) + +當一份規範的線下簽核流程完成後,管理員需執行以下操作使其生效: + +1. 在總表中找到狀態為「**待生效**」的目標規範。 +2. 點擊該規範右側的 **啟用圖示 (✅)**。 +3. 在「啟用暫時規範」頁面,點擊 **[選擇檔案]**,並上傳**已簽核完成的 PDF 檔案**。 +4. 點擊 **[啟用規範]** 按鈕。 +5. 完成後,該規範的狀態會變為「**已生效**」,表示此規範已在效期內。 + +### 3.4 管理已生效的規範 (Editor / Admin) + +對於「**已生效**」的規範,您可以進行展延或提早終止: + +- **展延**: + 1. 點擊 **展延圖示 (📅+**)。 + 2. 選擇新的結束日期,並可選擇性上傳新的佐證文件。 + 3. 點擊儲存,規範的效期將會延長。 +- **終止**: + 1. 點擊 **終止圖示 (❌)**。 + 2. 填寫提早終止的原因。 + 3. 提交後,規範狀態將變為「**已終止**」。 + +### 3.5 搜尋、篩選與下載 + +- **搜尋**: 在主畫面的搜尋框輸入關鍵字,按下「篩選」即可。 +- **篩選**: 在下拉選單中選擇您想看的狀態,按下「篩選」即可。 +- **下載**: + - **待生效規範**: + - 所有角色都可下載 **PDF** 版本。 + - Editor 和 Admin 還可以額外下載 **Word** 原始檔。 + - **已生效/已終止/已過期規範**: + - 所有角色都可以下載由 Admin 上傳的**最終簽核版 PDF**。 + +### 3.6 檢視歷史紀錄 + +若要查看某份規範的所有變更紀錄 (如誰建立、誰啟用、誰展延),可以點擊該規範最右側的 **歷史紀錄圖示 (🕒)**。 + +--- + +## 4. 使用者管理 (僅限 Admin) + +管理員可以點擊頁面頂端導覽列的 **[後台管理]** 來進入使用者管理頁面。在此頁面中,您可以: + +- **建立新使用者**: 設定新使用者的帳號、密碼和角色。 +- **編輯現有使用者**: 修改使用者的角色或重設其密碼。 +- **刪除使用者**: 從系統中移除某個使用者。 + +--- + +## 5. 名詞解釋 + +- **待生效 (Pending Approval)**: 規範已建立,但尚未上傳簽核後的正式文件,還未生效。 +- **已生效 (Active)**: 規範已由管理員啟用,目前在有效期內。 +- **已終止 (Terminated)**: 規範在有效期結束前,被人為提早結束。 +- **已過期 (Expired)**: 規範已超過其設定的結束日期,自動失效。 + +--- + +## 6. 常見問題 (FAQ) + +**Q: 為什麼我看不到「建立規範」或「啟用」的按鈕?** +A: 您的帳號權限不足。建立規範需要 `Editor` 或 `Admin` 權限;啟用規範僅限 `Admin`。如有需要,請聯繫系統管理員。 + +**Q: 我忘記密碼了怎麼辦?** +A: 請聯繫系統管理員,請他/她為您重設密碼。 + +**Q: 我可以上傳 Word 檔案來啟用規範嗎?** +A: 不行。為了確保文件的最終性和不可修改性,系統規定必須上傳已簽核的 **PDF 檔案**來啟用規範。 diff --git a/app.py b/app.py new file mode 100644 index 0000000..c9fbca1 --- /dev/null +++ b/app.py @@ -0,0 +1,48 @@ +from flask import Flask, redirect, url_for, render_template +from flask_login import LoginManager, current_user +from models import db, User +from routes.auth import auth_bp +from routes.temp_spec import temp_spec_bp +from routes.upload import upload_bp +from routes.admin import admin_bp + +app = Flask(__name__) +app.config.from_object('config.Config') + +# 初始化資料庫 +db.init_app(app) + +# 初始化登入管理 +login_manager = LoginManager() +login_manager.init_app(app) +login_manager.login_view = 'auth.login' +login_manager.login_message = "請先登入以存取此頁面。" +login_manager.login_message_category = "info" + +# 預設首頁導向登入畫面 +@app.route('/') +def index(): + return redirect(url_for('auth.login')) + +# 載入登入使用者 +@login_manager.user_loader +def load_user(user_id): + return User.query.get(int(user_id)) + +# 註冊 Blueprint 模組路由 +app.register_blueprint(auth_bp) +app.register_blueprint(temp_spec_bp) +app.register_blueprint(upload_bp) +app.register_blueprint(admin_bp) + +# 註冊錯誤處理函式 +@app.errorhandler(404) +def not_found_error(error): + return render_template('404.html'), 404 + +@app.errorhandler(403) +def forbidden_error(error): + return render_template('403.html'), 403 + +if __name__ == '__main__': + app.run(debug=True) diff --git a/config.py b/config.py new file mode 100644 index 0000000..1ed092b --- /dev/null +++ b/config.py @@ -0,0 +1,13 @@ +import os +from dotenv import load_dotenv + +# 載入 .env 檔案中的環境變數 +load_dotenv() + +class Config: + SECRET_KEY = os.getenv('SECRET_KEY', 'a_default_secret_key_for_development') + SQLALCHEMY_DATABASE_URI = os.getenv('DATABASE_URL') + SQLALCHEMY_TRACK_MODIFICATIONS = False + UPLOAD_FOLDER = 'uploads' + GENERATED_FOLDER = 'generated' + MAX_CONTENT_LENGTH = 16 * 1024 * 1024 diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..37d4e3b --- /dev/null +++ b/init_db.py @@ -0,0 +1,72 @@ +# -*- coding: utf-8 -*- +import os +import secrets +import string +from flask import Flask +from werkzeug.security import generate_password_hash +from models import db, User +from config import Config + +def create_default_admin(app): + """在應用程式上下文中建立一個預設的管理員帳號。""" + with app.app_context(): + # 檢查管理員是否已存在 + if User.query.filter_by(username='admin').first(): + print("ℹ️ 'admin' 使用者已存在,跳過建立程序。") + return + + # 產生一個安全隨機的密碼 + password_length = 12 + alphabet = string.ascii_letters + string.digits + '!@#$%^&*()' + password = ''.join(secrets.choice(alphabet) for i in range(password_length)) + + # 建立新使用者 + admin_user = User( + username='admin', + password_hash=generate_password_hash(password), + role='admin' + ) + db.session.add(admin_user) + db.session.commit() + + print("✅ 預設管理員帳號已建立!") + print(" ===================================") + print(f" 👤 使用者名稱: admin") + print(f" 🔑 密碼: {password}") + print(" ===================================") + print(" 請妥善保管此密碼,並在首次登入後考慮變更。") + +def init_database(app): + """初始化資料庫:刪除所有現有資料表並重新建立。""" + with app.app_context(): + print("🔄 開始進行資料庫初始化...") + # 為了安全,先刪除所有表格,再重新建立 + db.drop_all() + print(" - 所有舊資料表已刪除。") + db.create_all() + print(" - 所有新資料表已根據 models.py 建立。") + print("✅ 資料庫結構已成功初始化!") + +if __name__ == '__main__': + # 建立一個暫時的 Flask app 來提供資料庫操作所需的應用程式上下文 + app = Flask(__name__) + app.config.from_object(Config) + + # 將資料庫物件與 app 綁定 + db.init_app(app) + + print("=================================================") + print(" ⚠️ 資料庫初始化腳本 ⚠️") + print("=================================================") + print("此腳本將會刪除所有現有的資料,並重新建立資料庫結構。") + print("這個操作是不可逆的!") + + # 讓使用者確認操作 + confirmation = input("👉 您確定要繼續嗎? (yes/no): ") + + if confirmation.lower() == 'yes': + init_database(app) + create_default_admin(app) + print("\n🎉 全部完成!") + else: + print("❌ 操作已取消。") \ No newline at end of file diff --git a/models.py b/models.py new file mode 100644 index 0000000..ad81e39 --- /dev/null +++ b/models.py @@ -0,0 +1,51 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from datetime import datetime + +db = SQLAlchemy() + +class User(db.Model, UserMixin): + __tablename__ = 'user' + id = db.Column(db.Integer, primary_key=True) + username = db.Column(db.String(50), unique=True, nullable=False) + password_hash = db.Column(db.String(255), nullable=False) + role = db.Column(db.Enum('viewer', 'editor', 'admin'), nullable=False) + last_login = db.Column(db.DateTime) + +class TempSpec(db.Model): + id = db.Column(db.Integer, primary_key=True) + spec_code = db.Column(db.String(20), nullable=False) + applicant = db.Column(db.String(50)) + title = db.Column(db.String(100)) + content = db.Column(db.Text) + start_date = db.Column(db.Date) + end_date = db.Column(db.Date) + status = db.Column(db.Enum('pending_approval', 'active', 'expired', 'terminated'), nullable=False, default='pending_approval') + created_at = db.Column(db.DateTime) + extension_count = db.Column(db.Integer, default=0) + termination_reason = db.Column(db.Text, nullable=True) + + # 關聯到 Upload 和 SpecHistory,並設定級聯刪除 + uploads = db.relationship('Upload', back_populates='spec', cascade='all, delete-orphan') + history = db.relationship('SpecHistory', back_populates='spec', cascade='all, delete-orphan') + +class Upload(db.Model): + id = db.Column(db.Integer, primary_key=True) + temp_spec_id = db.Column(db.Integer, db.ForeignKey('temp_spec.id', ondelete='CASCADE'), nullable=False) + filename = db.Column(db.String(200)) + upload_time = db.Column(db.DateTime) + + spec = db.relationship('TempSpec', back_populates='uploads') + +class SpecHistory(db.Model): + __tablename__ = 'SpecHistory' + id = db.Column(db.Integer, primary_key=True) + spec_id = db.Column(db.Integer, db.ForeignKey('temp_spec.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('user.id', ondelete='SET NULL'), nullable=True) + action = db.Column(db.String(50), nullable=False) + details = db.Column(db.Text, nullable=True) + timestamp = db.Column(db.DateTime, default=datetime.utcnow) + + # 建立與 User 和 TempSpec 的關聯,方便查詢 + user = db.relationship('User') + spec = db.relationship('TempSpec', back_populates='history') diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..81c5564 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,12 @@ +flask +flask-login +flask-sqlalchemy +pymysql +werkzeug +docx2pdf +python-docx +docxtpl +beautifulsoup4 +lxml +python-dotenv +mistune diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..8087186 --- /dev/null +++ b/routes/admin.py @@ -0,0 +1,76 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_required, current_user +from werkzeug.security import generate_password_hash +from models import User, db +from utils import admin_required + +admin_bp = Blueprint('admin', __name__, url_prefix='/admin') + +@admin_bp.before_request +@login_required +@admin_required +def before_request(): + """在處理此藍圖中的任何請求之前,確保使用者是已登入的管理員。""" + pass + +@admin_bp.route('/users') +def user_list(): + users = User.query.all() + return render_template('user_management.html', users=users) + +@admin_bp.route('/users/create', methods=['POST']) +def create_user(): + username = request.form.get('username') + password = request.form.get('password') + role = request.form.get('role') + + if not all([username, password, role]): + flash('所有欄位都是必填的!', 'danger') + return redirect(url_for('admin.user_list')) + + if User.query.filter_by(username=username).first(): + flash('該使用者名稱已存在!', 'danger') + return redirect(url_for('admin.user_list')) + + new_user = User( + username=username, + password_hash=generate_password_hash(password), + role=role + ) + db.session.add(new_user) + db.session.commit() + flash('新使用者已成功建立!', 'success') + return redirect(url_for('admin.user_list')) + +@admin_bp.route('/users/edit/', methods=['POST']) +def edit_user(user_id): + user = User.query.get_or_404(user_id) + new_role = request.form.get('role') + new_password = request.form.get('password') + + if new_role: + # 防止 admin 修改自己的角色,導致失去管理權限 + if user.id == current_user.id and user.role == 'admin' and new_role != 'admin': + flash('無法變更自己的管理員角色!', 'danger') + return redirect(url_for('admin.user_list')) + user.role = new_role + + if new_password: + user.password_hash = generate_password_hash(new_password) + + db.session.commit() + flash(f"使用者 '{user.username}' 的資料已更新。", 'success') + return redirect(url_for('admin.user_list')) + +@admin_bp.route('/users/delete/', methods=['POST']) +def delete_user(user_id): + # 避免 admin 刪除自己 + if user_id == current_user.id: + flash('無法刪除自己的帳號!', 'danger') + return redirect(url_for('admin.user_list')) + + user = User.query.get_or_404(user_id) + db.session.delete(user) + db.session.commit() + flash(f"使用者 '{user.username}' 已被刪除。", 'success') + return redirect(url_for('admin.user_list')) diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..403edd6 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,37 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_user, logout_user, login_required +from werkzeug.security import check_password_hash +from models import User, db +from datetime import datetime + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + user = User.query.filter_by(username=username).first() + + if user: + print(f"🔍 嘗試登入使用者:{username}") + else: + print("⚠️ 使用者不存在") + + if user and check_password_hash(user.password_hash, password): + login_user(user) + user.last_login = datetime.now() + db.session.commit() + print("✅ 登入成功") + return redirect(url_for('temp_spec.spec_list')) + else: + print("❌ 登入失敗,帳號或密碼錯誤") + flash('帳號或密碼錯誤,請重新輸入', 'danger') + + return render_template('login.html') + +@auth_bp.route('/logout') +@login_required +def logout(): + logout_user() + return redirect(url_for('auth.login')) diff --git a/routes/temp_spec.py b/routes/temp_spec.py new file mode 100644 index 0000000..76e93e7 --- /dev/null +++ b/routes/temp_spec.py @@ -0,0 +1,379 @@ +# -*- coding: utf-8 -*- +from flask import Blueprint, render_template, request, redirect, url_for, flash, send_file, current_app, jsonify +from flask_login import login_required, current_user +from datetime import datetime, timedelta +from models import TempSpec, db, Upload, SpecHistory +from utils import fill_template, editor_or_admin_required, add_history_log, admin_required +import os +import tempfile +from werkzeug.utils import secure_filename +from bs4 import BeautifulSoup +import re +import mistune + +temp_spec_bp = Blueprint('temp_spec', __name__) +BASE_DIR = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + +@temp_spec_bp.before_request +@login_required +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}" + +@temp_spec_bp.route('/preview', methods=['POST']) +def preview_spec(): + """產生預覽 PDF 並返回""" + data = request.json + + values = { + 'serial_number': data.get('serial_number', 'PREVIEW-SN'), + 'theme': data.get('theme', 'PREVIEW-THEME'), + 'applicant': data.get('applicant', ''), + 'applicant_phone': data.get('applicant_phone', ''), + 'station': data.get('station', ''), + 'tccs_info': data.get('tccs_info', ''), + 'start_date': data.get('start_date', datetime.today().strftime('%Y-%m-%d')), + 'end_date': (datetime.today() + timedelta(days=30)).strftime('%Y-%m-%d'), + 'package': data.get('package', ''), + 'lot_number': data.get('lot_number', ''), + 'equipment_type': data.get('equipment_type', ''), + 'change_before': data.get('change_before', ''), + 'change_after': data.get('change_after', ''), + 'data_needs': data.get('data_needs', ''), + } + + temp_docx_path = tempfile.mktemp(suffix=".docx") + temp_pdf_path = tempfile.mktemp(suffix=".pdf") + + try: + template_path = os.path.join(BASE_DIR, 'template_with_placeholders.docx') + fill_template(values, template_path, temp_docx_path, temp_pdf_path) + + with open(temp_pdf_path, 'rb') as f: + pdf_data = f.read() + + import io + return_data = io.BytesIO(pdf_data) + + try: + if os.path.exists(temp_docx_path): + os.remove(temp_docx_path) + if os.path.exists(temp_pdf_path): + os.remove(temp_pdf_path) + except Exception as e: + current_app.logger.error(f"無法刪除暫存檔: {e}") + + return send_file(return_data, mimetype='application/pdf') + + except Exception as e: + current_app.logger.error(f"預覽生成失敗: {e}") + if os.path.exists(temp_docx_path): + os.remove(temp_docx_path) + if os.path.exists(temp_pdf_path): + os.remove(temp_pdf_path) + return jsonify({"error": str(e)}), 500 + +@temp_spec_bp.route('/create', methods=['GET', 'POST']) +@editor_or_admin_required +def create_temp_spec(): + if request.method == 'POST': + data = request.form + now = datetime.now() + serial_number = _generate_next_spec_code() + stations = request.form.getlist('station') + if '其他' in stations and data.get('station_other'): + stations[stations.index('其他')] = data.get('station_other') + station_str = ', '.join(stations) + tccs_info = f"{data.get('tccs_level', '')} ({data.get('tccs_4m', '')})" + + values = { + 'serial_number': serial_number, + 'theme': data['theme'], + 'applicant': data.get('applicant', ''), + 'applicant_phone': data.get('applicant_phone', ''), + 'station': station_str, + 'tccs_info': tccs_info, + 'start_date': data.get('start_date', ''), + 'package': data.get('package', ''), + 'lot_number': data.get('lot_number', ''), + 'equipment_type': data.get('equipment_type', ''), + 'change_before': data.get('change_before', ''), + 'change_after': data.get('change_after', ''), + 'data_needs': data.get('data_needs', ''), + } + + generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER']) + os.makedirs(generated_folder, exist_ok=True) + word_path = os.path.join(generated_folder, f"{values['serial_number']}.docx") + pdf_path = os.path.join(generated_folder, f"{values['serial_number']}.pdf") + + db_content_parts = [] + db_content_parts.append("變更前:\n") + db_content_parts.append(values['change_before']) + db_content_parts.append("\n\n變更後:\n") + db_content_parts.append(values['change_after']) + db_content_parts.append("\n\n資料收集需求:\n") + db_content_parts.append(values['data_needs']) + db_content = "".join(db_content_parts) + + try: + start_date_obj = datetime.strptime(values['start_date'], '%Y-%m-%d').date() + except (ValueError, TypeError): + start_date_obj = datetime.today().date() + + end_date_obj = start_date_obj + timedelta(days=30) + values['end_date'] = end_date_obj.strftime('%Y-%m-%d') + + spec = TempSpec( + spec_code=values['serial_number'], + applicant=values['applicant'], + title=values['theme'], + content=db_content, + start_date=start_date_obj, + end_date=end_date_obj, + created_at=now, + status='pending_approval' + ) + db.session.add(spec) + db.session.flush() + add_history_log(spec.id, '建立', f"建立暫時規範,編號為 {spec.spec_code}") + db.session.commit() + + # 在產生用於下載的 PDF 前,需將 Markdown 轉為 HTML + values['change_before'] = mistune.html(values['change_before']) + values['change_after'] = mistune.html(values['change_after']) + try: + fill_template(values, os.path.join(BASE_DIR, 'template_with_placeholders.docx'), word_path, pdf_path) + except Exception as e: + current_app.logger.error(f"檔案生成失敗: {e}") + flash('檔案生成失敗,可能是 Word 模板或 PDF 轉換器問題,請聯絡管理員。', 'danger') + return redirect(url_for('temp_spec.create_temp_spec')) + + return send_file(word_path, as_attachment=True) + + next_spec_code = _generate_next_spec_code() + return render_template('create_temp_spec.html', next_spec_code=next_spec_code) + +@temp_spec_bp.route('/list') +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 + return render_template('spec_list.html', specs=specs, pagination=pagination, query=query, status=status_filter) + +@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') + 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/') +def download_initial_pdf(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER']) + pdf_path = os.path.join(generated_folder, f"{spec.spec_code}.pdf") + + if not os.path.exists(pdf_path): + flash('找不到最初產生的 PDF 檔案,可能已被刪除或移動。', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + return send_file(pdf_path, as_attachment=True) + +@temp_spec_bp.route('/download_initial_word/') +@login_required +def download_initial_word(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + # 安全性檢查:只有 editor 和 admin 可以下載 word + if current_user.role not in ['editor', 'admin']: + flash('權限不足,無法下載 Word 檔案。', 'danger') + abort(403) + + generated_folder = os.path.join(BASE_DIR, current_app.config['GENERATED_FOLDER']) + 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/') +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 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' + + if uploaded_file and uploaded_file.filename != '': + filename = secure_filename(uploaded_file.filename) + 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')}" + if 'new_upload' in locals(): + 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/') +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(BASE_DIR, current_app.config['GENERATED_FOLDER']) + files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx")) + files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.pdf")) + + 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)) + + image_folder = os.path.join(BASE_DIR, 'static', 'uploads', 'images') + if spec.content: + image_urls = re.findall(r'!\[.*?\]\((.*?)\)', spec.content) + for url in image_urls: + if url.startswith('/static/uploads/images/'): + img_filename = os.path.basename(url) + files_to_delete.append(os.path.join(image_folder, img_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')) \ No newline at end of file diff --git a/routes/upload.py b/routes/upload.py new file mode 100644 index 0000000..989dc92 --- /dev/null +++ b/routes/upload.py @@ -0,0 +1,29 @@ +from flask import Blueprint, request, jsonify, current_app +from werkzeug.utils import secure_filename +import os +import time + +upload_bp = Blueprint('upload', __name__) + +@upload_bp.route('/image', methods=['POST']) +def upload_image(): + file = request.files.get('file') + if not file: + return jsonify({'error': 'No file part'}), 400 + + # 建立一個獨特的檔名 + extension = os.path.splitext(file.filename)[1] + filename = f"{int(time.time())}_{secure_filename(file.filename)}" + + # 確保上傳資料夾存在 + # 為了讓圖片能被網頁存取,我們將它存在 static 資料夾下 + image_folder = os.path.join(current_app.static_folder, 'uploads', 'images') + os.makedirs(image_folder, exist_ok=True) + + file_path = os.path.join(image_folder, filename) + file.save(file_path) + + # 回傳 TinyMCE 需要的 JSON 格式 + # 路徑必須是相對於網域根目錄的 URL + location = f"/static/uploads/images/{filename}" + return jsonify({'location': location}) diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b00fcd3 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,35 @@ +body { + background-color: #f0f2f5; /* 一個柔和的淺灰色作為備用 */ + background-image: linear-gradient(to top, #dfe9f3 0%, white 100%); + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +.card { + border: none; + border-radius: 0.75rem; + box-shadow: 0 4px 6px rgba(0, 0, 0, 0.1), 0 1px 3px rgba(0, 0, 0, 0.08); + transition: all 0.3s ease-in-out; +} + +.card:hover { + transform: translateY(-5px); + box-shadow: 0 10px 15px rgba(0, 0, 0, 0.1), 0 4px 6px rgba(0, 0, 0, 0.08); +} + +.btn-primary { + transition: all 0.2s ease-in-out; +} + +.btn-primary:hover { + transform: scale(1.05); +} + +/* 頁面切換的淡入效果 */ +@keyframes fadeIn { + from { opacity: 0; } + to { opacity: 1; } +} + +main.container { + animation: fadeIn 0.5s ease-in-out; +} diff --git a/template_with_placeholders.docx b/template_with_placeholders.docx new file mode 100644 index 0000000000000000000000000000000000000000..6be8005aae0342f544db7f096d994e269b180346 GIT binary patch literal 27392 zcmeFZW0NS&wyxc_ZQJ&2&9-gZwr$(CZQI?mZQC}^T+e>@8|%bbasI$Q`5`MRDnC?K zU1Qvt_oyg2Nnj8Z05AXu0000&0F>Mxkpe&f0LH(k$N&&Pn!jyroQ!Rpbd}ugj2*S< z+^nq#3P6A;@&JJTj{kq{fAAA%N|u%#;716#A^Q$BsSTlXV;GU+d9_zUV!Z3Pl+i!% zF?QvDdy%1J5%EW=M^9*Ap8asja0zM6T{e;o6ZcfD6WBJzVBIg9BR=5V{;G*%I$y1!*j2U-Y!w> zH1P($;;D@PYV7zTnD6~>Z2tTJ1IYbvp^X=h)q3-nSDC+8q5p=qu7k0aBR$=}`u`Ww z|AXWB-=9MN&6NnMHx1t8*=$gXHfH_%MBnoxT~1aZP{)gCiD2&DxVS{m zkzsn`@jY}++vZ@-cK!6HhlJ2lq*P1fe~%4`mR8&(lQC?>KV}qR zb~qUAR~g89F+6?jboX+oGas7#{(}n>H+MBItjNnG?~3EtAXj(PF&}q12&>!zvytWq zHz`u6H#J?}prFQ5rQtsWiQ~;o`&-2dn@2Y85p+D~c6{7)U%pa(o~R54avrpuPCdq< zL^|*^6GCwps#r(R2uLt5nB}W#;1Hv1n~WR4fs(_r#)=XyQ$Xn~*fFsRA1*-A zJQcBgfu;8nIU1Izi~{*)zX*Gy0`s#(pRbpJ_qZqV+MXZ8Ww|D|SkTx5s0mEb#_YTr zR8be5rwDwtxJ9Ux0zgiz4sF$G1}>Ha1z_|G!1&;7kh;0KfDbfjqLNB$^mVEntaXrs zx|@rdeD|yysrWuaGvova%OU;zk90tCSiSx$aB?K+P`mK7esTi-X9PWCQWI0eIf^$H zD2I(sLR{7oO;9JEYJM&Y%{rke3=HFvKtF9qi>IElIDakZx|%~V=W#e#Tm_|f!wAUn zY-^{nu`K*4MBb*TlC_c&IFTzgZW1~{yLoWhoqVqBO}wb|MCWmk_y$5*yv!9?$&4ic z(Y{{{dV3i20H4q>pgwuE6PQYH&e8oC%#5INOI7mR(0tkI(?1+GZ@S-Mw1Tam2(X_# zmO%Fk3yfq?-^O@#z#JFLcv!8ft(_3aE<_x;C;O>zlhd(f=cf5iU(JfgX2E*rRe zQ6>r!R|VA-PXY8XZKmH zILpg9Z=nW}aX=EclMm{`$n9e8LCig-$~<7V0W`si!WP8(BNKfS0?G0ueLlLW#>##t z3jf3%3Q#D4%#!BDfn5ZS}&=d)ZxKa z^qJwkR+v+^7|>E6D`JEYD!aE5`RrQ|1hYw1_jRowY zIt6JaO}aGMor>%@;J0pe(z>=EZCh4rnYF)YUKgi$Rv&#w`FsQP`bl1YsU;vits@+= zSjhfdt!Q#%7VP|v8AB+Y$;KW#6`9J8-gGvRbiEQiAvSsVe52dedpS7(x*6=Ojx2mN zlQ;zNr~kd#M4eGF$Dw{df-uOhSO%&|hz(T=*)S%GfJf?}6;3o?(z^ow6e)6oe^feOb~ zqeP#2Tz?!-FHX+1;TI9leb*7MAA`}+L&^t`Y`f8o%7%1k!ZMH4muxvY(2Y{DaYIM!(?3B($2m0#;zYfJsEPq=M zf#qiy%Ow34v3sXtlDI8G+9L){efO>jfpO>iphkmqs*tmL!r--S{qj`1&eWj&SKn{Ky6M=*@Jeo1rgZ z2^{VQtJ$*mtayfz(xK;+J!UM&2xTmEGb*5~0((0nM$?^;=vN#;)W0e6LWx++cT)!0 zH=ArUZ0E_;maV31)vc-P`+iyT<>`H)^QRMG*fKqZoz-PvyX(_VD_A-<*O-0_BKT|h z%l_%+XS2)Q`fBDVntb-zQwft&as$?Lj|t1r1`b%Z-%2>-cQRvwBao|oR>VLUUuYJs+8jrIU0 zv{<>ASL#RK_9Go3K3iugOrDU|esy5GCulW@UV3MGBee+9`B(E;6dG6z(1&o{ytCoXud+^tDH^&MXt(5w z_hN2Sd&n#_GdS}h$P9B_WiC~jMk#{O+Zg#{Pe^%`2pD)r65LJXVkqLdVShBWlgT(9 zYF@hWQb3ZZRa2MONduSVMgMv@y<-Y*CP&P~^5nEJz$5d1`X?GC8PAE^f6)Sk?z z8?WC+QGGeMHq0x~q|ez&9AGwluS`EEuQBZ6VU0lJiSlW*_axdSX>qz^QgBMh<@yJE ziA$1_l{-3<7VJFE6pdMb(?**5_VzT5DI~$pSSR7#tn`x52V=inIPN{rc_%om80Z;l zSV50%`%cp)c|X&2`t*fP*D7E2))x(jv>W*Oj?vVSLh6(I}g8C!8*QFx3NYv2B;wo^^QweyX;n(zl0I0C{SG87hplb zbOujmg^Vex1ORBpJUJFGu%F?W#3TAmgiZ}j?v8JFa- z(ztH9wLCYADCz5_-1JRGbc0Oc59!nApq^#2yl^y))e44$5dcY5sDJ|P1Px}(>)QH4 zdQP4iX(kWSpRyj&6v);XCXkI?Gr0~kEm-7Ez&3j+ful16_AVK!av4yJG$jeRvba+% zDK9j*eDT^63s(F@yMcSJ@n;pVyDQBdRwkB`5^SAW5d**$=dLe-XBpfOV_}%mDk$zr zdps*^ErJirT>juilfTdK8W}&vt*XvrG6G}*8U_c+FuD+f2+}x`*5J5Tr&2i3h(}(* zVBcT(B-os#d%ugf{&=O|6=kxVP^jcMTApm;6|$^5HVx3sw&za?i0{5CkTx9h%(h=i zUv8swvZENoX-z~^q>5({?9%bz#{5oI7eu+8Ac#pU-2_wKb-{HArRdy9D%}jhK)X-U ztSRO>Vq?laqHNWY#=NhtArqYQ!@_dc%u_TicPCb7R|60*-Qe*c@~d*XywocuWdr{o=KB3#iqz8LCFAbVB=7*jrMX5>S zT{$Brt^ornVc>nJIRll5LxT`X;U{C)fe$Wt$1i<(*VKl9fg#YpLa)mQjp&DlnN7Cl z5@(=Gvg0g%o^Ifncwq$KNDl_>fcpOkHE;adhk-yc^743r_PfK!gRA2xFXu9YxX5+~ z^UX4|$m8b(&I?^>0p==j``+0ka zL*?Zvwb;&;N`(j$O9ttZ{eJ2v4lL{a*!){2050K}3T8MgzS}X3X)>ON`<*hrD{zyR zMymSzVzfEacKOg(S0`=DiI?R8-9 z#&FnT`qVX%U$p3C`cZY9`V1Sr>C0pArG&NNnTUz|(JYKBfI5J=NA*F6mN`X zNYyGE+4(~#e74fz;NOjjv&%FrUTk(r-!^Q_aY(3(q|Cy^b$pbj@uU7Y1Dk^>WIPRc z@dr328fDg@n?CwbCh_27l*iY*>&42A@$!j-RGQ&{mIWsnj-!;CWx!ZbCdj`&}^hMPNa@@*p?tygk}g z{L!YSJOWgGIj=fbQxntPHy0Y;XGa);Mpx6b5cLFk>oYpNegBj$fZPVmj6&kQ2Y}zy zC6%Y2j}$v#*w*@%blO}+1EU_9=(EFFl$CYiQ!Jx@%*2GBV~jye0E$Fu3r5u^geEX$ z(!mj?(tkwjaF@~6N+00SZFRJ}zFv=x+^q#VdLu8<*jH2@hNdB5;=v(=ISZgcJl1#Z z{l{^NrLc?ae{*9L*8RC89>AJjpI;q~9MLsuaODW4j6z&;z$uMGMfoDe9la6SRxiV& zstE9eyo^RQlkC1i65A6$^nb2*Tx17pX@C+MSf{MD4gh!PMm^n1p~XbdjPwj=@EQY7 z#hY_puro@x9N=crNH!Wm5j6x_s1P#Nd4)Xjv4nB|A<|gxk(RE>vpy5qxq0mD6`{t& zz-U>p{)qxKzAU7qL2~)!_4>PA$RCj&PsR{0X*X%bbmLxX*hq!5ucbzO z#AHGR@AOawTd3*xT5PlV>g;Te)pqXEvo}{6ZNv0h=ej}VP3hsq!iwkeGmP%g~V?XH~9u-Ybcg;gP6n|p_p~U3}dTS zQ0->Fp##Ngr4d2N2*rKF4aUhRk{)nSvlK*`_CgSf7*m{zwd5(gr?Ce2BdNK^PFYmB zHxwRtwU);b(8L9mC8&M7$=uNv`Fk|uJ?)yAN{yhj=_460h9R0mKWWWlGaL1kL2IT#8C!1v1%kb~Jnp%H{7g#%65` z>>$sk4{Pu<)=rBRHxSYRF04ysUBr(iJ?$y)6^8Xn6|uZY^GnTRAM>zMGK)`Hq4-|?1;N~8bRMp$vu)FTeALHT+uwbx5hXnE7|P| zmpB8wUk_An;~O zi#Kh7#}AwQY;2v39slJ_Hl=9VW-_3R z?vP(`6KpF2x1+I^S2Rd@XD(j>w~aum+zGcJC)oBH4vRr&!&lYqQGH)?^H2t56sn1s0$mrm!v&YbPp$u(Sd=0|v;y0?wQ(%1Pg$ zQNO56G6SACy0d7QLBW*!?iwsL0_t>AY`Q*JwObhH;w2jkP)&bwTW#hNF|EI9M2~D6 zoC2seq;K?WI*4tkQ?7xuneG9MH#D?Q+Zs z`O!+a{+uBzZo%Ct@!k!iB`J=J9rtbCTox$%rMi~||03WN#MN748D?aJzgXs0%%De! z@-%04#6cZfo&GG=tNavZUlH51O@WZJR{X;-n&~PF_7>uqbwLU2_1LUIQA@65(2d5m zlB8j7VRsX0y-by-r@5WOZLtIwnWVo@kK_a?P!Uvr)OX0=yQ z==}Bax+Cj|ZZ>XZ7g38SD8z5n*~*a znlO}bh%~gKRc(l1Oj`)m4RSrS%J-)+j>doj?oW?K3wVXC16&u+n5O z@<{q@?(HS8$8Z&Z8IG zR$~cavVY+m#9&@NHZWVQOeq+0_!sBlf8pHRP4pLM9cDp!O6QQjI9G80gR=*`+GX9H zCC(arp)pqKUz{O{IG0ndxkcO3r)t#&;1kR3FMZDrAOmu ztZdj~P@cqB4#ezpS@1>Ai;-{0+mw|04mB)dXg`;*hw6QHDj@w(TmG&YrtthQr?6m5 z_Pv9MZY>~rX6VSYi0PO$hSYz@L;F?=YkbIR_>%wn^}dq29EdiU5`1KXu(dI;I-&|6 zzV*kObUZ~-zp{)_8ak{Hh0L_#3eJlLmh1tn;e!T%TZd&fnF>eE;>W_e@_^B)kMRDY z@nBf~?PQ1n!!85vj|WWr?Mp^95*+3xEe{n7c|R;TTP_&F69BUycn=NMNS6u0ymIS7 zoxkytT}wAp;!GdC!2KvH&M6LV$(40t9aDnv1Rb> z@5$L>w`le6>YgvVVfSZW71J5E>iNd2NAL89Vj`FqdkET&n3tFND~6xOFUm*B<-aHhFe>UsR4nkN==jL>pR#~WtUr$qd;*v^#7q7Ro}bx2_qIB!tdeV9Tul+z6F#c0DQr)oG5JB*!pYtVnwRs>?PY50Zv=;Y=8f3*e0%kj$FcNeK-%2Ju zDQ87$q68{}7XU;DjUX^nT%N7klE5Xk<2>oyo$b ztJTK$sA&qMbPU^xYZg{csk23zylch2ueR~bCzFgK-Vvm`v;Vz4EY;IF6KOq_i}~Gz-`_ z?yzylEY7J=6L}UtJwP=TDQx+eP-GZun>bQq^>SnXMLG4MwGA#%Q zHRHIH2*T{fHVHluYiQUDKgvKA-p#K*NFi;Fl*CN_3e3q`w5AW@fR8Zz z!$X|9Xp|zf{c=;|*JvCvGuFxbQ58gXS>guGS-);+(kTH<>?d=eg=HTq!qkT1qgT~V zfe>d!EzDtBFQktsR(m!@+62V;((*V{e_u#x?DJt;?U1>tr`g^&+BLI3Y z+XQ7%qO{>bw~Xq!m6N6>wn*#4cSt>)tYsu8ih_1EtEy!45e^h5aY!V}Jd?yx3u9qi zIZV&5Y6-s5p!zFVb7k~i3+SJ)G+vW;$>p$BK_nG#wN2zJsKyT?oq=Hh!%XhMF_8bIztc=TX!Zppla#iA_ z+lS4N%`WxmeT;F}5;T1!z^0?G%qkYJ^=l_;NIUABPSu#o*5yVJCc*3HKOH?x+Jmit zXU%14Ava)k76<_s+lOIf(7Bqg7Z{ypel$_Vn%R-aarWrqX{_Ji*o06wqI>l6uhuBv zD7fA@!T7>^$oib`1W+%9w=1vj`Y7Kx!LtQ+^jsbo!8@a9VO(LN{XdB;pYAZPG|#@g zje$BT_bIldMFFoCnodWy|534LwqX}H|0?!xn19Q0|5}0me>tuzb;BX^UxLlO;HuZ= z3W9+@Ff`+4(3!|BJ3rVA)F2;7B9aijC3@EQn0*hWZQr1n07GW&4d|Bo^}HgXv3u?@ zi)1NUR1hF*^)gUTJMbG(z9)TEmzUnta60fIarG2EMAq9@1a(=Hj*l9o2?m}+d^ z&R|rd6i|>V3Ucr~;Ex;>>GC@f3QYJ@MhJ+ppvah`7J08kF+{jR+&S0-u8K=I^vji5 z{17Gxc3$iNhNSefXox49kssZkHpqJ}KfR!w4WXx55!iV7C4jeV{=5nSEGOb_hBkfn zgcXwLj`(-!`8MqG`Ybg^|HvdeyP9AlwL#r>0hq~A>vdqe2v+Yr$R%(+u0g0kO5e*o zhyn|s3sF;Kk+ASElCQFYu<-6=9*P4`=dO4>l)Izy7y|LmrnqaQ_->h`8Uy3Zb<{ma zL>tUKu3D@F5`QkJ8iS1GEq~`?f94v3&Q_>8Lvd%_ln&a~b<1jN9jQUYRS_RfR~pKs zUTLRnv_^9TXCZ9JSw|HKjH+G3eVJlT%9@RaHzj~tK#Y*0qrn1$C`mA=*v<_*vq@`Z z`v`SL91ohRjRx><%43i`BHUq9`L2qZu_1$K!>aROo3KM3`9LgJJ#tnF_L~ie+@jSI zQ7mu#tLIfD<_8@Iu*yxj(oC&x*(vWR#nU7j7#nsivClX^qE$S!Dm&_2K*yk91DQ9{ zwG$WSm6S8aXB3?>g}OI^2-2S5;nNpwTONFx%T1_RBI~Ncd+=odo%Mpw@GFi;^&QnB3Mt$w)QC!@gFn3b3>?BRc{EtKOMed9PM`gg{>qx7ubvV$LvDc6C7Mz z%b9LxYO-Af#u)Q?`!41hU^%7|GI@c?WIpv*kH_s@jl<^};PA?DM|uT67HQZyGVd;M4-Hr>*DP61`vu z_2Jkpi>Af0z`JfVsu}i_hTVC4EuT1=K*q6XTURBj%y*<;&C*|2BD1yFZm<)07nzng z&7pyPeU@_17*@NTH9M9oFkN8AvC*(z-mBK{zkzE75c|iikhjcQG@^031EeV1^@3Zm zVOu%J_CPB%B5I^~5jPBIL)a;?#r<69P&fb$;tMEoB(>O4lL7&cDgB|rix~2?TsnJB z#``Kezt0P4;bw`{5@tF^yb2}n0KKN{sJLc?%aW=)=n9GLqDLJ3Qy}L6Jvqa_Aod%C*daNk@)GLvLW!8>Qqkl1p=&~= zxVQC~v{(IXXKk^9UWnMxSAn`vhQaJo73K@4I!YuhY8p_F#m#cFrYGAh5%AWJuWQ}i zIb@AD4KpWvf?zUK!<{%`pLd+B+yPXQz^iN8O2j;pEWss@PvPVj;zqw6^ zk%y>J&?gm`#w}Q%hi6zjzsAPEy!N@yRWy98(q7(f4$ZE# z5|M*JPG9Et-8mt;j4s>LVcVZ0)ocikS30wnBC%YVY2EgeQ6jqr()&$14KR~fDV9To zc4{u{sPX6XhC0LgV*19*PxLst=q98Yrqk&x6X+PSv zzmOBJm1Ap|IS(g^+%x9R^aaD5oA#Btp+}n02b5=xEHDT0sNy=rDbPWaZ&@!@gN^ZQ z7bkv#$ttV8t0+S8%RhGZSLmXd6J1!dL{iTsdu%~Hv0fzjC3s^Z6^~0Hu|kI*H#~rn zJWj|232zJ(6ffEu=l43)jtxTcJJaJWGK(;68i~m!Fh#S$e#|>He6StpO_$x7on2)AU z;7i>z$`td~Frkv@&)bg>u|M{X(@-850JtahKgzXyV?Yl9^w&o{HDHS=*gbrfXdGBd z)LixWq%7zflNX+$L=UIr6i|*bkuLu+3{Vc7AwBCha{z%G%hC>Tt`qJssJCE-cEwP2qLRD>2t$Fql z?Z1;OUdDL7?b4SI=Gon^#{%^!QAls^+={K#(XG)tZwBl6J%+suUITJ6eyfth>!3*k z>+wzhAB8a=+oIp-uOhM={hO)dXzb)&z*kO zbLH0aQt+pURu@KV*TwF9%%4gR$*olEA& zUR{TJwQA_7Bq@kCk8`7^^BseAZBB<Ojjd|r-5K8r+QQA-b-DN& zEgsv!v)$+Z5lc;a$d-$5YsXHm=f>ySHbxh>>-}CX?EavMx6e%>O0QuXzU#uhV&u;6 z?af`Eb=r}y9CEWpUgWlQUf@)(oF5GN zXGPc8OjEq8m0nQ`nYoLlX%;E+Y%iqr>HYIi*NrWf&8l=9v}|;q5BiwJ=^qoanIFz? z9A8e}+&8;B+>J0@*Ust4Sk;$g04ERB6z!dH3*+S2yQLt!<{lA4czQbVX+BHA(mP zXVuu*FYh&%O*-F)QPnT6x5v$CbXeIOgYRb3?+5$LXU|uA zo;rJ8!NL1p{O8b>%B$C}HV;?VSN#tcl~6^#kNTyR!Gr5PyNo}?<16GZV}=`WnRhJl zxmw$qTM1w2?Aa>4y8S0$v!PZt=ilf8EoDw_x5 zao;?kKT&@6+`ex&R{Gn#+p*R-X^}W=pO(BXS&c0^TfOS_>1jTzbZc{;le{e*-MHl6 zeG$^L*edBuO&@ND592Ohl|7S4&&<73U^`hw5iJPf>>b zM}JVJ_<6ri6uyR6og}PlpZXHP@_E;N=P?F5?Ru0MZ&{GEt05y6|qgR7bg4M3+oc| zWI-WPR}WfOF#*LcTAXo*nWIXgj$a~+B-y+16d4Zk5M;yP!DGrZ)Q%h75bQUlwy~Q= z4JmOiKs-#Z#nnZ5jKiE05bCYnQ4NP69`G#F$bP~L1t?)Ipy412kVq`$(_;OJzYr_X zuOP0^;3r``sk!73M^*tM5!09Pymr~ zUgFd%uUOi1Wsu+u^i~Kv7+TZAasmy z>lG=AL%b+Q>7b-oQAo4e{j}?lBQDaQYJgPC3xmOlUDPn7CTo6xTbDNvV!{<#4k~Xc zP~uTQ(lES^g9`E_u4GrzzC<@&0~h7SdpMQ zva?m#fM8X#r@>%Yn%6idFamB0m(UcL>!2uvjR{LmhXoSiIG&W-#(>P;u9{GjJt5%Y z|I#lao3bS5iSbNMEZ4i{poN?bFx(2!sO<_vn~hC9)}nuh-KReGXNTNiAAW{CQJ?6f zU|bSV!|5%jK#B=cN=jmIEin1LEWpz8O@$TP2!u+hSr$!o?`D@dq}6AgV9s%TaLMAz z!qK=hgv9WopjqOi;CUtN`S0ts?sw?Rm@d}=C&c-% ztc)DC=7_Cpi#F_y7CjeANn4SLj5wN}wYLI?gq?-gybmaUuGl{n_k;p`;DQLeq9BG` zv2dze*K3DchW_iz*K#A_a|)g{$s?-vIxS|I>krj8hC+<8aNIA8b6O!g8;k~ta&1^n zWmj34774#J#b1?;qM%6PTGJ?TcgPhFA@;6hhsO5xI)YwTyl*|em? z?ZeEl!p>5~%wveFp<_^@PBf=Ws>?OPXd>Y3VUJ^hxLX%pM2%9MktA6bHeJq@tmYc5 za}*BWkBANHEYvkCrCAV*3D7c*xbbX^sD(Q^8D>$WSxk}vzL*pbxGbtI<4@rtVOrJ^ zu9fFu?|0=bn8Xwk?pMLeonVZ&MHqI~L0Z(^%{v!OV4sV1v7>4-=41<|t@JO?(0~wW z!867Oqr@g5#!`lH?LzDb5rgKmn^Db}s~>^Zm@y%-ELRC}8A3J8tY)T5l?Y1Chz+%@ z7^Om$bU>8X+n^_c<^&7Y6h;ppg^Vf++saUjPNvx=YlIdiD@xGZNjp_sN1=q5+pHhr zCD_4CiVZ$dv5i*@#A`6pH|hydWf5wQs{=05z-PG%t$J>DcGV?(r~NTU&3409@(L6uvb}{>+0(6c`^= zGJ=j6XK`y*B0Xa)JJG0iU7svSDUtv zptz;8q)?NTS$L2sJVZ%7CUxEQ!l!(OehNLQJYt zzz#jcgjr0iG|Dl!TsinMarH;UrmQi^{4SQx=<`;K5ze_2>DGF!G0$fqJ3?XK-4ypiF*s_ig z(1=NpmOa=~FgFlJ!LVz4>bTiCN}XdGfAcubm}qI9oaPm7%t*$rV6Tm$bn)45+a~6- zam8fYZOwzOjcaMk1+6qLS|*{al#Hk@`z;3y2%7mV>*&gq~(EimMNL? z$k(SC3|Vx62gb`ZFA2}P39k#M+UwjjgmKt9sUvLz_KTUC#!gN+yJag6rfeE#+td0n zKW5#EMLKY#+FOm=dtF6bRe5y|#GpNNaKyTrwowt)fVNfOY>SVG^)&;iuGt!_uj4%E z6;X3fJ1qlhHzH{o(>D#{&COc=wv*{j8g zC~R6y_%H+ACOji{R$28{KwYWyn$Pa;;8hQ;6y~*Wfvhf`o!6^=QFmRw@MZ)0(95mv zSC-2yZ%kZ6N32`*GPAcLM3RFtH9gc|us$~qV;{XwkXl;i} zS$9P$g&m1H^XK))f0*+$hF&hqzGA6?^#=K`%87^IR^a<3X-s zBKgcU=OQytbxOnTZ{YW|RMqyh8HkZ9f|%y3M8d+Dn!Yl=BsDKHV!>T88RH?#q!tTn z7l^EgGv=m40^(8Ym{~`Uvu+|S`O)9&}cq(r*Tp}d%90R7UN!Z^p$XKF+f9xH? zM9-bCr&?N(a+@P^xVViC>mPFk+1=*S96r!InSdq{1cK zR@hoG?R(L&8QQ_61)XNSOFrNkQmmY{y|f0pW#N*I_ah`_4?UFF+0G;AoJnNa983t# zYwXdGo3Ge`65n<0J>?oX4ct)>W8V1f4oMwQVH0O(Ka3{~TY{R)7&BOtusk&E1 zc0g3j#w`U!pe%5#iIz?qWwhF-X)@?RMZL=qqB7!AQkB_c09|6y3YE4t0Tx*`c1EDS zHLs;f8dff+5!-i`7NJ@W=Ap+Gyht#%_4P^_5uyNBU!U0)mm%gNA8VY4wDeXP*`F{s z@r*8Jx7=tyBCuS!Gg&*0=vHSN**|65*xnAak{-V zENxR?WtmG^#-01i0EPl!oywVXVCAXofoE8a2(9RWC3+E>?bxDg)<}EX(>9s!!Ytj;0F& zz!Rq6zvBr~L%k5n^KoezpXtT@G}GBb9~ zWk$61-E_ikUV`l?=C_ofB3#`1P}J<(^(s)LU7_VDC)^tIN`ut!#p!<4lG!i80(eU0 zkULpQq|T3_h`|@#%4ai>MHOQQ+WO8hIeALT)y%w+a-8KO?IuekcF~dXQNQ$>8w|=T zRKzchG0NTG6`QkG2G?p*4&0^!)6J$V_i4a2dL-x!Z?v)}I7an~48l!|3i+1OBUB?Q zb8E}`!r0oLhr~>~i@=QScqYV7$lye{N}<T%Dg&2|Sr)$VaZ`VboI~o()$&l@jC&Y%A!ZNH(W$mO%Zf7nh z!o~ZMa;;C2w5Ek{Ba^a6Pk1fl(LNG1oCcF~HMIKG&5mF&N4=Uc{b#?K_irbDBWvt& z=^~5lJzcTswt$PSA6~L@g$*}9krfY6|BiBcMq(-FW(_SlegcYml(Ar;B~8H<;UcHW zG+~b5tf=nq#C7+;sv%~Tk>x&MRM}UQoQ~_y7*5=3->R(N8vLNlXzmHLvt;fl2( zuXSOyCaeXmSLDbnd+>;`Q#dX*h$ul#wfo>#jS>42+O$u#o&BfccJ;cc!eV5nTxJ&; zyjt8D>+A?}-S_3{*rJEox99ln(XyxHb@+IZ2tG5@oVCl1d8fjcv)7)vTek8Fvlr9W zb>WrM;mghuOt*&z@MF48r`237%byr9^8j?qxa-%qPA_JO>+BbAH)DV4R@H0?u|~**W1v~ut!IZEg;?w%*#vZ zWRUIlxGMU^+q(rPJNK>bq`rsm=fQEd$G{?@BKhU(Xk8lkq%GfG9_j4`1Vn>GKt{;x=5`8|p+&V`^ zPm6cHWaGv5bDar_0m(v3k94IL`o`)K(i;%WpI%uH&u=~-uNU9{Xvy}fn*JXBdxh!m zm&wQg6aa?Kj!w4LYSvct=1#`e|5Pdo3P=Db@(BJ8`oFiTxPN{O5TZML+QV^?9~D=G zvW~YQ(30a~k3-71s!+ES-}BX#dwq(RPY$WXouN_is^(=Ud1P~?u2Jq)~| z$&b`cIaz(JeS<6ehvoqnui;1D`ScGIT=wSwYVRt;qT1T_AR*n|DUAq2BLdQm(%qdS zNGV8zl!SD5OQ$pp0s;aK-6dT^NPlzA@jWW%eBX6_|6hJQ3~TTEUVAUrti9H=p6A}L zMJ`QmoAzf}pw3P)B?jIIL`RVoTCu~vjt6$)LELCt7niZ@HMKXav)-^4_-BkaJdl!X z33lJ=u%pEPXde}jqtlE3S0kmqQ?!MtCj`ON6M{3FZO?3Z7?e=X-x%~FH8(}Z5Ss{1 z4ownY?!TBU656AuMA`IE^*-l(ei|uaWr=9Vht5^g?>dcn&%jWplLk$7T1jar8?e3j zOju-9pWltEA3LqrRw6bpNa;ie?fZ0n=`w?(%wTddd2&!}Rk6})#pAkWx`!ptn{R{I zitGjP5W*h~S<3m7j(nzvTa2Jo|3Sdn`n3cB*4OByH^(3-t`K&gzB7xN4U-|$uV1oYn|gW zvPm{Jrqf0uh|w8jk2b>)LJll(DJco+F4r*Zw;Krk!fPXggXA2S&0;mBeqZE0QkW## zR14u73d^$D*5msIwFz=FRU$!nP9f)mQ_dQ?H-aN)NU8Z^E~HNt+?y9hs|Jzw>e)RRCR zedP<|gyr{n#aTX@7~$w)-RGLEWHf4}QT=QL%dAi1v)oN2T`_p78>snSgMaMSt`WMr zK(YFu#!^(Cz&BkXsjmtCvSwUjj>(TIJR~cmeZV@X)S@gN^3klUJE9GJT@`{KI&+Sb;vK-a(7@reMY#{mZVlo%P$#)7nzwP3atMEZi!_SHLkezEs`ksBFA- zK0LQ#gsz>%Bo29B-bd$(ld+BylYgj8GJ96r>~T8AS;|#)nkwD?M6x~XW4Hgr@KAdR zyz)dOai}p)mOwOF;J~uMQfjj0e%=AXleSx8vdmRBQPnGY(P1H?{i*x z9wTbDv<`1v1t-P}#7(}Gciv*FiU$s-4(EJS z89rj<%1+idBIj;At$yhVU&QwGuKljU7*`F6m6-_-o7W7vL~gU4v^qqUbwa_wUhkCF zPGO@uK3QnluHKFr^8ABp`tV`0SRvClwdHdP#8!{T+51jzVWZN#UHO)R4vy7*RT_=r^-`AlCNV~^K$<8t{)`=!5bsFaRK@|_C1c++|e z-M*6^l`(w%I;FcqD0wZs;J*K$G@1XKe3~dNPghR&7amo0PmquNa&baEQ`dTo8rw&+Q0#K z8K|C%V5q!-xR#DFZ#^87u`&3C2s35q&;}uM9OuHU%7++tl*tabI)D*hZaqJ^)uI$l zKDLXN+_HYT;?rUS4;Zkjile9+SJu-|F0cFS9dlp7gXm?_+UHAqxB^nL-&c27)<{Tt z{pdeZBKIPqvfaMD8SvR+Xi?7?;6P7DWtR|sCBhQwuf5wv2N9*7sBq+Vmmxx?~h`U9NNOM{OfJ+9&0+ zTLx5t^8pu9=D64kqkU+U24hxgArCQ;$UvIQU3`{ABr8eS7afa-rq@d$4|d`taTFsk z#+bu4W|$QhR)IR+H-n+LB{$ToP_>Vgd%MNXQP7QZdl}1T7t1qo^d=Jz^TgQd)2r8p z-k&QIRVY5TP&|DuXLf_eyHBSERSjXcjwWldw;0+#(YFtr9y!Q*S#zx-H0@DHu`BX5 z@|4m>`Vy;_F7VuOFC;Horq;%*flO4tr?L=durdT=u+j=;uu=wbu#yRGuo6Kq*)-G? z*o&Kwtkm&T7oW64q(;I^$nAuD39SE^p}|>m7~n7qL0O1s7Hn&F)+~6zx3{&k3)W97 zJ)8v<5u~CdA%-~CAX8pA_Jpr3K{>4(CY?Ot8;3nL23*N^#XZe8=Ert{{ za=Cj@4;tH&H_Cn}&B>O){W+)P)||@1EqGi>zi&8+BPQwEQr!&s+=5voichz=E3U;% z<438}7)`M%1?V*AwSW=ASPOm|49se9it9aUZv#A#w*mr4_7{LkAJ>A3*!G`j;Np@G zt|WSzv>fA^yoI#!swCdz`F0+EK*!JbF*f@Cc>>!yxgdHJs0^QPoenMwS2kSvXPdB!*rDI@%lsOB0X_4Wp*4!<2`0OZJJ1@;VCOaKY6&TY~`*>$JdjO8_a zwC9H93{ncBDRoGo2h+-=A}uK+3UTHWEwZppqjQXiD;#M_@PHOKLtCESsO-^j>{Q&U z0z{5S;;hKq8BWwm$)ZKb1qUzw;&2998&JW2Q18dzBJ6{sca+4&z1IF6j`A>jF_bS~ z1oyjtz01|pCm6!`l1`rAit2;YF|n8@A3-_$;^-+oXhY2($R{NTs2zGM?1NLyqN%1O zr3P#rP`5()Y5Pj`T-?9Je+uKHy>-{ZV4kn1H$7^2_fS>e)0ZR|1A6|Q5xA4FWpLC! z5S^t76{qk~lh-%j#hUO*Qt3J|q-An^yD0|#5Ii*QbSB(3>Otg&k*l`39i8|6X06fXWDTiZ$XH%v<&`IMS=$v5`O^!PyW*_r>6c?e# ziy*)AqX55ikU#9Vub+pj=q;-Yy4LN$AI8>IF0p{&4IGrf(xAXc+0p|&@dN*JPDAH> zqt<1Gj#oWX2il^Hc$LVfd_BZ6hL0?%uN8sTVzDH{GB3ZOoD1h`c6Y&z#=ML^483}U zq&yJT@R)k-(bnXONptr(=tx)abR5`sG{u1>Eg?((VsCSD8q}#85BBX!>t!9}C;)C6 zN*Q+3=RT*D238;UIk0Z6jf4kWa6aaro7k-QhW3(&-EX8uaN5!2C7*^42kL3^)D{JA z#QB~^*pxuTq_8#+|0dDIZE{Xa(q+)ytM(AYpjthd_bvW*V}f_LV|rxv!Mlmtx?co;P^p~UZc zt2&XS#h)jDK`PL-$5?Ko4Hk(^8;hVA5!j!^NyB?bF~W?eJ*djur?i=xvJ)~S+aD=v z$;wZ+SVd6NYtCx5A5%u%!=QVjg$O%60sbX|P9e50p-t3#KMG%57EU)@mfXxhR2-`; zao71JetXLe50NuIOQxe<^DeTt zgyL!3hzGmn@r#N5FLghq#Akk*aR_r6!z~r8pQ-v_jgsH7TbEnn(%8TxsUY3nz&=O} zP9cQW+1V#{(Krl@1ksQ$)qS=j?u2s~=nYy@qK<{W-cE~LsnOLpL`Ak=vcf*y38s>W;b99G)ck`jVjQ)Wfl*AhbAH5Te0gs50(X*Q!ll-ip}aZ~j$IQPnf zm@GA)#-)@Pn{>mx6<*d2v+uB z#V>gz8XHMD_WnL(BjW_(X>VvI%}&+FP<1FrQst) z$v2I{-LR1Xda|2WhY=>if;~ZQ1D6e*N2+xxs3+0sUs~ zTtpfg-ue=uN=|^8nI{)-LYoCU47v2n! z$>&vyFA*86l4}%3JSz6?4`d`BcgXS5eo@{X#VUVh53#g?Y_k!q)y!L%uB^K6TNOTi zp&_EP6C`;hbyFd<;#`~MP&hB~Bw#1mqH5a)jY8)9atr=-9ni;zjcQZfJHL*L5;uPu z%Zpq|GU4fKG9S} z7*o|T@CJ!{^XR(6s?|JCq@E;~+8MdX%f)@aNdzAXFxRhhU*L|TXM^Z}oQxc=8+tl$~OU>?;lE%hYHD?>X*%YbLb-05K+4lkq)NsR4s zK5E-nJ(*Q`-gKP2zQ&xS4{{AD=TRs25VK6tG0-A5J`(32mZUGvMd>sq<6j!s_c>it zrt(wvx!f%E0*Q&Bi$c7SmjD8s(m+YAhIRKci8gDe5)rs zpvF_Hv@lPCY#nhTl0#EYsSd)@-{)Bg260)?64H834`1o>K|zh07(QcLj)yEh7gYbc z$kH3zscVEerf^_lDfs_cWVxA}JpU!6GBvKlVdf8q6Z{2c&|XB^=STN)WPLN4RE!GB zya3wSrp7T=GIFWMo6-VG%j`m5n-jl2yrz;tIUa+KY^(x?SZi!~GtAlGSXEe>b*nS> zKSZ&{e{U~aC1o$2LJ{_w7k=fj)sIw3ou$+&PYK)`0EHm@3HpN~Wbq zQgTC&r2DK>m-|&g-REs4Qh5kN?kPMCqyy{GX1{w+3GKsH4yf?{8EafYg7#i$91zKaeW8aQ(FWowLnQ}xH3FgL)zjdUs=TJ?p|9eh}RdVI$u6F zg2i!%q{`dS^zL(a<8`jnogLB6hw4eQe8Nq14wBx(l|S1!SPSzv;a^dZm5#Cm$&7qb zeBU3O&Zzp9Ij&i_SAlP+AyZ{j`q3E^{(kupMYlC3K~hY3VIC2H(}^|Q9X)W^0$~X+ zvYg4HKk^J=2pG+s6r(p@v;3&WFN~gu+?d&Q2*~Pzk2`L3<%A)Df(x5A5H>wnuISvbZ&GKW8 zvrY+VW-ne?jQ?sSF>+ux)bM6ZA@;aj$a94pjkitE2Wshf*a=od-7{84ReEIhZY!E7 zz+^LZrPE`8*{tpL~}QFk&eUSf0+HCbFp*}ZktT1+uU1~akToqUOg{DAWB)U#fM z3=U7`udcjbTy9=}aHJ|F=Y0OHDbL_6W#R(cQ<9@3ZqHehh0pmQ1-Ap*8N8Xb> zmlXO?)a?mfIK1$*focp-@);J?k`E%PbvlwO>H02?dD21?V3Lw( zg%W9tgp0TeLNXc6iD#(8+Y)zQ+HXmOr9jg7Lb%`|Xsj~Hhi49Kdb-bjPjL0{Wv25! z>SRZpslTmUAyfIR==U^uy`}Q)IrT27X84mTp2dK7BpQoJM(In0#F}(CyU~78pKNRg zFsb>2aPh77Inz{L9e7ssw+MZ4H4u1ztcbX;6TH6R4FUnjT^|=I3)xO)EYCnD9Ou+c zi)KpsGllVlPev2oS$bLh12Ao|VGKmkeb2{4X@qQ*9Y`$?C&%Z`iv@dlsK9WIH z<@%<%G`PnQ6869`t|o6*YPii?4o4nTMlLBgoX_kt+EVP z1as(SHZ^@3$20LgsSTt4)2TCE{}6Et>x_dh=iZ*@c@5(1`)m)O`~s{pxJHln2e04? zPi=hyzBgt9(}to#;WLN#zAvo*C5m-div6ly)6oOgpNCzTYX3pR@G(DO%lPbNJ``xU5LT{LNSM7p5J3*!cSQ?osbYYm5Fo%E=J%-5Ac=z32}b0Z|mfVT5!U@MNfy&6%`@ zY#eVe80-Xb0u=CRGBPv?Do_9u^JMIj*)He)@g0&Axyzf+LD4906r%uBli9)uX_2Xg z`Ruc{DS6n!YPswoyvPfZ-sy;u6n1%>4Os~8bP5tiPXKm$`+*E1yc*qSq=v6{9YDl} ztZf~kgDpyP<7i1eY(xV^OMS?70okZ&bR-e7_PnwNZz&TTZ* z1(s2yz`apj|1Ln}@JOb4`VtQT6>^I2Usm_lzaRhwG^CG{Uw7g%K$ z*{BdxV2CIod0$@Jz`yMQcW~g3v{R7O&3%*h>h6(4Io!c3$(G>d49j&an;Il$(~RSc zACM8yVm3{8soJw+{A%dhGJf6$Hy2xk>F@iBm{jHZ@sbd4M2=enof``Anq=ThB0|)H ziYqggh~jz=@kfh@T?@rUZ&8|TPsV)aV2|0qwte94vBOp$|Ne5AUvBl!kN@(j7!~>7 z34VVC#jgZ2uquCfA;n$bU2XoK&`Ve{_CJ*R@4|mqbNdMf0FX(3!2cI*x4TStwYYwA z#leK+{;AA$7k^j#=qKKc@(2D`C8WFXyJ9mx;aoI7;J--E+(qA&arlWQeDKS~{}6S! zi@uxs{u52j`~!VA5&ka4-6X-E6at(-D1J>DybJ$5pyekR0Qf8k__y$uy9B>SeEdqV zBXyVHXZ*)qmb-xuKY3VXe%!@xVGwumclS?!;^`Is(+Yp@rrrhL^$Yw2hid---|-UM zWw^WC{K?>=`-9=P#phl8@00PLXaFGI008*M)ch{|_qorna0}yK;6G+UchSE;ihe~C fz!FdY?+25LJR)qc_#+ky4=@Z +

403

+

權限不足 (Forbidden)

+

抱歉,您沒有權限存取此頁面。

+
返回總表 + +{% endblock %} diff --git a/templates/404.html b/templates/404.html new file mode 100644 index 0000000..8daa179 --- /dev/null +++ b/templates/404.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}找不到頁面{% endblock %} + +{% block content %} +
+

404

+

找不到頁面 (Not Found)

+

抱歉,您要找的頁面不存在。

+ 返回總表 +
+{% endblock %} diff --git a/templates/activate_spec.html b/templates/activate_spec.html new file mode 100644 index 0000000..56745d7 --- /dev/null +++ b/templates/activate_spec.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}啟用暫時規範{% endblock %} + +{% block content %} +

上傳簽核檔案以啟用規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+
+ 請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。 +
+
+ + +
+ + 取消 +
+
+
+{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..4d77af0 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,94 @@ + + + + + + {% block title %}暫時規範系統{% endblock %} + + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/create_temp_spec.html b/templates/create_temp_spec.html new file mode 100644 index 0000000..5caa930 --- /dev/null +++ b/templates/create_temp_spec.html @@ -0,0 +1,332 @@ +{% extends "base.html" %} + +{% block title %}暫時規範建立 - 暫時規範系統{% endblock %} + +{% block head %} +{{ super() }} + + + + + + + +{% endblock %} + +{% block content %} +
+
+

暫時規範建立

+
+
+
+
+
+ + +
+
+ + +
+
+
+
+ + +
+
+ + +
+
+
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+
+ +
+ +
+
+
+
+
+
+
+
+
+ + +
+
+
+
+
+
+
+ +
+ +
+ +
+
+ +
+ +
+ +
+ + +
+ +
+ + +
+
+
+
+
+
+ + + +{% endblock %} + +{% block scripts %} + + + + +{% endblock %} diff --git a/templates/extend_spec.html b/templates/extend_spec.html new file mode 100644 index 0000000..a99f7d4 --- /dev/null +++ b/templates/extend_spec.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}展延暫時規範{% endblock %} + +{% block content %} +

展延暫時規範

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} +
{{ message }}
+ {% endfor %} + {% endif %} +{% endwith %} + +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+

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

+ +
+ + +
預設為原結束日期後一個月。
+
+ +
+ + +
如果本次展延有新的文件版本,請在此上傳。
+
+ + + 取消 +
+
+
+{% endblock %} diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..88b4178 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,40 @@ +{% extends "base.html" %} + +{% block title %}登入 - 暫時規範系統{% endblock %} + +{% block content %} +
+
+

登入

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+
+
+
+{% endblock %} diff --git a/templates/spec_history.html b/templates/spec_history.html new file mode 100644 index 0000000..b37ccd6 --- /dev/null +++ b/templates/spec_history.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

操作歷史紀錄

+

規範編號: {{ spec.spec_code }}

+
+ 返回總表 +
+ +
+
+
    + {% for entry in history %} +
  • +
    +
    + {{ entry.action }} + 由 {{ entry.user.username if entry.user else '[已刪除的使用者]' }} 執行 +
    + {{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') }} +
    +

    {{ entry.details }}

    +
  • + {% else %} +
  • 沒有任何歷史紀錄。
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/spec_list.html b/templates/spec_list.html new file mode 100644 index 0000000..cc0d3f9 --- /dev/null +++ b/templates/spec_list.html @@ -0,0 +1,143 @@ +{% extends "base.html" %} + +{% block title %}暫時規範總表{% endblock %} + +{% block content %} +
+

暫時規範總表

+ {% if current_user.role in ['editor', 'admin'] %} + 建立新規範 + {% endif %} +
+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {# This block is now handled by the Toast container in base.html #} + {% endif %} +{% endwith %} + + +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+ + + + + + + + + + + + + + {% for spec in specs %} + + + + + + + + + + {% endfor %} + +
編號主題申請者建立日期結束日期狀態操作
{{ spec.spec_code }}{{ spec.title }}{{ spec.applicant }}{{ spec.created_at.strftime('%Y-%m-%d') }}{{ spec.end_date.strftime('%Y-%m-%d') }} + {% if spec.status == 'active' %} + 已生效 + {% elif spec.status == 'pending_approval' %} + 待生效 + {% elif spec.status == 'terminated' %} + 已終止 + {% else %} + 已過期 + {% endif %} + + {# 只有 admin 才能看到啟用按鈕 #} + {% if current_user.role == 'admin' %} + {% if spec.status == 'pending_approval' %} + + {% endif %} + {% endif %} + + {# editor 或 admin 都能看到展延跟終止按鈕 #} + {% if current_user.role in ['editor', 'admin'] %} + {% if spec.status == 'active' %} + + + {% endif %} + {% endif %} + + {# Admin 專屬的刪除按鈕 #} + {% if current_user.role == 'admin' %} +
+ +
+ {% endif %} + + {# 下載按鈕 #} + {% if spec.status == 'pending_approval' %} + {# 待生效狀態的下載按鈕 #} + + {% if current_user.role in ['editor', 'admin'] %} + + {% endif %} + {% elif spec.uploads %} + {# 其他狀態(已生效、終止等),只提供已簽核的 PDF 下載 #} + + {% endif %} + + +
+
+ + +
+{% endblock %} diff --git a/templates/terminate_spec.html b/templates/terminate_spec.html new file mode 100644 index 0000000..62db896 --- /dev/null +++ b/templates/terminate_spec.html @@ -0,0 +1,27 @@ +{% extends "base.html" %} + +{% block title %}提早結束暫時規範{% endblock %} + +{% block content %} +

提早結束暫時規範

+ +
+
+ 規範編號: {{ spec.spec_code }} +
+
+
+

主題: {{ spec.title }}

+
+ 執行此操作將會立即終止這份暫時規範,狀態將變為「已終止」,結束日期會更新為今天。 +
+
+ + +
+ + 取消 +
+
+
+{% endblock %} diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100644 index 0000000..43c42a9 --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,91 @@ +{% extends "base.html" %} + +{% block title %}帳號管理{% endblock %} + +{% block content %} +

帳號管理

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} +{% endwith %} + + +
+
+ 新增使用者 +
+
+
+
+ +
+
+ +
+
+ +
+
+ +
+
+
+
+ + +
+
+ 現有使用者列表 +
+
+ + + + + + + + + + + + {% for user in users %} + + + + + + + + + + + {% endfor %} + +
ID使用者名稱權限上次登入操作
{{ user.id }}{{ user.username }}
+ + {{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '從未' }} + + +
+ +
+
+
+
+{% endblock %} diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..a90ce0c --- /dev/null +++ b/utils.py @@ -0,0 +1,160 @@ +from docxtpl import DocxTemplate, InlineImage +from docx.shared import Mm +from docx2pdf import convert +import os +import re +from functools import wraps +from flask_login import current_user +from flask import abort +from bs4 import BeautifulSoup, NavigableString, Tag +import pythoncom +import mistune +from PIL import Image + +BASE_DIR = os.path.dirname(os.path.abspath(__file__)) + +def _resolve_image_path(src: str) -> str: + """ + 將 HTML 圖片 src 轉換為本地檔案絕對路徑 + 支援 /static/... 路徑與相對路徑 + """ + if src.startswith('/'): + static_index = src.find('/static/') + if static_index != -1: + img_path_rel = src[static_index+1:] # 移除開頭斜線 + return os.path.join(BASE_DIR, img_path_rel) + return os.path.join(BASE_DIR, src.lstrip('/')) + +import logging + +DEBUG_LOG = True # 設定為 False 可關閉 debug 訊息 + +def _process_markdown_sections(doc, md_content): + from bs4 import BeautifulSoup, Tag + from PIL import Image + from docxtpl import InlineImage + from docx.shared import Mm + + def log(msg): + if DEBUG_LOG: + print(f"[DEBUG] {msg}") + + def resolve_image(src): + if src.startswith('/'): + static_index = src.find('/static/') + if static_index != -1: + path_rel = src[static_index + 1:] + return os.path.join(BASE_DIR, path_rel) + return os.path.join(BASE_DIR, src.lstrip('/')) + + def extract_table_text(table_tag): + lines = [] + for i, row in enumerate(table_tag.find_all("tr")): + cells = row.find_all(["td", "th"]) + row_text = " | ".join(cell.get_text(strip=True) for cell in cells) + lines.append(row_text) + if i == 0: + lines.append(" | ".join(["---"] * len(cells))) + return "\n".join(lines) + + results = [] + if not md_content: + log("Markdown content is empty") + return results + + html = mistune.html(md_content) + soup = BeautifulSoup(html, 'lxml') + + for elem in soup.body.children: + if isinstance(elem, Tag): + if elem.name == 'table': + table_text = extract_table_text(elem) + log(f"[表格] {table_text}") + results.append({'text': table_text, 'image': None}) + continue + + if elem.name in ['p', 'div']: + for child in elem.children: + if isinstance(child, Tag) and child.name == 'img' and child.has_attr('src'): + try: + img_path = resolve_image(child['src']) + if os.path.exists(img_path): + with Image.open(img_path) as im: + width_px = im.width + width_mm = min(width_px * 25.4 / 96, 130) + image = InlineImage(doc, img_path, width=Mm(width_mm)) + log(f"[圖片] {img_path}, 寬: {width_mm:.2f} mm") + results.append({'text': None, 'image': image}) + else: + log(f"[警告] 圖片不存在: {img_path}") + except Exception as e: + log(f"[錯誤] 圖片處理失敗: {e}") + else: + text = child.get_text(strip=True) if hasattr(child, 'get_text') else str(child).strip() + if text: + log(f"[文字] {text}") + results.append({'text': text, 'image': None}) + return results + + + + + +def fill_template(values, template_path, output_word_path, output_pdf_path): + from docxtpl import DocxTemplate + import pythoncom + from docx2pdf import convert + + doc = DocxTemplate(template_path) + + # 填入 context,None 改為空字串 + context = {k: (v if v is not None else '') for k, v in values.items()} + + # 更新後版本:處理 Markdown → sections(支援圖片+表格+段落) + context["change_before_sections"] = _process_markdown_sections(doc, context.get("change_before", "")) + context["change_after_sections"] = _process_markdown_sections(doc, context.get("change_after", "")) + + # 渲染 + doc.render(context) + doc.save(output_word_path) + + # 轉 PDF + try: + pythoncom.CoInitialize() + convert(output_word_path, output_pdf_path) + except Exception as e: + print(f"PDF conversion failed: {e}") + raise + finally: + pythoncom.CoUninitialize() + + + + +def admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role != 'admin': + abort(403) # Forbidden + return f(*args, **kwargs) + return decorated_function + +def editor_or_admin_required(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_user.is_authenticated or current_user.role not in ['editor', 'admin']: + abort(403) # Forbidden + return f(*args, **kwargs) + return decorated_function + +def add_history_log(spec_id, action, details=""): + """新增一筆操作歷史紀錄""" + from models import db, SpecHistory + + history_entry = SpecHistory( + spec_id=spec_id, + user_id=current_user.id, + action=action, + details=details + ) + db.session.add(history_entry)