From b9557250a410cf778a51ece25ffe28543f494ffb Mon Sep 17 00:00:00 2001 From: beabigegg Date: Wed, 27 Aug 2025 18:03:54 +0800 Subject: [PATCH] 1st --- .gitignore | 40 +++ README.md | 133 +++++++++ USER_MANUAL.md | 105 +++++++ app.py | 54 ++++ config.py | 31 ++ init_db.py | 72 +++++ ldap_utils.py | 118 ++++++++ models.py | 59 ++++ requirements.txt | 14 + routes/__init__.py | 0 routes/admin.py | 76 +++++ routes/auth.py | 54 ++++ routes/temp_spec.py | 426 +++++++++++++++++++++++++++ routes/upload.py | 29 ++ static/css/style.css | 203 +++++++++++++ template_with_placeholders.docx | Bin 0 -> 27596 bytes templates/403.html | 12 + templates/404.html | 12 + templates/activate_spec.html | 27 ++ templates/base.html | 94 ++++++ templates/create_temp_spec_form.html | 83 ++++++ templates/extend_spec.html | 35 +++ templates/login.html | 43 +++ templates/onlyoffice_editor.html | 39 +++ templates/register.html | 47 +++ templates/spec_history.html | 34 +++ templates/spec_list.html | 153 ++++++++++ templates/terminate_spec.html | 27 ++ templates/user_management.html | 91 ++++++ utils.py | 197 +++++++++++++ 代辦.txt | 45 +++ 31 files changed, 2353 insertions(+) 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 ldap_utils.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_form.html create mode 100644 templates/extend_spec.html create mode 100644 templates/login.html create mode 100644 templates/onlyoffice_editor.html create mode 100644 templates/register.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 create mode 100644 代辦.txt diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..c664fac --- /dev/null +++ b/.gitignore @@ -0,0 +1,40 @@ +# --- 敏感資訊 (Sensitive Information) --- +# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。 + +# --- 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/ +.env diff --git a/README.md b/README.md new file mode 100644 index 0000000..e9ad889 --- /dev/null +++ b/README.md @@ -0,0 +1,133 @@ +# TEMP Spec System - 暫時規範管理系統 (ONLYOFFICE-Edition) + +這是一個使用 Flask 開發的 Web 應用程式,旨在管理、追蹤和存檔暫時性的工程規範。此版本已整合 ONLYOFFICE Document Server,提供強大的所見即所得(WYSIWYG)線上文件編輯體驗。 + +## 核心功能 + +- **使用者權限管理**: 內建三種角色 (`viewer`, `editor`, `admin`),各角色擁有不同操作權限。 +- **規範生命週期**: 支援暫時規範的建立、啟用、展延、終止與刪除。 +- **線上文件編輯**: 整合 ONLYOFFICE Document Server,支援多人協作與專業級的線上 Word 文件編輯。 +- **範本自動填入**: 建立規範時,可將表單資料自動填入 Word 範本中。 +- **檔案管理**: 支援上傳簽核後的文件,並與對應的規範進行關聯。 +- **歷史紀錄**: 詳細記錄每一份規範的所有變更歷史,方便追蹤與稽核。 + +--- + +## 環境要求 + +在部署此應用程式之前,請確保您的系統已安裝以下軟體: + +1. **Python**: 建議使用 `Python 3.10` 或更高版本。 +2. **MySQL**: 需要一個 MySQL 資料庫來儲存所有應用程式資料。 +3. **Docker**: **[重要]** 本專案依賴 ONLYOFFICE Document Server,推薦使用 Docker 進行部署和管理。請確保您的伺服器已安裝並運行 Docker。 +4. **Git**: 用於從版本控制系統下載程式碼。 + +--- + +## 安裝與設定步驟 + +請依照以下步驟來設定您的開發或生產環境: + +### 1. 下載程式碼 + +```bash +git clone +cd TEMP_spec_system_V2 +``` + +### 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. 設定環境變數 + +複製範例檔案,並填入您的實際設定: + +```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 +DATABASE_URL="mysql+pymysql://user:password@host:port/dbname" + +# --- ONLYOFFICE 設定 --- +# 您 ONLYOFFICE Document Server 的公開存取位址 +ONLYOFFICE_URL="http://localhost:8080/" + +# 用於保護 ONLYOFFICE 通訊的 JWT 密鑰 (請務必修改為一個新的隨機長字串) +ONLYOFFICE_JWT_SECRET="your-onlyoffice-jwt-secret-string" +``` + +**注意**: 請先在您的 MySQL 中手動建立一個資料庫。 + +### 5. 啟動 ONLYOFFICE Document Server + +使用 Docker 啟動 ONLYOFFICE Document Server,並啟用 JWT 驗證。 + +```bash +docker run -i -t -d -p 8080:80 --restart=always \ + -e JWT_ENABLED=true \ + -e JWT_SECRET="your-onlyoffice-jwt-secret-string" \ + onlyoffice/documentserver +``` + +**[重要]**:指令中的 `JWT_SECRET` 值,必須和您在 `.env` 檔案中設定的 `ONLYOFFICE_JWT_SECRET` **完全一致**。 + +### 6. 初始化資料庫 + +執行初始化腳本來建立所有需要的資料表,並產生一個預設的管理員帳號。 + +```bash +python init_db.py +``` + +腳本會提示您確認操作。輸入 `yes` 後,它會建立資料表並在終端機中顯示預設 `admin` 帳號的隨機密碼。**請務必記下此密碼**。 + +--- + +## 執行應用程式 + +### 開發模式 + +**請確保您的 ONLYOFFICE Docker 容器已在運行中**,然後在另一個終端機視窗執行 `app.py`: + +```bash +python app.py +``` + +應用程式預設會在 `http://127.0.0.1:5000` 上執行。 + +### 生產環境 + +在生產環境中,建議使用生產級的 WSGI 伺服器,例如 `Gunicorn` (Linux) 或 `Waitress` (Windows)。 + +**使用 Waitress (Windows) 的範例:** + +```bash +pip install waitress +waitress-serve --host=0.0.0.0 --port=8000 app:app +``` \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..d9a6153 --- /dev/null +++ b/USER_MANUAL.md @@ -0,0 +1,105 @@ +# 系統操作說明書 (User Manual) + +歡迎使用新版「暫時規範管理系統」。本說明書將引導您如何操作整合了 ONLYOFFICE 線上編輯器的新系統。 + +## 1. 系統簡介 + +本系統旨在提供一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。 + +--- + +## 2. 登入與主畫面 + +### 2.1 登入 + +請使用管理員提供的帳號和密碼,在首頁進行登入。 + +### 2.2 主畫面 (暫時規範總表) + +登入後的主畫面會列出所有暫時規範,功能包含: + +- **建立新規範按鈕**: (僅 Editor/Admin 可見) 點擊此處開始建立一份新的規範。 +- **搜尋與篩選區**: 可根據「編號」、「主題」或「狀態」快速找到目標。 +- **規範列表**: 顯示每份規範的關鍵資訊。 +- **操作按鈕**: 根據您的「角色」和規範的「狀態」提供不同的操作選項。 + +--- + +## 3. 核心操作流程 + +### 3.1 流程總覽 + +新版系統的標準工作流程如下: + +1. **Editor** 填寫初始資料表單,建立一份新的暫時規範草稿。 +2. 系統將初始資料**自動填入** Word 範本,並在瀏覽器中開啟 **ONLYOFFICE 線上編輯器**。 +3. **Editor** 在線上完成文件的詳細內容撰寫,文件會自動儲存。 +4. 完成後,可下載最終的 Word 文件 (`.docx`),進行線下簽核流程。 +5. 簽核完成後,將文件儲存為 **PDF (`.pdf`) 格式**。 +6. **Admin** 登入系統,上傳簽核完成的 PDF 檔案,正式**啟用**該規範。 + +### 3.2 建立新的暫時規範 (Editor / Admin) + +1. 在主畫面點擊 **[+ 建立新規範]** 按鈕。 +2. 在「建立新的暫時規範」頁面,填寫所有初始資料,如主題、站別、Package 等。 +3. 點擊 **[建立並開始編輯]** 按鈕。 +4. 系統將會自動重新導向至 ONLYOFFICE 編輯器頁面,您會看到一個已預先填好初始資料的 Word 文件。 +5. 在線上編輯器中完成所有內容的修改與撰寫。您的所有變更都會**自動儲存**。完成後可直接關閉分頁或返回總表。 + +### 3.3 啟用暫時規範 (僅限 Admin) + +當一份規範的線下簽核流程完成後,管理員需執行以下操作使其生效: + +1. 在總表中找到狀態為「**待生效**」的目標規範。 +2. 點擊該規範右側的 **啟用圖示 (✅)**。 +3. 在「啟用暫時規範」頁面,點擊 **[選擇檔案]**,並上傳**已簽核完成的 PDF 檔案**。 +4. 點擊 **[啟用規範]** 按鈕。 +5. 完成後,該規範的狀態會變為「**已生效**」。 + +### 3.4 管理已生效的規範 (Editor / Admin) + +對於「**已生效**」的規範,您可以進行展延或提早終止: + +- **展延**: + 1. 點擊 **展延圖示 (📅+**)。 + 2. 選擇新的結束日期,並**必須上傳新的佐證文件 (PDF 格式)**。 + 3. 點擊儲存,規範的效期將會延長。 +- **終止**: + 1. 點擊 **終止圖示 (❌)**。 + 2. 填寫提早終止的原因。 + 3. 提交後,規範狀態將變為「**已終止**」。 + +### 3.5 搜尋、篩選與下載 + +- **搜尋/篩選**: 在主畫面的搜尋框和下拉選單中操作。 +- **下載**: + - **待生效規範**: + - Editor 和 Admin 可下載仍在編輯中的 **Word** 原始檔。 + - **已生效/已終止/已過期規範**: + - 所有角色都可以下載由 Admin 上傳的**最終簽核版 PDF**。 + +### 3.6 檢視歷史紀錄 + +點擊規範最右側的 **歷史紀錄圖示 (🕒)**,可查看該規範的所有變更紀錄。 + +--- + +## 4. 使用者管理 (僅限 Admin) + +管理員可點擊導覽列的 **[帳號管理]** 來新增、編輯或刪除使用者帳號。 + +--- + +## 5. 常見問題 (FAQ) + +**Q: 為什麼我看不到「建立規範」或「啟用」的按鈕?** +A: 您的帳號權限不足。建立規範需要 `Editor` 或 `Admin` 權限;啟用規範僅限 `Admin`。如有需要,請聯繫系統管理員。 + +**Q: 我忘記密碼了怎麼辦?** +A: 請聯繫系統管理員,請他/她為您重設密碼。 + +**Q: 為什麼編輯器頁面顯示 "Download failed" 或無法儲存?** +A: 這通常是開發環境的網路設定問題。請確認您主機的防火牆是否允許來自 Docker 的連線,或聯繫系統管理員檢查網路設定。 + +**Q: 我可以上傳 Word 檔案來啟用規範嗎?** +A: 不行。為了確保文件的最終性和不可修改性,系統規定必須上傳已簽核的 **PDF 檔案**來啟用規範。 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..3c0c264 --- /dev/null +++ b/app.py @@ -0,0 +1,54 @@ +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(): + # 檢查使用者是否已經通過驗證 (已登入) + if current_user.is_authenticated: + # 如果已登入,直接導向到暫規總表 + return redirect(url_for('temp_spec.spec_list')) + else: + # 如果未登入,才導向到登入頁面 + 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..68dd8fc --- /dev/null +++ b/config.py @@ -0,0 +1,31 @@ +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 + ONLYOFFICE_URL = os.getenv('ONLYOFFICE_URL') + ONLYOFFICE_JWT_SECRET = os.getenv('ONLYOFFICE_JWT_SECRET') + + # LDAP Configuration + LDAP_SERVER = os.getenv('LDAP_SERVER') + LDAP_PORT = int(os.getenv('LDAP_PORT', 389)) + LDAP_USE_SSL = os.getenv('LDAP_USE_SSL', 'false').lower() in ['true', '1', 't'] + LDAP_BIND_USER_DN = os.getenv('LDAP_BIND_USER_DN') + LDAP_BIND_USER_PASSWORD = os.getenv('LDAP_BIND_USER_PASSWORD') + LDAP_SEARCH_BASE = os.getenv('LDAP_SEARCH_BASE') # e.g., 'ou=users,dc=panjit,dc=com,dc=tw' + LDAP_USER_LOGIN_ATTR = os.getenv('LDAP_USER_LOGIN_ATTR', 'userPrincipalName') # AD attribute for user login (e.g., user@panjit.com.tw) + + # SMTP Configuration + SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.panjit.com.tw') + SMTP_PORT = int(os.getenv('SMTP_PORT', 25)) + SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() in ['true', '1', 't'] + SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL') + SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD') 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/ldap_utils.py b/ldap_utils.py new file mode 100644 index 0000000..7ea83b8 --- /dev/null +++ b/ldap_utils.py @@ -0,0 +1,118 @@ +from ldap3 import Server, Connection, ALL, Tls +import ssl +from flask import current_app + +def authenticate_ldap_user(username, password): + """ + Authenticates a user against the LDAP server using their credentials. + Returns a dictionary with user info upon success, otherwise None. + """ + # Ensure username is in UPN format (e.g., user@domain.com) + # Assuming the domain part is derivable or static. + # This logic might need adjustment based on how users log in. + if '@' not in username: + # This assumes a fixed domain, which should be configured or improved + domain = current_app.config['LDAP_SEARCH_BASE'].split('dc=')[1].replace(',', '.') + user_upn = f"{username}@{domain}" + else: + user_upn = username + + ldap_server = current_app.config['LDAP_SERVER'] + ldap_port = current_app.config['LDAP_PORT'] + use_ssl = current_app.config['LDAP_USE_SSL'] + + server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl} + if use_ssl: + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + server_options['tls'] = tls_config + + server = Server(**server_options, get_info=ALL) + + try: + # Attempt to bind with the user's credentials to authenticate + conn = Connection(server, user=user_upn, password=password, auto_bind=True) + + if conn.bound: + # Authentication successful. Now, get user details. + search_base = current_app.config['LDAP_SEARCH_BASE'] + login_attr = current_app.config['LDAP_USER_LOGIN_ATTR'] + search_filter = f'({login_attr}={user_upn})' + + conn.search(search_base, search_filter, attributes=['dn', 'mail', 'displayName', 'sAMAccountName']) + + if conn.entries: + entry = conn.entries[0] + user_info = { + 'dn': entry.entry_dn, + 'email': str(entry.mail) if 'mail' in entry else None, + 'display_name': str(entry.displayName) if 'displayName' in entry else None, + 'username': str(entry.sAMAccountName) if 'sAMAccountName' in entry else username + } + conn.unbind() + return user_info + else: + # This case is unlikely if bind succeeded, but handle it just in case + conn.unbind() + return None + else: + # Authentication failed + return None + + except Exception as e: + # Log the exception in a real application + print(f"LDAP authentication error: {e}") + return None + + +def get_ldap_group_members(group_name): + """ + Retrieves a list of email addresses for members of a given LDAP group. + Uses the application's bind credentials for searching. + """ + ldap_server = current_app.config['LDAP_SERVER'] + ldap_port = current_app.config['LDAP_PORT'] + use_ssl = current_app.config['LDAP_USE_SSL'] + bind_dn = current_app.config['LDAP_BIND_USER_DN'] + bind_password = current_app.config['LDAP_BIND_USER_PASSWORD'] + search_base = current_app.config['LDAP_SEARCH_BASE'] + + server_options = {'host': ldap_server, 'port': ldap_port, 'use_ssl': use_ssl} + if use_ssl: + tls_config = Tls(validate=ssl.CERT_NONE, version=ssl.PROTOCOL_TLSv1_2) + server_options['tls'] = tls_config + + server = Server(**server_options, get_info=ALL) + + try: + # Bind with the service account + conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True) + + if conn.bound: + # First, find the group to get its member list + group_search_filter = f'(&(objectClass=group)(cn={group_name}))' + conn.search(search_base, group_search_filter, attributes=['member']) + + if not conn.entries: + print(f"LDAP group '{group_name}' not found.") + conn.unbind() + return [] + + members_dn = conn.entries[0].member.values + emails = [] + + # For each member DN, fetch their email + for member_dn in members_dn: + member_filter = f'(objectDN={member_dn})' + conn.search(member_dn, '(objectClass=*)', attributes=['mail']) + if conn.entries and 'mail' in conn.entries[0]: + emails.append(str(conn.entries[0].mail)) + + conn.unbind() + return emails + else: + print("Failed to bind to LDAP with service account.") + return [] + + except Exception as e: + print(f"LDAP group search error: {e}") + return [] diff --git a/models.py b/models.py new file mode 100644 index 0000000..2a3f47e --- /dev/null +++ b/models.py @@ -0,0 +1,59 @@ +from flask_sqlalchemy import SQLAlchemy +from flask_login import UserMixin +from datetime import datetime + +db = SQLAlchemy() + +class User(db.Model, UserMixin): + # 修改 table name + __tablename__ = 'ts_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): + # 新增並設定 table name + __tablename__ = 'ts_temp_spec' + 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): + # 新增並設定 table name + __tablename__ = 'ts_upload' + id = db.Column(db.Integer, primary_key=True) + # 注意:這裡的 ForeignKey 也要更新為新的 table name + temp_spec_id = db.Column(db.Integer, db.ForeignKey('ts_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): + # 修改 table name + __tablename__ = 'ts_spec_history' + id = db.Column(db.Integer, primary_key=True) + # 注意:這裡的 ForeignKey 也要更新為新的 table name + spec_id = db.Column(db.Integer, db.ForeignKey('ts_temp_spec.id', ondelete='CASCADE'), nullable=False) + user_id = db.Column(db.Integer, db.ForeignKey('ts_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') \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..30ce47a --- /dev/null +++ b/requirements.txt @@ -0,0 +1,14 @@ +flask +flask-login +flask-sqlalchemy +pymysql +werkzeug +docx2pdf +python-docx +docxtpl +beautifulsoup4 +lxml +python-dotenv +mistune +PyJWT +ldap3 \ No newline at end of file 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..301ad54 --- /dev/null +++ b/routes/auth.py @@ -0,0 +1,54 @@ +from flask import Blueprint, render_template, request, redirect, url_for, flash +from flask_login import login_user, logout_user, login_required, current_user +from werkzeug.security import check_password_hash +from ldap_utils import authenticate_ldap_user +from models import User, db +from datetime import datetime +from werkzeug.security import check_password_hash +from ldap_utils import authenticate_ldap_user, generate_password_hash + +auth_bp = Blueprint('auth', __name__) + +@auth_bp.route('/login', methods=['GET', 'POST']) +def login(): + if current_user.is_authenticated: + return redirect(url_for('temp_spec.spec_list')) + + if request.method == 'POST': + username = request.form['username'] + password = request.form['password'] + + # Step 1: Authenticate against LDAP + user_info = authenticate_ldap_user(username, password) + + if user_info: + # Step 2: User authenticated successfully, find or create local user + local_user = User.query.filter_by(username=user_info['username']).first() + + if not local_user: + # Create a new user in the local database + local_user = User( + username=user_info['username'], + # password_hash is no longer needed for login, can be empty or random + password_hash='ldap_authenticated', + role='viewer' # Default role for new users + ) + db.session.add(local_user) + + # Update last_login time + local_user.last_login = datetime.now() + db.session.commit() + + # Step 3: Log in the user with Flask-Login + login_user(local_user) + return redirect(url_for('temp_spec.spec_list')) + else: + 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..39fb5d8 --- /dev/null +++ b/routes/temp_spec.py @@ -0,0 +1,426 @@ +# -*- 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')) \ 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..b1b3371 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,203 @@ +/* --- 全域與背景設定 --- */ +body { + background-color: #0d1117; + background-image: linear-gradient(180deg, #161b22 0%, #0d1117 100%); + background-attachment: fixed; + color: #c9d1d9; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* --- 強制設定通用元素的文字顏色 --- */ +p, label, th, td, .form-label, .form-check-label, .card-body { + color: #c9d1d9; +} + +/* --- 導覽列 --- */ +.navbar { + background-color: rgba(13, 17, 23, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid #30363d; +} + +/* --- 標題 --- */ +h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { + color: #f0f6fc; +} + +/* --- 卡片與容器 --- */ +.card { + background-color: #161b22; + border: 1px solid #30363d; + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} +.card-header, .card-footer { + background-color: rgba(22, 27, 34, 0.7); + border-bottom: 1px solid #30363d; + color: #f0f6fc; +} + +/* --- 按鈕 --- */ +.btn-primary { + background-color: #5865f2; border-color: #5865f2; color: #ffffff; + transition: all 0.2s ease-in-out; +} +.btn-primary:hover, .btn-primary:focus { + background-color: #4752c4; border-color: #4752c4; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.5); +} +.btn-success { background-color: #2ea043; border-color: #2ea043; color: #fff; } +.btn-success:hover { background-color: #268839; border-color: #268839; color: #fff;} +.btn-danger { background-color: #da3633; border-color: #da3633; color: #fff;} +.btn-danger:hover { background-color: #b92d2b; border-color: #b92d2b; color: #fff;} +.btn-warning { background-color: #f0ad4e; border-color: #f0ad4e; color: #0d1117; } +.btn-warning:hover { background-color: #e39b37; border-color: #e39b37; color: #0d1117;} +.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #0d1117; } +.btn-info:hover { background-color: #0baccc; border-color: #0baccc; color: #0d1117;} + +/* --- 表單輸入框 --- */ +.form-control, .form-select { + background-color: #0d1117; color: #c9d1d9; + border: 1px solid #30363d; border-radius: 0.375rem; +} +.form-control:focus, .form-select:focus { + background-color: #0d1117; color: #c9d1d9; + border-color: #5865f2; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); +} +.form-control::placeholder { color: #8b949e; } +.form-control[readonly] { background-color: #161b22; opacity: 0.7; } +.input-group-text { + background-color: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; +} + +/* --- 表格 --- */ +.table { + --bs-table-color: #c9d1d9; + --bs-table-bg: #161b22; + --bs-table-border-color: #30363d; + --bs-table-striped-color: #c9d1d9; + --bs-table-striped-bg: #21262d; + --bs-table-hover-color: #f0f6fc; + --bs-table-hover-bg: #30363d; + border-color: var(--bs-table-border-color); +} +.table > thead { + color: #f0f6fc; +} + +/* --- 分頁 --- */ +.pagination { + --bs-pagination-color: #58a6ff; + --bs-pagination-bg: #0d1117; /* 最深的背景色,使其與容器分離 */ + --bs-pagination-border-color: #30363d; + --bs-pagination-hover-color: #80b6ff; + --bs-pagination-hover-bg: #21262d; /* 懸停時變亮 */ + --bs-pagination-hover-border-color: #4d555e; + --bs-pagination-focus-color: #80b6ff; + --bs-pagination-focus-bg: #21262d; + --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: #5865f2; + --bs-pagination-active-border-color: #5865f2; + --bs-pagination-disabled-color: #8b949e; + --bs-pagination-disabled-bg: #161b22; /* 禁用的背景色,使其看起來凹陷 */ + --bs-pagination-disabled-border-color: #30363d; +} + +/* --- 提示訊息 (Alerts) --- */ +.alert { border-width: 1px; border-style: solid; } +.alert-danger { background-color: rgba(218, 54, 51, 0.15); border-color: #da3633; color: #ff8986; } +.alert-success { background-color: rgba(46, 160, 67, 0.15); border-color: #2ea043; color: #7ce38f; } +.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #6be2fa; } +.alert-warning { background-color: rgba(240, 173, 78, 0.15); border-color: #f0ad4e; color: #f0ad4e; } + +/* --- 狀態標籤 (Badges) --- */ +.badge { --bs-badge-font-size: 0.8em; --bs-badge-padding-y: 0.4em; --bs-badge-padding-x: 0.7em; } +.bg-success { background-color: #2ea043 !important; } +.bg-info { background-color: #0dcaf0 !important; color: #0d1117 !important; } +.bg-warning { background-color: #f0ad4e !important; color: #0d1117 !important; } +.bg-secondary { background-color: #8b949e !important; } + +/* --- 連結 --- */ +a { color: #58a6ff; } +a:hover { color: #80b6ff; } + +/* 頁面切換的淡入效果 */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +main.container { animation: fadeIn 0.5s ease-in-out; } + +/* --- 通知 (Toast) --- */ +.toast { + background-color: #21262d; /* 使用比卡片稍亮的深色背景 */ + border: 1px solid #30363d; + color: #c9d1d9; +} + +.toast-header { + background-color: #161b22; /* 使用與卡片相同的深色背景 */ + color: #f0f6fc; /* 標題使用較亮的白色文字 */ + border-bottom: 1px solid #30363d; +} + +.toast-body { + color: #c9d1d9; /* 內文使用標準的灰色文字 */ +} + +/* 讓關閉按鈕在深色背景下可見 */ +.btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* --- 列表群組 (List Group for History Page) --- */ +.list-group-flush .list-group-item { + background-color: transparent; /* 在 card 中使用透明背景 */ + border-color: #30363d; +} + +.list-group-item { + background-color: #161b22; + border-color: #30363d; +} + +/* 確保列表內的文字顏色正確 */ +.list-group-item, +.list-group-item p, +.list-group-item small { + color: #c9d1d9; /* 標準灰色文字 */ +} + +/* 讓使用者名稱等重要文字更亮 */ +.list-group-item h5 strong { + color: #f0f6fc; +} + +/* --- 剩餘天數標籤 (Days Remaining Badge) --- */ +.days-badge { + padding: 0.3em 0.6em; + border-radius: 0.375rem; + font-weight: 500; + font-size: 0.85em; + color: #0d1117; /* 預設使用深色文字 */ +} + +.days-safe { + background-color: #2ea043; /* 綠色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-warning { + background-color: #f0ad4e; /* 黃色 */ +} + +.days-critical { + background-color: #da3633; /* 紅色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-expired { + background-color: #8b949e; /* 灰色 */ + color: #ffffff; +} \ No newline at end of file diff --git a/template_with_placeholders.docx b/template_with_placeholders.docx new file mode 100644 index 0000000000000000000000000000000000000000..7bef1e32f82a6951d1b2ac00f50a595fdbd99a0f GIT binary patch literal 27596 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

