1st
This commit is contained in:
40
.gitignore
vendored
Normal file
40
.gitignore
vendored
Normal file
@@ -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
|
133
README.md
Normal file
133
README.md
Normal file
@@ -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 <your-repository-url>
|
||||
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
|
||||
```
|
105
USER_MANUAL.md
Normal file
105
USER_MANUAL.md
Normal file
@@ -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 檔案**來啟用規範。
|
54
app.py
Normal file
54
app.py
Normal file
@@ -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)
|
31
config.py
Normal file
31
config.py
Normal file
@@ -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')
|
72
init_db.py
Normal file
72
init_db.py
Normal file
@@ -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("❌ 操作已取消。")
|
118
ldap_utils.py
Normal file
118
ldap_utils.py
Normal file
@@ -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 []
|
59
models.py
Normal file
59
models.py
Normal file
@@ -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')
|
14
requirements.txt
Normal file
14
requirements.txt
Normal file
@@ -0,0 +1,14 @@
|
||||
flask
|
||||
flask-login
|
||||
flask-sqlalchemy
|
||||
pymysql
|
||||
werkzeug
|
||||
docx2pdf
|
||||
python-docx
|
||||
docxtpl
|
||||
beautifulsoup4
|
||||
lxml
|
||||
python-dotenv
|
||||
mistune
|
||||
PyJWT
|
||||
ldap3
|
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
76
routes/admin.py
Normal file
76
routes/admin.py
Normal file
@@ -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/<int:user_id>', 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/<int:user_id>', 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'))
|
54
routes/auth.py
Normal file
54
routes/auth.py
Normal file
@@ -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'))
|
426
routes/temp_spec.py
Normal file
426
routes/temp_spec.py
Normal file
@@ -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/<int:spec_id>')
|
||||
@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/<int:spec_id>', 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/<int:spec_id>', 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"""
|
||||
<html>
|
||||
<body>
|
||||
<p>您好,</p>
|
||||
<p>暫時規範 <b>{spec.spec_code} - {spec.title}</b> 已由管理員啟用,並正式生效。</p>
|
||||
<p>詳細資訊請登入系統查看。</p>
|
||||
<p>生效日期: {spec.start_date.strftime('%Y-%m-%d')}<br>
|
||||
結束日期: {spec.end_date.strftime('%Y-%m-%d')}</p>
|
||||
<p>此為系統自動發送的通知郵件,請勿直接回覆。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
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/<int:spec_id>', 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/<int:spec_id>')
|
||||
@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/<int:spec_id>')
|
||||
@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/<int:spec_id>', 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/<int:spec_id>')
|
||||
@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/<int:spec_id>', 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'))
|
29
routes/upload.py
Normal file
29
routes/upload.py
Normal file
@@ -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})
|
203
static/css/style.css
Normal file
203
static/css/style.css
Normal file
@@ -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;
|
||||
}
|
BIN
template_with_placeholders.docx
Normal file
BIN
template_with_placeholders.docx
Normal file
Binary file not shown.
12
templates/403.html
Normal file
12
templates/403.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}權限不足{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<h1 class="display-1">403</h1>
|
||||
<h2 class="mb-4">權限不足 (Forbidden)</h2>
|
||||
<p class="lead">抱歉,您沒有權限存取此頁面。</p>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-primary mt-3">返回總表</a>
|
||||
</div>
|
||||
{% endblock %}
|
12
templates/404.html
Normal file
12
templates/404.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}找不到頁面{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2 class="mb-4">找不到頁面 (Not Found)</h2>
|
||||
<p class="lead">抱歉,您要找的頁面不存在。</p>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-primary mt-3">返回總表</a>
|
||||
</div>
|
||||
{% endblock %}
|
27
templates/activate_spec.html
Normal file
27
templates/activate_spec.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}啟用暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">上傳簽核檔案以啟用規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<div class="alert alert-info">
|
||||
請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="signed_file" class="form-label"><strong>已簽核的 PDF 檔案</strong></label>
|
||||
<input class="form-control" type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-success">上傳並啟用</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
94
templates/base.html
Normal file
94
templates/base.html
Normal file
@@ -0,0 +1,94 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}暫時規範系統{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Toast UI Editor Core CSS -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
|
||||
<!-- Plugins CSS -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-table-merged-cell/latest/toastui-editor-plugin-table-merged-cell.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-image-editor/latest/tui-image-editor.min.css">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('temp_spec.spec_list') }}">暫時規範系統</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('temp_spec.create_temp_spec') }}">暫時規範建立</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('temp_spec.spec_list') }}">總表檢視</a>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.user_list') }}">帳號管理</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">登出</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">登入</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
<strong class="me-auto">通知</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Toast UI Editor Dependencies & Core -->
|
||||
<script src="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
<!-- Plugins JS -->
|
||||
<script src="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor-plugin-table-merged-cell/latest/toastui-editor-plugin-table-merged-cell.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 啟用所有 Toast
|
||||
const toastElList = document.querySelectorAll('.toast');
|
||||
const toastList = [...toastElList].map(toastEl => new bootstrap.Toast(toastEl).show());
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
83
templates/create_temp_spec_form.html
Normal file
83
templates/create_temp_spec_form.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}建立新的暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10">
|
||||
<h2 class="mb-4">建立新的暫時規範</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="spec-form" method="post">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="theme" class="form-label">主題/目的</label>
|
||||
<input type="text" class="form-control" id="theme" name="theme" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="applicant" class="form-label">申請者</label>
|
||||
<input type="text" class="form-control" id="applicant" name="applicant" value="{{ current_user.username }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="applicant_phone" class="form-label">電話(分機)</label>
|
||||
<input type="text" class="form-control" id="applicant_phone" name="applicant_phone">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">站別 (可多選)</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="probing"><label>點測</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="dicing"><label>切割</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="diebond"><label>晶粒黏著</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="wirebond"><label>銲線黏著</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="solder"><label>錫膏焊接</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="molding"><label>成型</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="degate"><label>去膠</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="deflash"><label>吹砂</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="plating"><label>電鍍</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="trimform"><label>切彎腳</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="marking"><label>印字</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="tmtt"><label>測試</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="other"><label>其他</label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">TCCS Level</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" name="tccs_level">
|
||||
<option selected value="">請選擇 Level...</option>
|
||||
<option value="l1">Level 1</option>
|
||||
<option value="l2">Level 2</option>
|
||||
<option value="l3">Level 3</option>
|
||||
<option value="l4">Level 4</option>
|
||||
</select>
|
||||
<div class="input-group-text">
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="man"><label>人</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="machine"><label>機</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="material"><label>料</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="method"><label>法</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="env"><label>環</label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3"><label for="package" class="form-label">Package</label><input type="text" class="form-control" id="package" name="package"></div>
|
||||
<div class="col-md-4 mb-3"><label for="lot_number" class="form-label">工單批號</label><input type="text" class="form-control" id="lot_number" name="lot_number"></div>
|
||||
<div class="col-md-4 mb-3"><label for="equipment_type" class="form-label">設備型(編)號</label><input type="text" class="form-control" id="equipment_type" name="equipment_type"></div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary me-2">取消</a>
|
||||
<button type="submit" class="btn btn-primary">建立並開始編輯</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
35
templates/extend_spec.html
Normal file
35
templates/extend_spec.html
Normal file
@@ -0,0 +1,35 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}展延暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">展延暫時規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<p><strong>原結束日期:</strong> {{ spec.end_date.strftime('%Y-%m-%d') }}</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_end_date" class="form-label"><strong>新的結束日期</strong></label>
|
||||
<input type="date" class="form-control" id="new_end_date" name="new_end_date"
|
||||
value="{{ default_new_end_date.strftime('%Y-%m-%d') }}" required>
|
||||
<div class="form-text">預設為原結束日期後一個月。</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_file" class="form-label"><strong>重新上傳佐證檔案 (必填)</strong></label>
|
||||
<input class="form-control" type="file" id="new_file" name="new_file" accept=".pdf" required>
|
||||
<div class="form-text">請上傳展延申請的相關佐證文件 (PDF 格式)。</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">確認展延</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
43
templates/login.html
Normal file
43
templates/login.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}登入 - 暫時規範系統{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<h2 class="text-center mb-4">登入</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category or 'danger' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">使用者帳號</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-primary">登入</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<p class="mb-0">還沒有帳號嗎?</p>
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-outline-success">立即註冊</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
39
templates/onlyoffice_editor.html
Normal file
39
templates/onlyoffice_editor.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="mb-0">正在編輯: {{ spec.spec_code }}</h2>
|
||||
<p class="lead text-muted">主題: {{ spec.title }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary"><i class="bi bi-arrow-left-circle me-2"></i>返回總表</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0" style="height: 85vh;">
|
||||
<div id="placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript" src="{{ onlyoffice_url }}web-apps/apps/api/documents/api.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 從後端接收的設定
|
||||
const config = {{ config|tojson|safe }};
|
||||
|
||||
// 建立 DocEditor 物件
|
||||
const docEditor = new DocsAPI.DocEditor("placeholder", config);
|
||||
|
||||
// 您可以在這裡加入更多事件處理,例如:
|
||||
// config.events = {
|
||||
// 'onAppReady': function() { console.log('Editor is ready'); },
|
||||
// 'onDocumentStateChange': function(event) { console.log('Document state changed'); },
|
||||
// };
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
47
templates/register.html
Normal file
47
templates/register.html
Normal file
@@ -0,0 +1,47 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}註冊新帳號 - 暫時規範系統{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<h2 class="text-center mb-4">建立新帳號</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category or 'danger' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">使用者帳號</label>
|
||||
<input type="text" class="form-control" id="username" name="username" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">確認密碼</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
<div class="d-grid">
|
||||
<button type="submit" class="btn btn-success">註冊</button>
|
||||
</div>
|
||||
</form>
|
||||
<div class="text-center mt-3">
|
||||
<a href="{{ url_for('auth.login') }}">已經有帳號了?返回登入</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
34
templates/spec_history.html
Normal file
34
templates/spec_history.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-0">操作歷史紀錄</h2>
|
||||
<p class="lead text-muted">規範編號: {{ spec.spec_code }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary"><i class="bi bi-arrow-left-circle me-2"></i>返回總表</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for entry in history %}
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<span class="badge bg-primary rounded-pill me-2">{{ entry.action }}</span>
|
||||
由 <strong>{{ entry.user.username if entry.user else '[已刪除的使用者]' }}</strong> 執行
|
||||
</h5>
|
||||
<small>{{ entry.timestamp.strftime('%Y-%m-%d %H:%M:%S') }}</small>
|
||||
</div>
|
||||
<p class="mb-1 mt-2">{{ entry.details }}</p>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">沒有任何歷史紀錄。</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
153
templates/spec_list.html
Normal file
153
templates/spec_list.html
Normal file
@@ -0,0 +1,153 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}暫時規範總表{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">暫時規範總表</h2>
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.create_temp_spec') }}" class="btn btn-primary"><i class="bi bi-plus-circle-fill me-2"></i>建立新規範</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('temp_spec.spec_list') }}" class="row g-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="query" class="form-control" placeholder="搜尋編號或主題..." value="{{ query or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select name="status" class="form-select">
|
||||
<option value="">所有狀態</option>
|
||||
<option value="pending_approval" {% if status == 'pending_approval' %}selected{% endif %}>待生效</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>已生效</option>
|
||||
<option value="terminated" {% if status == 'terminated' %}selected{% endif %}>已終止</option>
|
||||
<option value="expired" {% if status == 'expired' %}selected{% endif %}>已過期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">篩選</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>編號</th>
|
||||
<th>主題</th>
|
||||
<th>申請者</th>
|
||||
<th>建立日期</th>
|
||||
<th>結束日期</th>
|
||||
<th class="text-center">剩餘天數</th>
|
||||
<th>狀態</th>
|
||||
<th class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for spec in specs %}
|
||||
<tr>
|
||||
<td>{{ spec.spec_code }}</td>
|
||||
<td>{{ spec.title }}</td>
|
||||
<td>{{ spec.applicant }}</td>
|
||||
<td>{{ spec.created_at.strftime('%Y-%m-%d') }}</td>
|
||||
<td>{{ spec.end_date.strftime('%Y-%m-%d') }}</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% 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 %}
|
||||
<span class="days-badge {{ color_class }}">
|
||||
{{ remaining_days if remaining_days >= 0 else '已過期' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span>-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td>
|
||||
{% if spec.status == 'active' %}
|
||||
<span class="badge fs-6 bg-success bg-opacity-75"><i class="bi bi-check-circle-fill me-1"></i>已生效</span>
|
||||
{% elif spec.status == 'pending_approval' %}
|
||||
<span class="badge fs-6 bg-info bg-opacity-75 text-dark"><i class="bi bi-hourglass-split me-1"></i>待生效</span>
|
||||
{% elif spec.status == 'terminated' %}
|
||||
<span class="badge fs-6 bg-warning bg-opacity-75 text-dark"><i class="bi bi-slash-circle-fill me-1"></i>已終止</span>
|
||||
{% else %}
|
||||
<span class="badge fs-6 bg-secondary bg-opacity-75"><i class="bi bi-calendar-x-fill me-1"></i>已過期</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.edit_spec', spec_id=spec.id) }}" class="btn btn-sm btn-warning" title="編輯"><i class="bi bi-pencil-fill"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' and spec.status == 'pending_approval' %}
|
||||
<a href="{{ url_for('temp_spec.activate_spec', spec_id=spec.id) }}" class="btn btn-sm btn-primary" title="啟用"><i class="bi bi-check2-circle"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %}
|
||||
<a href="{{ url_for('temp_spec.extend_spec', spec_id=spec.id) }}" class="btn btn-sm btn-secondary" title="展延"><i class="bi bi-calendar-plus"></i></a>
|
||||
<a href="{{ url_for('temp_spec.terminate_spec', spec_id=spec.id) }}" class="btn btn-sm btn-danger" title="終止"><i class="bi bi-x-circle"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<form action="{{ url_for('temp_spec.delete_spec', spec_id=spec.id) }}" method="post" class="d-inline" onsubmit="return confirm('您確定要永久刪除這份規範及其所有相關檔案嗎?此操作無法復原。');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="永久刪除"><i class="bi bi-trash-fill"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if spec.status == 'pending_approval' %}
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.download_initial_word', spec_id=spec.id) }}" class="btn btn-sm btn-primary" title="下載 Word"><i class="bi bi-file-earmark-word-fill"></i></a>
|
||||
{% endif %}
|
||||
{% elif spec.uploads %}
|
||||
<a href="{{ url_for('temp_spec.download_signed_pdf', spec_id=spec.id) }}" class="btn btn-sm btn-success" title="下載已簽核 PDF"><i class="bi bi-file-earmark-check-fill"></i></a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('temp_spec.spec_history', spec_id=spec.id) }}" class="btn btn-sm btn-outline-secondary" title="檢視歷史紀錄"><i class="bi bi-clock-history"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=pagination.prev_num, query=query, status=status) }}">上一頁</a>
|
||||
</li>
|
||||
{% for page_num in pagination.iter_pages() %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=page_num, query=query, status=status) }}">{{ page_num }}</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=pagination.next_num, query=query, status=status) }}">下一頁</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
27
templates/terminate_spec.html
Normal file
27
templates/terminate_spec.html
Normal file
@@ -0,0 +1,27 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}提早結束暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">提早結束暫時規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<div class="alert alert-warning">
|
||||
執行此操作將會立即終止這份暫時規範,狀態將變為「已終止」,結束日期會更新為今天。
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="reason" class="form-label"><strong>提早結束原因</strong></label>
|
||||
<textarea class="form-control" id="reason" name="reason" rows="4" required></textarea>
|
||||
</div>
|
||||
<button type="submit" class="btn btn-danger">確認終止</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
91
templates/user_management.html
Normal file
91
templates/user_management.html
Normal file
@@ -0,0 +1,91 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}帳號管理{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">帳號管理</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<!-- 新增使用者表單 -->
|
||||
<div class="card mb-4">
|
||||
<div class="card-header">
|
||||
新增使用者
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('admin.create_user') }}" method="post" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<input type="text" name="username" class="form-control" placeholder="使用者名稱" required>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<input type="password" name="password" class="form-control" placeholder="密碼" required>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<select name="role" class="form-select" required>
|
||||
<option value="viewer">Viewer</option>
|
||||
<option value="editor">Editor</option>
|
||||
<option value="admin">Admin</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">建立</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 使用者列表 -->
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
現有使用者列表
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>使用者名稱</th>
|
||||
<th>權限</th>
|
||||
<th>上次登入</th>
|
||||
<th colspan="2">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>{{ user.username }}</td>
|
||||
<form action="{{ url_for('admin.edit_user', user_id=user.id) }}" method="post" class="d-inline">
|
||||
<td>
|
||||
<select name="role" class="form-select form-select-sm">
|
||||
<option value="viewer" {% if user.role == 'viewer' %}selected{% endif %}>Viewer</option>
|
||||
<option value="editor" {% if user.role == 'editor' %}selected{% endif %}>Editor</option>
|
||||
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>Admin</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '從未' }}</td>
|
||||
<td>
|
||||
<button type="submit" class="btn btn-sm btn-success">更新</button>
|
||||
</td>
|
||||
</form>
|
||||
<td>
|
||||
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="post" onsubmit="return confirm('確定要刪除這位使用者嗎?');" class="d-inline">
|
||||
<button type="submit" class="btn btn-sm btn-danger" {% if user.id == current_user.id %}disabled{% endif %}>刪除</button>
|
||||
</form>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
197
utils.py
Normal file
197
utils.py
Normal file
@@ -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
|
45
代辦.txt
Normal file
45
代辦.txt
Normal file
@@ -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 帳號進行登入測試。同時,觸發一個暫規生效的流程,檢查郵件是否能成功發送。
|
||||
|
||||
本次的架構轉移工作已完成。如果您在配置或測試過程中遇到任何問題,或需要對通知邏輯進行進一步的調整,請隨時提出。
|
Reference in New Issue
Block a user