commit b9557250a410cf778a51ece25ffe28543f494ffb Author: beabigegg Date: Wed Aug 27 18:03:54 2025 +0800 1st 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 0000000..7bef1e3 Binary files /dev/null and b/template_with_placeholders.docx differ diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..87e6be7 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}權限不足{% endblock %} + +{% block content %} +
+

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