X9}X?&?Ga8uL0I+oFx22Tj}OBWcY2N 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 +

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..3db4caa --- /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_form.html b/templates/create_temp_spec_form.html new file mode 100644 index 0000000..abce4d0 --- /dev/null +++ b/templates/create_temp_spec_form.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}建立新的暫時規範{% endblock %} + +{% block content %} +
+
+

建立新的暫時規範

+
+
+
+
+
+ + +
+
+ + +
+
+ + +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ +
+
+ +
+ +
+
+
+
+
+
+
+
+
+
+ +
+
+
+
+
+ +
+ 取消 + +
+
+
+
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/extend_spec.html b/templates/extend_spec.html new file mode 100644 index 0000000..0220169 --- /dev/null +++ b/templates/extend_spec.html @@ -0,0 +1,35 @@ +{% extends "base.html" %} + +{% block title %}展延暫時規範{% endblock %} + +{% block content %} +

展延暫時規範

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

主題: {{ spec.title }}

+

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

+ +
+ + +
預設為原結束日期後一個月。
+
+ +
+ + +
請上傳展延申請的相關佐證文件 (PDF 格式)。
+
+ + + 取消 +
+
+
+{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..9d0e135 --- /dev/null +++ b/templates/login.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 %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
+
+ + +
+
+ +
+
+
+

還沒有帳號嗎?

+ 立即註冊 +
+
+
+
+{% endblock %} diff --git a/templates/onlyoffice_editor.html b/templates/onlyoffice_editor.html new file mode 100644 index 0000000..510c9ba --- /dev/null +++ b/templates/onlyoffice_editor.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

正在編輯: {{ spec.spec_code }}

+

主題: {{ spec.title }}

+
+ 返回總表 +
+ +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..b7870e7 --- /dev/null +++ b/templates/register.html @@ -0,0 +1,47 @@ +{% 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 %} \ No newline at end of file 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..10e9d92 --- /dev/null +++ b/templates/spec_list.html @@ -0,0 +1,153 @@ +{% extends "base.html" %} + +{% block title %}暫時規範總表{% endblock %} + +{% block content %} +
+

暫時規範總表

+ {% if current_user.role in ['editor', 'admin'] %} + 建立新規範 + {% endif %} +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + {% 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 in ['active', 'expired'] %} + {% set remaining_days = (spec.end_date - today).days %} + {% if remaining_days < 0 %} + {% set color_class = 'days-expired' %} + {% elif remaining_days <= 3 %} + {% set color_class = 'days-critical' %} + {% elif remaining_days <= 7 %} + {% set color_class = 'days-warning' %} + {% else %} + {% set color_class = 'days-safe' %} + {% endif %} + + {{ remaining_days if remaining_days >= 0 else '已過期' }} + + {% else %} + - + {% endif %} + + {% if spec.status == 'active' %} + 已生效 + {% elif spec.status == 'pending_approval' %} + 待生效 + {% elif spec.status == 'terminated' %} + 已終止 + {% else %} + 已過期 + {% endif %} + + {% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %} + + {% endif %} + + {% if current_user.role == 'admin' and spec.status == 'pending_approval' %} + + {% endif %} + + {% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %} + + + {% endif %} + + {% if current_user.role == 'admin' %} +
+ +
+ {% endif %} + + {% if spec.status == 'pending_approval' %} + {% if current_user.role in ['editor', 'admin'] %} + + {% endif %} + {% elif spec.uploads %} + + {% endif %} + +
+
+
+ + +
+{% endblock %} \ No newline at end of file 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..2bf9611 --- /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..0506ac5 --- /dev/null +++ b/utils.py @@ -0,0 +1,197 @@ +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) + + +import smtplib +from email.mime.text import MIMEText +from email.header import Header +from flask import current_app + +def send_email(to_addrs, subject, body): + """ + Sends an email using the SMTP settings from the config. + """ + try: + smtp_server = current_app.config['SMTP_SERVER'] + smtp_port = current_app.config['SMTP_PORT'] + use_tls = current_app.config['SMTP_USE_TLS'] + sender_email = current_app.config['SMTP_SENDER_EMAIL'] + sender_password = current_app.config['SMTP_SENDER_PASSWORD'] + + msg = MIMEText(body, 'html', 'utf-8') + msg['Subject'] = Header(subject, 'utf-8') + msg['From'] = sender_email + msg['To'] = ', '.join(to_addrs) + + server = smtplib.SMTP(smtp_server, smtp_port) + if use_tls: + server.starttls() + + if sender_password: + server.login(sender_email, sender_password) + + server.sendmail(sender_email, to_addrs, msg.as_string()) + server.quit() + print(f"Email sent to {', '.join(to_addrs)}") + return True + except Exception as e: + print(f"Failed to send email: {e}") + return False diff --git a/代辦.txt b/代辦.txt new file mode 100644 index 0000000..6101775 --- /dev/null +++ b/代辦.txt @@ -0,0 +1,45 @@ +### **交付與後續步驟** + +程式碼已準備就緒,但要使其正常運作,您需要完成以下**關鍵步驟**: + +**1. 填寫環境變數 (`.env` 檔案):** + 請根據您從 IT 部門獲取的資訊,在專案根目錄的 `.env` 檔案中新增或修改以下變數。這一步至關重要。 + + ```ini + # --- LDAP Settings --- + LDAP_SERVER=dc1.panjit.com.tw + LDAP_PORT=389 + LDAP_USE_SSL=false + # 服務帳號 (用於查詢群組) + LDAP_BIND_USER_DN="CN=ServiceAccount,OU=Services,DC=panjit,DC=com,DC=tw" + LDAP_BIND_USER_PASSWORD="service_account_password" + # 使用者和群組的搜尋基礎 + LDAP_SEARCH_BASE="OU=Users,DC=panjit,DC=com,DC=tw" + LDAP_USER_LOGIN_ATTR=userPrincipalName + + # --- SMTP Settings --- + SMTP_SERVER=mail.panjit.com.tw + SMTP_PORT=25 + SMTP_USE_TLS=false + # 發信人帳號 + SMTP_SENDER_EMAIL=app-noreply@panjit.com.tw + SMTP_SENDER_PASSWORD="email_password_if_needed" + ``` + +**2. 安裝新的 Python 套件:** + 在您的開發環境中執行以下指令,以安裝 `ldap3`: + + ```bash + pip install -r requirements.txt + ``` + +**3. 自訂郵件通知:** + * 我添加的郵件通知僅作為一個**範例**。您需要: + * 將範例中的 `'TempSpec_Approvers'` 替換為貴公司**實際的郵件群組名稱**。 + * 將此通知邏輯複製並修改,應用到其他需要發送通知的地方,例如 `terminate_spec` (結束通知) 和 `extend_spec` (展延通知)。 + * 根據需要建立一個排程任務 (例如,使用 APScheduler 或 Windows 工作排程器),定期檢查即將到期的暫規並發送提醒郵件。 + +**4. 測試:** + 完成上述配置後,請啟動應用程式並使用您的 AD 帳號進行登入測試。同時,觸發一個暫規生效的流程,檢查郵件是否能成功發送。 + +本次的架構轉移工作已完成。如果您在配置或測試過程中遇到任何問題,或需要對通知邏輯進行進一步的調整,請隨時提出。 \ No newline at end of file