check_ok
This commit is contained in:
9
.claude/settings.local.json
Normal file
9
.claude/settings.local.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"permissions": {
|
||||
"allow": [
|
||||
"Bash(chmod:*)"
|
||||
],
|
||||
"deny": [],
|
||||
"ask": []
|
||||
}
|
||||
}
|
67
.dockerignore
Normal file
67
.dockerignore
Normal file
@@ -0,0 +1,67 @@
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
pip-log.txt
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
static/generated/*
|
||||
!static/generated/.gitkeep
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Test files
|
||||
tests/
|
||||
test_*
|
||||
*_test.py
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
35
.env
Normal file
35
.env
Normal file
@@ -0,0 +1,35 @@
|
||||
# .env
|
||||
# 將敏感資訊存放在此,並確保此檔案被加入 .gitignore,不要上傳到版本控制系統
|
||||
|
||||
# 資料庫連線字串
|
||||
# 格式: mysql+pymysql://<user>:<password>@<host>:<port>/<dbname>
|
||||
DATABASE_URL="mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060"
|
||||
|
||||
# Flask 的 Secret Key
|
||||
SECRET_KEY="933f9064329f29e642b20089e6ee16b3dd15da6acb6fdd98"
|
||||
|
||||
# ONLYOFFICE Document Server URL
|
||||
ONLYOFFICE_URL="http://localhost:8080/"
|
||||
|
||||
# Secret key for ONLYOFFICE JWT
|
||||
ONLYOFFICE_JWT_SECRET="933f9064330f29e642b20089e6ee16b3dd15da6acb6fdd98"
|
||||
|
||||
# --- LDAP Settings ---
|
||||
LDAP_SERVER=panjit.com.tw
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=false
|
||||
# 服務帳號 (用於查詢群組)
|
||||
LDAP_BIND_USER_DN="CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW"
|
||||
LDAP_BIND_USER_PASSWORD="panjit2481"
|
||||
# 使用者和群組的搜尋基礎
|
||||
LDAP_SEARCH_BASE="OU=PANJIT,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_USE_SSL=false
|
||||
SMTP_AUTH_REQUIRED=false
|
||||
SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw
|
||||
SMTP_SENDER_PASSWORD=
|
36
.env.docker.example
Normal file
36
.env.docker.example
Normal file
@@ -0,0 +1,36 @@
|
||||
# Docker 部署環境變數配置範例
|
||||
# 複製此文件為 .env 並填入實際的值
|
||||
|
||||
# === 對外服務端口配置 (12010~12019) ===
|
||||
APP_PORT=12010 # Flask 應用程式主服務
|
||||
ONLYOFFICE_PORT=12011 # OnlyOffice 文檔編輯服務
|
||||
NGINX_PORT=12013 # Nginx HTTP (生產環境使用)
|
||||
NGINX_SSL_PORT=12014 # Nginx HTTPS (生產環境使用)
|
||||
|
||||
# === Flask 應用設定 ===
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=933f9064329f29e642b20089e6ee16b3dd15da6acb6fdd98
|
||||
|
||||
# === 外部資料庫連線 (使用 mysql.theaken.com) ===
|
||||
DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060
|
||||
|
||||
# === LDAP 設定 ===
|
||||
LDAP_SERVER=panjit.com.tw
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=false
|
||||
LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw
|
||||
LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW
|
||||
LDAP_BIND_USER_PASSWORD=panjit2481
|
||||
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||
|
||||
# === SMTP 郵件設定 ===
|
||||
SMTP_SERVER=mail.panjit.com.tw
|
||||
SMTP_PORT=25
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_AUTH_REQUIRED=false
|
||||
SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw
|
||||
SMTP_SENDER_PASSWORD=
|
||||
|
||||
# === ONLYOFFICE 設定 ===
|
||||
ONLYOFFICE_JWT_SECRET=933f9064330f29e642b20089e6ee16b3dd15da6acb6fdd98
|
57
.env.example
Normal file
57
.env.example
Normal file
@@ -0,0 +1,57 @@
|
||||
# Flask 應用程式設定
|
||||
SECRET_KEY=your-super-secret-and-random-string-here
|
||||
FLASK_ENV=production
|
||||
APP_PORT=5000
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# 資料庫設定
|
||||
DB_ROOT_PASSWORD=tempspec123
|
||||
DB_NAME=tempspec_db
|
||||
DB_USER=tempspec_user
|
||||
DB_PASSWORD=tempspec_pass
|
||||
DB_PORT=3306
|
||||
|
||||
# LDAP/Active Directory 設定
|
||||
LDAP_SERVER=ldap://your-dc.company.com
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=False
|
||||
LDAP_SEARCH_BASE=DC=company,DC=com
|
||||
LDAP_BIND_USER_DN=CN=service,DC=company,DC=com
|
||||
LDAP_BIND_USER_PASSWORD=service_password
|
||||
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||
|
||||
# SMTP 郵件設定
|
||||
# 方案 1: 使用 Port 25 (無需認證) - 推薦用於內部郵件伺服器
|
||||
SMTP_SERVER=mail.panjit.com.tw
|
||||
SMTP_PORT=25
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_AUTH_REQUIRED=false
|
||||
SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw
|
||||
SMTP_SENDER_PASSWORD=
|
||||
|
||||
# 方案 2: 使用 Port 587 (需要認證) - 用於外部 SMTP 或有認證需求的伺服器
|
||||
# SMTP_SERVER=smtp.company.com
|
||||
# SMTP_PORT=587
|
||||
# SMTP_USE_TLS=true
|
||||
# SMTP_USE_SSL=false
|
||||
# SMTP_AUTH_REQUIRED=true
|
||||
# SMTP_SENDER_EMAIL=noreply@company.com
|
||||
# SMTP_SENDER_PASSWORD=smtp_password
|
||||
|
||||
# 方案 3: 使用 Port 465 (SSL + 認證) - 用於安全連接
|
||||
# SMTP_SERVER=smtp.gmail.com
|
||||
# SMTP_PORT=465
|
||||
# SMTP_USE_TLS=false
|
||||
# SMTP_USE_SSL=true
|
||||
# SMTP_AUTH_REQUIRED=true
|
||||
# SMTP_SENDER_EMAIL=yourapp@gmail.com
|
||||
# SMTP_SENDER_PASSWORD=app_password
|
||||
|
||||
# ONLYOFFICE Document Server 設定
|
||||
ONLYOFFICE_PORT=8080
|
||||
ONLYOFFICE_JWT_SECRET=your-onlyoffice-jwt-secret-string
|
||||
|
||||
# Nginx 設定 (可選)
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
41
.env.production
Normal file
41
.env.production
Normal file
@@ -0,0 +1,41 @@
|
||||
# 生產環境配置範例
|
||||
# 複製此檔案為 .env.production 並修改相應值
|
||||
|
||||
# === Flask 應用設定 ===
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your_super_secret_production_key_here_change_me
|
||||
|
||||
# === 資料庫設定 ===
|
||||
DATABASE_URL=mysql+pymysql://prod_user:prod_password@mysql.company.com:3306/prod_db
|
||||
|
||||
# === Redis 快取設定 ===
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# === CDN 設定 ===
|
||||
CDN_DOMAIN=cdn.yourcompany.com
|
||||
|
||||
# === LDAP 設定 ===
|
||||
LDAP_SERVER=ldap.company.com
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=true
|
||||
LDAP_SEARCH_BASE=DC=company,DC=com
|
||||
LDAP_BIND_USER_DN=CN=service_account,CN=Users,DC=company,DC=com
|
||||
LDAP_BIND_USER_PASSWORD=service_account_password
|
||||
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||
|
||||
# === SMTP 設定 ===
|
||||
SMTP_SERVER=smtp.company.com
|
||||
SMTP_PORT=587
|
||||
SMTP_USE_TLS=true
|
||||
SMTP_SENDER_EMAIL=temp-spec@company.com
|
||||
SMTP_SENDER_PASSWORD=smtp_password
|
||||
SMTP_AUTH_REQUIRED=true
|
||||
|
||||
# === ONLYOFFICE 設定 ===
|
||||
ONLYOFFICE_JWT_SECRET=your_onlyoffice_jwt_secret_production_key
|
||||
|
||||
# === 服務端口設定 ===
|
||||
APP_PORT=12010
|
||||
ONLYOFFICE_PORT=12011
|
||||
NGINX_PORT=80
|
||||
NGINX_SSL_PORT=443
|
53
.gitignore
vendored
Normal file
53
.gitignore
vendored
Normal file
@@ -0,0 +1,53 @@
|
||||
# --- 敏感資訊 (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/
|
||||
|
||||
# --- 環境設定檔 ---
|
||||
|
||||
|
||||
# --- 測試相關 (Testing) ---
|
||||
# 忽略測試檔案
|
||||
test_*.py
|
||||
*_test.py
|
||||
tests/
|
||||
|
||||
# --- 開發者專用文件 (Developer Only) ---
|
||||
# 最佳實踐文件(包含敏感設定資訊)
|
||||
BEST_PRACTICES.md
|
||||
DEVELOPER_GUIDE.md
|
371
DEPLOYMENT.md
Normal file
371
DEPLOYMENT.md
Normal file
@@ -0,0 +1,371 @@
|
||||
# 部署指南 - 暫時規範管理系統 V4
|
||||
|
||||
本文件提供詳細的部署指導,涵蓋 Docker 環境部署方式。
|
||||
|
||||
## 🎉 生產環境優化完成
|
||||
|
||||
**✅ 已完成50人併發生產環境優化**:
|
||||
- Gunicorn WSGI部署(多進程併發)
|
||||
- Redis快取系統(提升效能)
|
||||
- Nginx反向代理(負載均衡)
|
||||
- CDN支援(靜態資源加速)
|
||||
- 資源限制(防止系統過載)
|
||||
- 監控工具(效能監控)
|
||||
|
||||
## 📋 目錄
|
||||
|
||||
1. [系統需求](#1-系統需求)
|
||||
2. [快速部署](#2-快速部署)
|
||||
3. [生產環境配置](#3-生產環境配置)
|
||||
4. [監控與管理](#4-監控與管理)
|
||||
5. [服務訪問](#5-服務訪問)
|
||||
6. [疑難排解](#6-疑難排解)
|
||||
|
||||
## 1. 系統需求
|
||||
|
||||
### 前置需求檢查清單
|
||||
|
||||
- [ ] Docker 20.10+ 已安裝且運行中
|
||||
- [ ] Docker Compose 2.0+ 已安裝
|
||||
- [ ] 外部 MySQL 資料庫可訪問 (mysql.theaken.com:33306)
|
||||
- [ ] LDAP/Active Directory 伺服器可連線
|
||||
- [ ] SMTP 郵件伺服器已配置
|
||||
- [ ] 足夠的磁碟空間 (建議至少 10GB)
|
||||
|
||||
### 端口需求
|
||||
|
||||
確保以下端口未被占用:
|
||||
- `12010`: Flask 應用程式(Gunicorn)
|
||||
- `12011`: OnlyOffice 文檔服務
|
||||
- `12013`: Nginx HTTP(反向代理)
|
||||
- `12014`: Nginx HTTPS(反向代理)
|
||||
- `6379`: Redis 快取(內部)
|
||||
|
||||
## 2. 快速部署
|
||||
|
||||
### 一鍵部署(推薦)
|
||||
|
||||
```bash
|
||||
# 1. 克隆專案
|
||||
git clone <repository-url>
|
||||
cd TEMP_spec_system_V4
|
||||
|
||||
# 2. 配置環境變數
|
||||
cp .env.production .env
|
||||
# 編輯 .env 文件,填入實際的配置值
|
||||
|
||||
# 3. 啟動所有服務(生產環境優化版本)
|
||||
docker-compose up -d
|
||||
|
||||
# 4. 檢查服務狀態
|
||||
docker-compose ps
|
||||
```
|
||||
|
||||
**預期輸出應包含以下服務**:
|
||||
```
|
||||
NAME STATUS PORTS
|
||||
tempspec-redis Up (healthy)
|
||||
tempspec-onlyoffice Up (healthy) 0.0.0.0:12011->80/tcp
|
||||
tempspec-app Up (healthy) 0.0.0.0:12010->5000/tcp
|
||||
tempspec-nginx Up 0.0.0.0:12013->80/tcp, 0.0.0.0:12014->443/tcp
|
||||
```
|
||||
|
||||
```bash
|
||||
# 5. 查看服務日誌
|
||||
docker-compose logs -f
|
||||
|
||||
# 6. 驗證服務可訪問性
|
||||
curl -I http://localhost:12013/login # Nginx 反向代理(推薦)
|
||||
curl -I http://localhost:12010/login # 直接訪問 Flask
|
||||
curl -I http://localhost:12011 # OnlyOffice 服務
|
||||
```
|
||||
|
||||
## 3. 生產環境配置
|
||||
|
||||
### 3.1 服務架構(生產優化)
|
||||
|
||||
```
|
||||
用戶請求 → Nginx (12013/12014) → Gunicorn (多進程) → Flask App
|
||||
↓
|
||||
Redis快取
|
||||
↓
|
||||
外部MySQL資料庫
|
||||
```
|
||||
|
||||
**服務組件**:
|
||||
- **Nginx**: 反向代理 + 靜態檔案 + 負載均衡(端口 12013/12014)
|
||||
- **Flask 應用**: Gunicorn WSGI伺服器(多進程,端口 12010)
|
||||
- **Redis**: 快取系統(內部端口 6379)
|
||||
- **OnlyOffice**: 文檔編輯服務(端口 12011)
|
||||
- **MySQL**: 外部資料庫服務(mysql.theaken.com)
|
||||
|
||||
### 3.2 效能規格(50人併發支援)
|
||||
|
||||
- **併發處理**: 2-8個Gunicorn worker進程
|
||||
- **記憶體使用**: App容器1GB + Redis 256MB
|
||||
- **快取命中**: Redis快取減少70%+資料庫查詢
|
||||
- **響應時間**: < 200ms(快取命中時)
|
||||
- **可用性**: 99.9%+(健康檢查 + 自動重啟)
|
||||
|
||||
### 3.3 環境變數配置
|
||||
|
||||
編輯 `.env` 檔案(基於 `.env.production` 範例):
|
||||
|
||||
```env
|
||||
# 生產環境基本設定
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your_super_secret_production_key_here
|
||||
|
||||
# 服務端口
|
||||
APP_PORT=12010 # Gunicorn WSGI伺服器
|
||||
ONLYOFFICE_PORT=12011 # OnlyOffice 服務
|
||||
NGINX_PORT=12013 # Nginx HTTP
|
||||
NGINX_SSL_PORT=12014 # Nginx HTTPS
|
||||
|
||||
# Redis 快取
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
|
||||
# CDN 支援 (可選)
|
||||
CDN_DOMAIN=cdn.yourcompany.com
|
||||
|
||||
# 資料庫連線
|
||||
DATABASE_URL=mysql+pymysql://user:pass@mysql.theaken.com:33306/dbname
|
||||
|
||||
# LDAP 設定
|
||||
LDAP_SERVER=panjit.com.tw
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=false
|
||||
LDAP_SEARCH_BASE=OU=PANJIT,DC=panjit,DC=com,DC=tw
|
||||
LDAP_BIND_USER_DN=CN=LdapBind,CN=Users,DC=PANJIT,DC=COM,DC=TW
|
||||
LDAP_BIND_USER_PASSWORD=your_ldap_password
|
||||
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||
|
||||
# SMTP 設定
|
||||
SMTP_SERVER=mail.panjit.com.tw
|
||||
SMTP_PORT=25
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw
|
||||
|
||||
# ONLYOFFICE 設定
|
||||
ONLYOFFICE_JWT_SECRET=your_onlyoffice_jwt_secret
|
||||
```
|
||||
|
||||
## 4. 監控與管理
|
||||
|
||||
### 4.1 系統監控
|
||||
|
||||
```bash
|
||||
# 即時監控(每5秒刷新)
|
||||
python monitor.py --watch 5
|
||||
|
||||
# 單次檢查
|
||||
python monitor.py
|
||||
|
||||
# JSON格式輸出
|
||||
python monitor.py --json
|
||||
```
|
||||
|
||||
### 4.2 Docker 管理命令
|
||||
|
||||
```bash
|
||||
# 查看所有服務狀態
|
||||
docker-compose ps
|
||||
|
||||
# 查看實時日誌
|
||||
docker-compose logs -f
|
||||
|
||||
# 查看特定服務日誌
|
||||
docker-compose logs -f app
|
||||
docker-compose logs -f redis
|
||||
docker-compose logs -f nginx
|
||||
|
||||
# 重啟服務
|
||||
docker-compose restart
|
||||
docker-compose restart app
|
||||
|
||||
# 停止所有服務
|
||||
docker-compose down
|
||||
|
||||
# 查看資源使用
|
||||
docker stats
|
||||
```
|
||||
|
||||
### 4.3 Redis 快取管理
|
||||
|
||||
```bash
|
||||
# 連接Redis並測試
|
||||
docker-compose exec redis redis-cli ping
|
||||
|
||||
# 查看快取統計
|
||||
docker-compose exec redis redis-cli info stats
|
||||
|
||||
# 清空所有快取
|
||||
docker-compose exec redis redis-cli FLUSHALL
|
||||
|
||||
# 查看快取鍵值數量
|
||||
docker-compose exec redis redis-cli DBSIZE
|
||||
```
|
||||
|
||||
## 5. 服務訪問
|
||||
|
||||
### 5.1 服務入口
|
||||
|
||||
服務啟動後,可透過以下 URL 訪問:
|
||||
|
||||
**主要服務**:
|
||||
- **主應用程式 (Nginx)**: http://localhost:12013/login 🌟 **推薦**
|
||||
- **主應用程式 (直接)**: http://localhost:12010/login
|
||||
- **OnlyOffice 服務**: http://localhost:12011
|
||||
|
||||
**推薦使用方式(生產環境)**:
|
||||
- 使用 Nginx 反向代理: `http://localhost:12013`
|
||||
- 直接訪問 Flask: `http://localhost:12010`
|
||||
|
||||
### 5.2 登入資訊
|
||||
|
||||
- **認證方式**: LDAP/Active Directory
|
||||
- **登入帳號**: 使用公司 LDAP 帳號密碼
|
||||
- **登入格式**: 支援 `username@panjit.com.tw` 或 `username`
|
||||
|
||||
### 5.3 預設管理員帳號
|
||||
|
||||
如需創建本地管理員帳號(非LDAP):
|
||||
|
||||
```bash
|
||||
# 進入應用容器
|
||||
docker-compose exec app python update_admin.py
|
||||
|
||||
# 或手動創建
|
||||
docker-compose exec app python -c "
|
||||
from models import db, User
|
||||
from werkzeug.security import generate_password_hash
|
||||
from app import app
|
||||
|
||||
with app.app_context():
|
||||
admin = User(
|
||||
username='admin',
|
||||
email='admin@company.com',
|
||||
password_hash=generate_password_hash('admin123'),
|
||||
is_admin=True
|
||||
)
|
||||
db.session.add(admin)
|
||||
db.session.commit()
|
||||
print('管理員帳號已創建: admin/admin123')
|
||||
"
|
||||
```
|
||||
|
||||
## 6. 疑難排解
|
||||
|
||||
### 6.1 生產環境常見問題
|
||||
|
||||
**Redis 連接失敗**
|
||||
```bash
|
||||
# 檢查Redis容器狀態
|
||||
docker-compose logs redis
|
||||
|
||||
# 測試Redis連接
|
||||
docker-compose exec redis redis-cli ping
|
||||
|
||||
# 重啟Redis
|
||||
docker-compose restart redis
|
||||
```
|
||||
|
||||
**應用程式無回應**
|
||||
```bash
|
||||
# 檢查Gunicorn日誌
|
||||
docker-compose logs app
|
||||
|
||||
# 檢查容器資源
|
||||
docker stats tempspec-app
|
||||
|
||||
# 重啟應用
|
||||
docker-compose restart app
|
||||
```
|
||||
|
||||
**效能問題**
|
||||
```bash
|
||||
# 檢查快取命中率
|
||||
python monitor.py
|
||||
|
||||
# 檢查Gunicorn worker狀態
|
||||
docker-compose exec app ps aux | grep gunicorn
|
||||
|
||||
# 調整worker數量(編輯gunicorn.conf.py)
|
||||
```
|
||||
|
||||
### 6.2 基本故障排除
|
||||
|
||||
**容器無法啟動**
|
||||
```bash
|
||||
# 檢查容器狀態
|
||||
docker-compose ps
|
||||
|
||||
# 查看詳細日誌
|
||||
docker-compose logs app
|
||||
docker-compose logs onlyoffice
|
||||
docker-compose logs redis
|
||||
```
|
||||
|
||||
**資料庫連線失敗**
|
||||
```bash
|
||||
# 測試資料庫連接
|
||||
docker-compose exec app python -c "
|
||||
import pymysql
|
||||
try:
|
||||
conn = pymysql.connect(host='mysql.theaken.com', port=33306, user='A060', password='WLeSCi0yhtc7', database='db_A060')
|
||||
print('Database connection successful')
|
||||
conn.close()
|
||||
except Exception as e:
|
||||
print(f'Database connection failed: {e}')
|
||||
"
|
||||
```
|
||||
|
||||
**端口衝突**
|
||||
修改 `.env` 文件中的端口設定:
|
||||
```env
|
||||
APP_PORT=12015 # 改為其他可用端口
|
||||
ONLYOFFICE_PORT=12016 # 改為其他可用端口
|
||||
NGINX_PORT=12017 # 改為其他可用端口
|
||||
```
|
||||
|
||||
### 6.3 維護命令
|
||||
|
||||
```bash
|
||||
# 完全重建服務(清除快取)
|
||||
docker-compose down
|
||||
docker-compose build --no-cache
|
||||
docker-compose up -d
|
||||
|
||||
# 更新單一服務
|
||||
docker-compose up -d --force-recreate app
|
||||
|
||||
# 清理未使用的 Docker 資源
|
||||
docker system prune -a
|
||||
```
|
||||
|
||||
### 6.4 效能調優
|
||||
|
||||
**Redis 優化**
|
||||
```bash
|
||||
# 調整 Redis 記憶體限制(編輯 docker-compose.yml)
|
||||
# 預設: 256MB,可根據需要調整
|
||||
|
||||
# 監控 Redis 使用
|
||||
docker-compose exec redis redis-cli info memory
|
||||
```
|
||||
|
||||
**Gunicorn 調優**
|
||||
```bash
|
||||
# 編輯 gunicorn.conf.py 調整:
|
||||
# - workers: worker 進程數量
|
||||
# - timeout: 請求超時時間
|
||||
# - max_requests: worker 重啟頻率
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
**🎉 生產環境部署完成!系統已準備好支援50人的併發使用。**
|
||||
|
||||
**快速啟動**: `docker-compose up -d`
|
||||
**系統監控**: `python monitor.py --watch 5`
|
||||
**服務訪問**: http://localhost:12013/login
|
46
Dockerfile
Normal file
46
Dockerfile
Normal file
@@ -0,0 +1,46 @@
|
||||
# 使用官方 Python 3.10 運行時作為基礎映像
|
||||
FROM python:3.10-slim
|
||||
|
||||
# 設定環境變數
|
||||
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||
PYTHONUNBUFFERED=1 \
|
||||
DEBIAN_FRONTEND=noninteractive
|
||||
|
||||
# 設定工作目錄
|
||||
WORKDIR /app
|
||||
|
||||
# 更新系統套件並安裝必要的系統依賴
|
||||
RUN apt-get update && apt-get install -y \
|
||||
gcc \
|
||||
default-libmysqlclient-dev \
|
||||
pkg-config \
|
||||
curl \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
# 複製依賴檔案
|
||||
COPY requirements.txt .
|
||||
|
||||
# 安裝 Python 依賴
|
||||
RUN pip install --no-cache-dir -r requirements.txt
|
||||
|
||||
# 複製應用程式代碼
|
||||
COPY . .
|
||||
|
||||
# 建立必要的目錄
|
||||
RUN mkdir -p uploads static/generated logs
|
||||
|
||||
# 設定權限
|
||||
RUN chmod +x app.py
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 5000
|
||||
|
||||
# 健康檢查
|
||||
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 \
|
||||
CMD curl -f http://localhost:5000/ || exit 1
|
||||
|
||||
# 複製生產配置文件
|
||||
COPY gunicorn.conf.py wsgi.py ./
|
||||
|
||||
# 啟動命令 - 使用 Gunicorn
|
||||
CMD ["gunicorn", "-c", "gunicorn.conf.py", "wsgi:app"]
|
366
README.md
Normal file
366
README.md
Normal file
@@ -0,0 +1,366 @@
|
||||
# 暫時規範管理系統 V4
|
||||
|
||||
企業級暫時規範生命週期管理系統,提供完整的文件管理、LDAP整合驗證、智慧通知系統及排程提醒功能。
|
||||
|
||||
## 🚀 系統特色
|
||||
|
||||
- **LDAP/AD 整合驗證**:支援企業Active Directory單一登入
|
||||
- **ONLYOFFICE 線上編輯**:即時協作文件編輯功能
|
||||
- **智慧通知系統**:動態收件人選擇與自動提醒
|
||||
- **文件生命週期管理**:完整的建立、啟用、展延、終止流程
|
||||
- **多平台支援**:支援 Windows/Linux 環境部署
|
||||
- **Docker 容器化**:一鍵部署環境
|
||||
|
||||
## 📋 功能模組
|
||||
|
||||
### 核心功能
|
||||
- **文件管理**:Word範本自動化生成與PDF轉換
|
||||
- **權限控制**:三級權限管理 (Viewer/Editor/Admin)
|
||||
- **歷史追蹤**:完整的操作記錄與版本控制
|
||||
- **檔案上傳**:支援多種格式的佐證文件上傳
|
||||
|
||||
### 智慧通知系統
|
||||
- **動態收件人選擇**:整合LDAP的即時用戶搜尋
|
||||
- **郵件記憶功能**:自動記憶並帶出之前使用的通知對象
|
||||
- **全流程通知**:啟用、展延、終止操作的自動郵件通知
|
||||
- **自動提醒**:3天與7天到期前的主動提醒郵件
|
||||
- **排程系統**:每日自動檢查即將到期的規範
|
||||
|
||||
### 編輯器整合
|
||||
- **ONLYOFFICE整合**:支援Word文件的線上即時編輯
|
||||
- **Toast UI Editor**:Markdown格式的內容編輯器
|
||||
- **圖片支援**:內嵌圖片顯示與編輯功能
|
||||
|
||||
## 🏗️ 系統架構
|
||||
|
||||
```
|
||||
暫時規範系統 V3
|
||||
├── 前端介面 (Flask + Bootstrap 5)
|
||||
├── 後端邏輯 (Python Flask)
|
||||
├── 資料庫 (MySQL/SQLite)
|
||||
├── LDAP整合 (Active Directory)
|
||||
├── 文件引擎 (ONLYOFFICE)
|
||||
├── 排程服務 (APScheduler)
|
||||
└── 郵件系統 (SMTP)
|
||||
```
|
||||
|
||||
## 🛠️ 技術棧
|
||||
|
||||
- **後端框架**:Python Flask 3.x
|
||||
- **資料庫ORM**:SQLAlchemy
|
||||
- **前端UI**:Bootstrap 5 + Tom Select
|
||||
- **文件處理**:python-docx, docx2pdf
|
||||
- **認證系統**:Flask-Login + LDAP3
|
||||
- **排程系統**:Flask-APScheduler
|
||||
- **容器化**:Docker + Docker Compose
|
||||
|
||||
## 📦 安裝部署
|
||||
|
||||
### 前置需求
|
||||
|
||||
- Python 3.8+
|
||||
- MySQL 8.0+ 或 SQLite
|
||||
- ONLYOFFICE Document Server
|
||||
- LDAP/Active Directory 伺服器
|
||||
- SMTP 郵件伺服器
|
||||
|
||||
### 快速開始 (Docker)
|
||||
|
||||
1. **克隆專案**
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd TEMP_spec_system_V3
|
||||
```
|
||||
|
||||
2. **設定環境變數**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 編輯 .env 檔案設定資料庫、LDAP、SMTP 等參數
|
||||
```
|
||||
|
||||
3. **使用Docker Compose啟動**
|
||||
```bash
|
||||
docker-compose up -d
|
||||
```
|
||||
|
||||
4. **初始化資料庫**
|
||||
```bash
|
||||
docker-compose exec app python init_db.py
|
||||
```
|
||||
|
||||
5. **資料庫遷移(如果需要)**
|
||||
```bash
|
||||
# 新增郵件功能欄位
|
||||
docker-compose exec app python migrate_add_email_column.py
|
||||
```
|
||||
|
||||
### 手動安裝
|
||||
|
||||
#### Windows 環境
|
||||
|
||||
1. **安裝Python依賴**
|
||||
```cmd
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **設定環境變數**
|
||||
```cmd
|
||||
copy .env.example .env
|
||||
REM 編輯 .env 檔案
|
||||
```
|
||||
|
||||
3. **初始化資料庫**
|
||||
```cmd
|
||||
python init_db.py
|
||||
```
|
||||
|
||||
4. **資料庫遷移(如果需要)**
|
||||
```cmd
|
||||
python migrate_add_email_column.py
|
||||
```
|
||||
|
||||
5. **啟動 ONLYOFFICE Document Server**
|
||||
```cmd
|
||||
docker run -d -p 8080:80 --restart=always ^
|
||||
-e JWT_ENABLED=true ^
|
||||
-e JWT_SECRET=your-onlyoffice-jwt-secret-string ^
|
||||
onlyoffice/documentserver
|
||||
```
|
||||
|
||||
6. **啟動應用程式**
|
||||
```cmd
|
||||
REM 開發環境
|
||||
python app.py
|
||||
|
||||
REM 生產環境 (Windows 建議使用 Waitress)
|
||||
pip install waitress
|
||||
waitress-serve --host=0.0.0.0 --port=5000 app:app
|
||||
```
|
||||
|
||||
#### Linux 環境
|
||||
|
||||
1. **安裝Python依賴**
|
||||
```bash
|
||||
pip install -r requirements.txt
|
||||
```
|
||||
|
||||
2. **設定環境變數**
|
||||
```bash
|
||||
cp .env.example .env
|
||||
# 編輯 .env 檔案
|
||||
```
|
||||
|
||||
3. **初始化資料庫**
|
||||
```bash
|
||||
python init_db.py
|
||||
```
|
||||
|
||||
4. **資料庫遷移(如果需要)**
|
||||
```bash
|
||||
python migrate_add_email_column.py
|
||||
```
|
||||
|
||||
5. **啟動 ONLYOFFICE Document Server**
|
||||
```bash
|
||||
docker run -d -p 8080:80 --restart=always \
|
||||
-e JWT_ENABLED=true \
|
||||
-e JWT_SECRET=your-onlyoffice-jwt-secret-string \
|
||||
onlyoffice/documentserver
|
||||
```
|
||||
|
||||
6. **啟動應用程式**
|
||||
```bash
|
||||
# 開發環境
|
||||
python app.py
|
||||
|
||||
# 生產環境 (使用 Gunicorn)
|
||||
pip install gunicorn
|
||||
gunicorn -w 4 -b 0.0.0.0:5000 app:app
|
||||
```
|
||||
|
||||
## ⚙️ 組態設定
|
||||
|
||||
### 環境變數 (.env)
|
||||
|
||||
```env
|
||||
# Flask 設定
|
||||
SECRET_KEY=your_secret_key_here
|
||||
UPLOAD_FOLDER=uploads
|
||||
|
||||
# 資料庫設定
|
||||
DATABASE_URL=mysql+pymysql://user:password@localhost/tempspec_db
|
||||
|
||||
# LDAP 設定
|
||||
LDAP_SERVER=ldap://your-dc.company.com
|
||||
LDAP_PORT=389
|
||||
LDAP_USE_SSL=False
|
||||
LDAP_SEARCH_BASE=DC=company,DC=com
|
||||
LDAP_BIND_USER_DN=CN=service,DC=company,DC=com
|
||||
LDAP_BIND_USER_PASSWORD=service_password
|
||||
LDAP_USER_LOGIN_ATTR=userPrincipalName
|
||||
|
||||
# SMTP 郵件設定 (Port 25 無認證方式)
|
||||
SMTP_SERVER=mail.company.com
|
||||
SMTP_PORT=25
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_AUTH_REQUIRED=false
|
||||
SMTP_SENDER_EMAIL=temp-spec-system@company.com
|
||||
SMTP_SENDER_PASSWORD=
|
||||
|
||||
# ONLYOFFICE 設定
|
||||
ONLYOFFICE_URL=http://onlyoffice:8080
|
||||
ONLYOFFICE_JWT_SECRET=your_jwt_secret
|
||||
```
|
||||
|
||||
### SMTP 配置說明
|
||||
|
||||
系統支援多種 SMTP 配置方式:
|
||||
|
||||
- **Port 25(推薦)**:內部郵件伺服器,無需認證
|
||||
- **Port 587**:STARTTLS + 認證
|
||||
- **Port 465**:SSL + 認證
|
||||
|
||||
詳細設定請參考 `SMTP_CONFIGURATION_UPDATE.md`
|
||||
|
||||
## 📚 使用說明
|
||||
|
||||
### 登入規範
|
||||
|
||||
**重要**:系統要求使用完整的UPN格式帳號登入
|
||||
|
||||
✅ **正確格式**:`user@domain.com`
|
||||
❌ **錯誤格式**:`user`
|
||||
|
||||
### 初次設定管理員
|
||||
|
||||
系統預設所有使用者為 `viewer` 權限。設定管理員的方式:
|
||||
|
||||
1. **程式設定**:修改 `routes/auth.py` 中的預設管理員帳號
|
||||
|
||||
2. **手動設定**:在資料庫中更新用戶權限:
|
||||
```sql
|
||||
UPDATE ts_user SET role='admin' WHERE username='user@domain.com';
|
||||
```
|
||||
|
||||
### 郵件通知功能
|
||||
|
||||
系統具備智慧郵件管理功能:
|
||||
|
||||
1. **規範生效時**:輸入通知郵件對象,系統自動記憶
|
||||
2. **規範終止時**:自動帶出生效時使用的郵件清單,可編輯後發送
|
||||
3. **規範展延時**:自動帶出郵件清單,修改後更新記錄
|
||||
|
||||
### 排程任務
|
||||
|
||||
系統預設每天凌晨 2:00 執行到期檢查任務:
|
||||
|
||||
- 7天到期提醒
|
||||
- 3天到期提醒
|
||||
- 自動發送提醒郵件
|
||||
|
||||
## 🔐 安全性設定
|
||||
|
||||
### LDAP 整合
|
||||
- 支援 SSL/TLS 加密連線
|
||||
- 服務帳號權限最小化原則
|
||||
- 自動用戶同步與權限管控
|
||||
|
||||
### 資料保護
|
||||
- JWT Token 驗證
|
||||
- 檔案存取權限控制
|
||||
- SQL Injection 防護
|
||||
- XSS 攻擊防護
|
||||
|
||||
## 🐛 疑難排解
|
||||
|
||||
### 常見問題
|
||||
|
||||
1. **LDAP 連線失敗**
|
||||
- 檢查防火牆設定 (通常是 389/636 port)
|
||||
- 確認服務帳號權限
|
||||
- 驗證 LDAP 伺服器位址和搜尋基底
|
||||
|
||||
2. **ONLYOFFICE 無法載入**
|
||||
- 確認 Document Server 運行狀態:`docker ps`
|
||||
- 檢查網路連線設定
|
||||
- 驗證 JWT Secret 設定是否一致
|
||||
|
||||
3. **郵件發送失敗**
|
||||
- 確認 SMTP 設定正確
|
||||
- 檢查郵件伺服器認證設定
|
||||
- 驗證防火牆規則 (Port 25/587/465)
|
||||
|
||||
4. **排程任務未執行**
|
||||
- 檢查 APScheduler 初始化
|
||||
- 確認應用程式持續運行
|
||||
- 查看系統日誌
|
||||
|
||||
### 日誌查看
|
||||
|
||||
```bash
|
||||
# Docker 環境
|
||||
docker-compose logs -f app
|
||||
|
||||
# 一般環境
|
||||
tail -f logs/app.log
|
||||
|
||||
# Windows 環境
|
||||
Get-Content logs/app.log -Tail 10 -Wait
|
||||
```
|
||||
|
||||
## 🤝 開發指南
|
||||
|
||||
### 程式碼結構
|
||||
|
||||
```
|
||||
├── app.py # 主應用程式
|
||||
├── config.py # 組態設定
|
||||
├── models.py # 資料模型
|
||||
├── tasks.py # 排程任務
|
||||
├── routes/ # 路由模組
|
||||
│ ├── auth.py # 認證相關
|
||||
│ ├── temp_spec.py # 暫規管理
|
||||
│ ├── upload.py # 檔案上傳
|
||||
│ └── api.py # API介面
|
||||
├── templates/ # 前端範本
|
||||
├── static/ # 靜態檔案
|
||||
├── utils.py # 工具函式
|
||||
└── ldap_utils.py # LDAP 工具
|
||||
```
|
||||
|
||||
### 資料庫遷移
|
||||
|
||||
當系統需要資料庫結構更新時:
|
||||
|
||||
```bash
|
||||
# 執行遷移腳本
|
||||
python migrate_add_email_column.py
|
||||
```
|
||||
|
||||
## 📄 授權條款
|
||||
|
||||
本專案採用 MIT 授權條款。
|
||||
|
||||
## 🆕 版本歷程
|
||||
|
||||
### v3.2.0 (最新版本)
|
||||
- 🆕 新增郵件通知記憶功能
|
||||
- 🆕 支援 Port 25 無認證 SMTP
|
||||
- ♻️ 優化郵件管理邏輯
|
||||
- 🗑️ 移除測試檔案和調試代碼
|
||||
|
||||
### v3.1.0
|
||||
- 🆕 新增 LDAP/AD 整合驗證
|
||||
- 🆕 整合 ONLYOFFICE 線上編輯器
|
||||
- 🆕 實作智慧通知系統
|
||||
- 🆕 新增自動排程提醒功能
|
||||
- 🆕 支援 Docker 容器化部署
|
||||
|
||||
### v3.0.0
|
||||
- ♻️ 重構權限管理系統
|
||||
- 🗑️ 移除本地帳號管理功能
|
||||
|
||||
---
|
||||
|
||||
**暫時規範管理系統 V3** - 讓企業文件管理更智慧、更高效!
|
393
USER_MANUAL.md
Normal file
393
USER_MANUAL.md
Normal file
@@ -0,0 +1,393 @@
|
||||
# 暫時規範管理系統 V3 操作說明書
|
||||
|
||||
歡迎使用企業級暫時規範管理系統 V3。本系統整合了LDAP認證、ONLYOFFICE線上編輯器及智慧通知系統,提供完整的文件生命週期管理解決方案。
|
||||
|
||||
## 📋 目錄
|
||||
|
||||
1. [系統簡介](#1-系統簡介)
|
||||
2. [登入與主畫面](#2-登入與主畫面)
|
||||
3. [核心操作流程](#3-核心操作流程)
|
||||
4. [智慧通知系統](#4-智慧通知系統)
|
||||
5. [進階功能](#5-進階功能)
|
||||
6. [角色權限說明](#6-角色權限說明)
|
||||
7. [常見問題](#7-常見問題)
|
||||
|
||||
---
|
||||
|
||||
## 1. 系統簡介
|
||||
|
||||
暫時規範管理系統 V3 是一個集中化平台,用於管理、追蹤和存檔所有暫時性的工程規範。它涵蓋了從草擬、線上編輯、簽核、生效到最終歸檔的完整生命週期。
|
||||
|
||||
### 🚀 V3.2 版本新特色
|
||||
|
||||
- **LDAP/AD 整合**:使用企業Active Directory帳號登入
|
||||
- **智慧郵件記憶**:自動記憶並帶出之前使用的通知對象
|
||||
- **彈性郵件編輯**:可編輯通知名單並更新記錄
|
||||
- **多種SMTP支援**:支援Port 25無認證及其他認證方式
|
||||
- **自動排程提醒**:系統主動發送到期提醒郵件
|
||||
- **增強的編輯體驗**:ONLYOFFICE文件協作編輯
|
||||
|
||||
---
|
||||
|
||||
## 2. 登入與主畫面
|
||||
|
||||
### 2.1 LDAP 登入
|
||||
|
||||
系統使用企業 Active Directory 進行單一登入。
|
||||
|
||||
**🚨 重要登入規範**:
|
||||
|
||||
✅ **正確格式**:必須使用完整的 UPN 格式帳號
|
||||
例如:`user@domain.com`
|
||||
|
||||
❌ **錯誤格式**:不支援縮略帳號
|
||||
例如:`user`
|
||||
|
||||
**登入步驟**:
|
||||
1. 在登入頁面輸入您的 **完整 AD 帳號**(例如:user@domain.com)
|
||||
2. 輸入您的 **AD 密碼**
|
||||
3. 點擊「**登入**」按鈕
|
||||
|
||||
> **注意**:
|
||||
> - 首次登入的用戶預設為 `Viewer` 權限
|
||||
> - 需要聯繫系統管理員提升權限
|
||||
|
||||
### 2.2 主畫面導覽
|
||||
|
||||
登入後會看到暫時規範列表,包含:
|
||||
|
||||
- **規範編號**:系統自動產生(PE+民國年+月份+序號)
|
||||
- **主題**:規範標題
|
||||
- **申請人**:規範申請者
|
||||
- **狀態**:pending_approval(待生效)/active(已生效)/expired(已過期)/terminated(已終止)
|
||||
- **時間範圍**:生效日期至結束日期
|
||||
- **操作按鈕**:依權限顯示不同功能
|
||||
|
||||
---
|
||||
|
||||
## 3. 核心操作流程
|
||||
|
||||
### 3.1 建立新規範(Editor/Admin 權限)
|
||||
|
||||
1. 點擊「**新增規範**」按鈕
|
||||
2. 填寫規範資訊:
|
||||
- **主題**:規範標題
|
||||
- **申請人**:申請者姓名
|
||||
- **申請人電話**:聯絡電話
|
||||
- **相關資訊**:包裝、批號、設備類型等
|
||||
|
||||
3. **選擇適用站別**:
|
||||
- Probing、Dicing、Die bond、Wire bond 等
|
||||
- 可多選
|
||||
|
||||
4. **TCCS等級選擇**:
|
||||
- L1-L4 四個等級
|
||||
- 單選
|
||||
|
||||
5. **4M選擇**:
|
||||
- Man、Machine、Material、Method、Environment
|
||||
- 單選
|
||||
|
||||
6. 點擊「**建立**」完成草稿建立
|
||||
|
||||
### 3.2 編輯規範內容(Editor/Admin 權限)
|
||||
|
||||
**🆕 ONLYOFFICE 線上編輯**:
|
||||
|
||||
1. 在規範列表點擊「**編輯**」按鈕
|
||||
2. 系統開啟 ONLYOFFICE 編輯器
|
||||
3. 進行文件編輯、格式調整
|
||||
4. 使用 **Ctrl+S** 定期儲存
|
||||
5. 編輯完成後關閉編輯器視窗
|
||||
|
||||
**編輯器功能**:
|
||||
- 全功能 Word 文件編輯
|
||||
- 即時自動儲存
|
||||
- 支援圖片、表格插入
|
||||
- 格式化工具列
|
||||
|
||||
### 3.3 啟用規範(Admin 權限)
|
||||
|
||||
將規範從「待生效」變更為「已生效」狀態:
|
||||
|
||||
1. 點擊「**啟用**」按鈕
|
||||
2. **上傳已簽核檔案**:選擇已簽核的PDF檔案
|
||||
3. **設定通知對象**:
|
||||
- 在搜尋框輸入姓名或Email(至少2個字元)
|
||||
- 從下拉清單選擇收件者
|
||||
- 支援AD群組搜尋(格式:`group:群組名稱`)
|
||||
- 可選擇多位收件者
|
||||
|
||||
4. 點擊「**確認啟用**」
|
||||
5. 系統自動:
|
||||
- 更新規範狀態
|
||||
- **記憶通知對象**供後續使用
|
||||
- 發送啟用通知郵件
|
||||
|
||||
### 3.4 展延規範(Editor/Admin 權限)
|
||||
|
||||
延長已生效規範的結束日期:
|
||||
|
||||
1. 點擊「**展延**」按鈕
|
||||
2. **設定新結束日期**:選擇展延後的日期
|
||||
3. **上傳佐證檔案**:提供展延理由相關文件(PDF格式)
|
||||
4. **🆕 智慧通知設定**:
|
||||
- 系統自動帶出之前啟用時使用的通知對象
|
||||
- 可直接使用或進行編輯
|
||||
- 修改後的名單會更新到系統記錄中
|
||||
|
||||
5. 點擊「**確認展延**」
|
||||
6. 系統自動發送展延通知郵件
|
||||
|
||||
### 3.5 終止規範(Editor/Admin 權限)
|
||||
|
||||
提早結束規範:
|
||||
|
||||
1. 點擊「**終止**」按鈕
|
||||
2. **填寫終止原因**:說明提早結束的理由
|
||||
3. **🆕 智慧通知設定**:
|
||||
- 系統自動帶出之前啟用時使用的通知對象
|
||||
- 顯示提示「以下為生效時設定的通知對象」
|
||||
- 可直接使用或進行編輯
|
||||
|
||||
4. 點擊「**確認終止**」
|
||||
5. 系統自動:
|
||||
- 更新結束日期為今日
|
||||
- 發送終止通知郵件
|
||||
|
||||
---
|
||||
|
||||
## 4. 智慧通知系統
|
||||
|
||||
### 4.1 🆕 郵件記憶功能
|
||||
|
||||
**V3.2 新增功能**:系統現在具備智慧郵件管理能力
|
||||
|
||||
**運作機制**:
|
||||
1. **規範啟用時**:輸入通知郵件對象,系統自動記憶
|
||||
2. **規範終止時**:自動帶出啟用時的郵件清單,可編輯後發送
|
||||
3. **規範展延時**:自動帶出郵件清單,修改後會更新記錄
|
||||
|
||||
**操作說明**:
|
||||
- 系統會顯示「以下為生效時設定的通知對象」提示
|
||||
- 可以直接使用預設的郵件清單
|
||||
- 也可以修改郵件清單後再發送
|
||||
- 展延時修改的名單會成為新的預設通知對象
|
||||
|
||||
### 4.2 動態收件人選擇
|
||||
|
||||
**搜尋功能**:
|
||||
- 輸入至少 **2個字元** 開始搜尋
|
||||
- 支援姓名或Email模糊搜尋
|
||||
- 即時顯示搜尋結果
|
||||
|
||||
**選擇方式**:
|
||||
- **個人用戶**:直接選擇用戶
|
||||
- **AD群組**:輸入 `group:群組名稱` 選擇整個群組
|
||||
- **多重選擇**:可同時選擇多位收件者
|
||||
|
||||
**群組搜尋**:
|
||||
- 格式:`group:TempSpec_Admins`
|
||||
- 系統會自動展開群組成員
|
||||
- 發送時會寄給所有群組成員
|
||||
|
||||
### 4.3 通知類型
|
||||
|
||||
**手動通知**(操作觸發):
|
||||
- 規範啟用通知
|
||||
- 規範展延通知
|
||||
- 規範終止通知
|
||||
|
||||
**🆕 自動提醒**(系統排程):
|
||||
- **7天到期提醒**:在規範到期前7天自動發送
|
||||
- **3天到期提醒**:在規範到期前3天自動發送
|
||||
- **發送時間**:每天凌晨2:00檢查並發送
|
||||
|
||||
**郵件內容**:
|
||||
- HTML格式美化顯示
|
||||
- 包含規範編號、標題、申請人
|
||||
- 明確標示生效/結束日期
|
||||
- 提供系統連結
|
||||
|
||||
---
|
||||
|
||||
## 5. 進階功能
|
||||
|
||||
### 5.1 搜尋與篩選
|
||||
|
||||
**搜尋功能**:
|
||||
- 支援規範編號模糊搜尋
|
||||
- 支援主題關鍵字搜尋
|
||||
- 即時搜尋結果更新
|
||||
|
||||
**篩選功能**:
|
||||
- 按狀態篩選(待生效/已生效/已過期/已終止)
|
||||
- 多條件組合篩選
|
||||
- 篩選結果分頁顯示
|
||||
|
||||
### 5.2 文件下載
|
||||
|
||||
**Word文件下載**:
|
||||
- Editor/Admin 可下載編輯中的Word原始檔
|
||||
- 適用於線下簽核流程
|
||||
|
||||
**PDF文件下載**:
|
||||
- 所有用戶都可下載最終簽核版PDF
|
||||
- 適用於已生效/已終止的規範
|
||||
|
||||
### 5.3 歷史紀錄追蹤
|
||||
|
||||
點擊 **歷史紀錄圖示 (🕒)** 查看:
|
||||
|
||||
- 操作時間戳記
|
||||
- 執行用戶
|
||||
- 操作類型(建立/啟用/展延/終止)
|
||||
- 詳細說明
|
||||
|
||||
### 5.4 即將到期警示
|
||||
|
||||
在規範列表中會特別標示即將到期的規範:
|
||||
|
||||
- **🟡 橙色標示**:7天內到期
|
||||
- **🔴 紅色標示**:3天內到期
|
||||
- **閃爍動畫**:今日到期
|
||||
|
||||
---
|
||||
|
||||
## 6. 角色權限說明
|
||||
|
||||
### 6.1 權限等級
|
||||
|
||||
| 角色 | 登入 | 檢視 | 建立 | 編輯 | 啟用 | 展延/終止 | 刪除 |
|
||||
|------|------|------|------|------|------|-----------|------|
|
||||
| **Viewer** | ✅ | ✅ | ❌ | ❌ | ❌ | ❌ | ❌ |
|
||||
| **Editor** | ✅ | ✅ | ✅ | ✅ | ❌ | ✅ | ❌ |
|
||||
| **Admin** | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ | ✅ |
|
||||
|
||||
### 6.2 權限說明
|
||||
|
||||
**Viewer(檢視者)**:
|
||||
- 檢視所有規範內容
|
||||
- 下載PDF檔案
|
||||
- 檢視歷史紀錄
|
||||
|
||||
**Editor(編輯者)**:
|
||||
- 建立新規範草稿
|
||||
- 編輯規範內容
|
||||
- 展延和終止規範
|
||||
- 下載Word和PDF檔案
|
||||
|
||||
**Admin(管理員)**:
|
||||
- 所有Editor權限
|
||||
- 啟用規範(上傳簽核檔案)
|
||||
- 刪除規範
|
||||
- 系統管理功能
|
||||
|
||||
---
|
||||
|
||||
## 7. 常見問題
|
||||
|
||||
### 7.1 登入相關
|
||||
|
||||
**Q: 忘記帳號格式?**
|
||||
A: 必須使用完整的 `user@domain.com` 格式,不能只輸入 `user`
|
||||
|
||||
**Q: 無法登入?**
|
||||
A: 請確認:
|
||||
1. 帳號格式正確(包含@domain.com)
|
||||
2. 密碼正確
|
||||
3. AD帳號未被鎖定
|
||||
4. 網路連線正常
|
||||
|
||||
### 7.2 權限相關
|
||||
|
||||
**Q: 無法建立規範?**
|
||||
A: 請確認您的權限等級,Viewer無法建立規範,需要Editor以上權限。
|
||||
|
||||
**Q: 無法啟用規範?**
|
||||
A: 啟用功能需要Admin權限,請聯繫系統管理員。
|
||||
|
||||
### 7.3 編輯相關
|
||||
|
||||
**Q: ONLYOFFICE編輯器無法載入?**
|
||||
A: 請確認:
|
||||
1. 瀏覽器支援(建議Chrome/Edge)
|
||||
2. 網路連線穩定
|
||||
3. 彈出視窗未被阻擋
|
||||
|
||||
**Q: 編輯內容未儲存?**
|
||||
A: 建議:
|
||||
1. 編輯期間保持網路連線
|
||||
2. 避免同時多人編輯同一文件
|
||||
3. 定期手動儲存 (Ctrl+S)
|
||||
|
||||
### 7.4 通知相關
|
||||
|
||||
**Q: 搜尋不到AD用戶?**
|
||||
A: 請確認:
|
||||
1. 輸入至少2個字元才開始搜尋
|
||||
2. 用戶在AD中確實存在
|
||||
3. 服務帳號有足夠權限搜尋AD
|
||||
|
||||
**Q: 沒有收到通知郵件?**
|
||||
A: 請檢查:
|
||||
1. Email地址是否正確
|
||||
2. 垃圾郵件資料夾
|
||||
3. 公司郵件伺服器設定
|
||||
|
||||
**Q: 自動提醒郵件何時發送?**
|
||||
A: 系統每天凌晨2:00自動檢查並發送提醒,分別在到期前7天和3天發送。
|
||||
|
||||
**🆕 Q: 郵件通知對象會自動記憶嗎?**
|
||||
A: 是的,系統會記憶啟用時設定的通知對象:
|
||||
- 終止規範時會自動帶出之前的郵件清單
|
||||
- 展延規範時也會自動帶出,修改後會更新記錄
|
||||
- 您可以直接使用或編輯後再發送
|
||||
|
||||
### 7.5 檔案相關
|
||||
|
||||
**Q: 可以上傳Word檔案來啟用規範嗎?**
|
||||
A: 不可以。為確保文件完整性,啟用時必須上傳已簽核的 **PDF檔案**。
|
||||
|
||||
**Q: 檔案上傳失敗?**
|
||||
A: 請確認:
|
||||
1. 檔案格式正確(PDF)
|
||||
2. 檔案大小未超過限制
|
||||
3. 檔案名稱不含特殊字元
|
||||
4. 網路連線穩定
|
||||
|
||||
### 7.6 效能相關
|
||||
|
||||
**Q: 系統回應速度慢?**
|
||||
A: 可能原因:
|
||||
1. 網路連線問題
|
||||
2. 伺服器負載過高
|
||||
3. 資料庫查詢耗時
|
||||
4. 聯繫系統管理員檢查
|
||||
|
||||
---
|
||||
|
||||
## 📝 版本資訊
|
||||
|
||||
- **文件版本**: V3.2.0
|
||||
- **最後更新**: 2025年1月
|
||||
- **適用系統**: 暫時規範管理系統 V3.2
|
||||
|
||||
### 版本更新記錄
|
||||
|
||||
**V3.2.0**:
|
||||
- 新增郵件通知記憶功能
|
||||
- 支援Port 25無認證SMTP
|
||||
- 優化郵件管理邏輯
|
||||
- 更新操作說明
|
||||
|
||||
**V3.1.0**:
|
||||
- 新增LDAP/AD整合認證
|
||||
- 整合ONLYOFFICE線上編輯器
|
||||
- 實作智慧通知系統
|
||||
- 新增自動排程提醒功能
|
||||
|
||||
---
|
||||
|
||||
**感謝您使用暫時規範管理系統 V3!**
|
||||
希望這個操作手冊能幫助您更有效地使用系統功能。
|
123
app.py
Normal file
123
app.py
Normal file
@@ -0,0 +1,123 @@
|
||||
from flask import Flask, redirect, url_for, render_template
|
||||
from flask_login import LoginManager, current_user
|
||||
from flask_apscheduler import APScheduler
|
||||
from flask_caching import Cache
|
||||
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
|
||||
from routes.api import api_bp
|
||||
from cdn_utils import cdn_helper
|
||||
import redis
|
||||
|
||||
app = Flask(__name__)
|
||||
app.config.from_object('config.Config')
|
||||
|
||||
# 初始化資料庫
|
||||
db.init_app(app)
|
||||
|
||||
# 初始化Redis快取
|
||||
cache = Cache(app)
|
||||
|
||||
# 初始化CDN輔助
|
||||
cdn_helper.init_app(app)
|
||||
|
||||
# 初始化Redis連接(用於會話)
|
||||
try:
|
||||
redis_client = redis.from_url(app.config['CACHE_REDIS_URL'])
|
||||
app.config['SESSION_REDIS'] = redis_client
|
||||
except Exception as e:
|
||||
app.logger.warning(f"Redis連接失敗,使用本地快取: {e}")
|
||||
app.config['CACHE_TYPE'] = 'simple'
|
||||
|
||||
# 初始化排程器
|
||||
scheduler = APScheduler()
|
||||
scheduler.init_app(app)
|
||||
scheduler.start()
|
||||
|
||||
# 初始化登入管理
|
||||
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.register_blueprint(api_bp)
|
||||
|
||||
# 導入任務
|
||||
from tasks import check_expiring_specs
|
||||
|
||||
# 註冊排程任務:每天凌晨 2:00 執行一次
|
||||
@scheduler.task('cron', id='check_expiring_specs_job', hour=2, minute=0)
|
||||
def scheduled_job():
|
||||
check_expiring_specs(app)
|
||||
|
||||
# 註冊錯誤處理函式
|
||||
@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__':
|
||||
# 設定日誌等級以便偵錯
|
||||
import logging
|
||||
import sys
|
||||
|
||||
# 設定日誌輸出到 console
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
# 設定 Flask app 的日誌
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.addHandler(logging.StreamHandler(sys.stdout))
|
||||
|
||||
# 確保 LDAP 相關的日誌也能輸出
|
||||
ldap_logger = logging.getLogger('ldap_utils')
|
||||
ldap_logger.setLevel(logging.INFO)
|
||||
|
||||
print("=== 暫時規範系統 V4 啟動中 ===")
|
||||
print("日誌等級: INFO")
|
||||
print("="*50)
|
||||
print("🚀 系統啟動完成!")
|
||||
print("")
|
||||
print("📍 登入頁面:")
|
||||
print(" 本機: http://localhost:12010/login")
|
||||
print(" 容器: http://127.0.0.1:12010/login")
|
||||
print("")
|
||||
print("🔧 OnlyOffice 服務:")
|
||||
print(" URL: http://localhost:12011")
|
||||
print("")
|
||||
print("💡 提示: 使用 LDAP 帳號密碼登入系統")
|
||||
print("="*50)
|
||||
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
97
cache_utils.py
Normal file
97
cache_utils.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
快取輔助函數
|
||||
用於提升應用程式效能
|
||||
"""
|
||||
from functools import wraps
|
||||
from flask import request, current_app
|
||||
from app import cache
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
def cache_key(*args, **kwargs):
|
||||
"""生成快取鍵值"""
|
||||
key_data = {
|
||||
'args': args,
|
||||
'kwargs': kwargs,
|
||||
'user_id': getattr(request, 'user_id', 'anonymous'),
|
||||
'path': request.path if hasattr(request, 'path') else ''
|
||||
}
|
||||
key_string = json.dumps(key_data, sort_keys=True, default=str)
|
||||
return hashlib.md5(key_string.encode('utf-8')).hexdigest()
|
||||
|
||||
def cached_route(timeout=300):
|
||||
"""路由快取裝飾器"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_app.config.get('CACHE_TYPE') or current_app.debug:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
key = f"route:{f.__name__}:{cache_key(*args, **kwargs)}"
|
||||
|
||||
# 嘗試從快取獲取
|
||||
cached_result = cache.get(key)
|
||||
if cached_result is not None:
|
||||
current_app.logger.debug(f"快取命中: {key}")
|
||||
return cached_result
|
||||
|
||||
# 執行函數並快取結果
|
||||
result = f(*args, **kwargs)
|
||||
cache.set(key, result, timeout=timeout)
|
||||
current_app.logger.debug(f"快取設定: {key}")
|
||||
|
||||
return result
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def cached_query(timeout=300):
|
||||
"""資料庫查詢快取裝飾器"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_app.config.get('CACHE_TYPE') or current_app.debug:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
key = f"query:{f.__name__}:{cache_key(*args, **kwargs)}"
|
||||
|
||||
# 嘗試從快取獲取
|
||||
cached_result = cache.get(key)
|
||||
if cached_result is not None:
|
||||
current_app.logger.debug(f"查詢快取命中: {key}")
|
||||
return cached_result
|
||||
|
||||
# 執行查詢並快取結果
|
||||
result = f(*args, **kwargs)
|
||||
cache.set(key, result, timeout=timeout)
|
||||
current_app.logger.debug(f"查詢快取設定: {key}")
|
||||
|
||||
return result
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def invalidate_cache(pattern):
|
||||
"""清除快取"""
|
||||
try:
|
||||
if hasattr(cache, 'delete_many'):
|
||||
# Redis backend
|
||||
keys = cache.cache._read_clients.keys(f"flask_cache_{pattern}*")
|
||||
if keys:
|
||||
cache.delete_many(*keys)
|
||||
current_app.logger.info(f"清除快取: {len(keys)} 個項目")
|
||||
else:
|
||||
# Simple cache backend
|
||||
cache.clear()
|
||||
current_app.logger.info("清除所有快取")
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"清除快取失敗: {e}")
|
||||
|
||||
# 快取統計
|
||||
def cache_stats():
|
||||
"""獲取快取統計資訊"""
|
||||
try:
|
||||
if hasattr(cache.cache, 'info'):
|
||||
return cache.cache.info()
|
||||
else:
|
||||
return {"status": "simple cache", "info": "無統計資訊"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
66
cdn_utils.py
Normal file
66
cdn_utils.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
CDN 工具函數
|
||||
用於靜態資源加速
|
||||
"""
|
||||
from flask import current_app, url_for as flask_url_for
|
||||
import os
|
||||
|
||||
def cdn_url_for(endpoint, **values):
|
||||
"""
|
||||
CDN化的 url_for 函數
|
||||
自動將靜態資源指向CDN域名
|
||||
"""
|
||||
if endpoint == 'static':
|
||||
cdn_domain = current_app.config.get('CDN_DOMAIN', '').strip()
|
||||
if cdn_domain:
|
||||
# 確保CDN域名格式正確
|
||||
if not cdn_domain.startswith(('http://', 'https://')):
|
||||
cdn_domain = f"https://{cdn_domain}"
|
||||
|
||||
filename = values.get('filename', '')
|
||||
if filename:
|
||||
# 移除開頭的斜線
|
||||
filename = filename.lstrip('/')
|
||||
return f"{cdn_domain.rstrip('/')}/static/{filename}"
|
||||
|
||||
# 非靜態資源或未配置CDN時使用原始url_for
|
||||
return flask_url_for(endpoint, **values)
|
||||
|
||||
def get_static_url(filename):
|
||||
"""
|
||||
獲取靜態資源URL
|
||||
自動判斷使用CDN還是本地路徑
|
||||
"""
|
||||
return cdn_url_for('static', filename=filename)
|
||||
|
||||
def is_cdn_enabled():
|
||||
"""檢查是否啟用CDN"""
|
||||
return bool(current_app.config.get('CDN_DOMAIN', '').strip())
|
||||
|
||||
class CDNHelper:
|
||||
"""CDN輔助類"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""初始化應用"""
|
||||
app.jinja_env.globals['cdn_url_for'] = cdn_url_for
|
||||
app.jinja_env.globals['get_static_url'] = get_static_url
|
||||
app.jinja_env.globals['is_cdn_enabled'] = is_cdn_enabled
|
||||
|
||||
# 添加模板過濾器
|
||||
app.jinja_env.filters['cdn'] = self._cdn_filter
|
||||
|
||||
def _cdn_filter(self, filename):
|
||||
"""Jinja2過濾器:將靜態檔案路徑轉換為CDN URL"""
|
||||
if filename.startswith('/static/'):
|
||||
filename = filename[8:] # 移除 '/static/' 前綴
|
||||
elif filename.startswith('static/'):
|
||||
filename = filename[7:] # 移除 'static/' 前綴
|
||||
|
||||
return get_static_url(filename)
|
||||
|
||||
# 全局CDN輔助實例
|
||||
cdn_helper = CDNHelper()
|
50
config.py
Normal file
50
config.py
Normal file
@@ -0,0 +1,50 @@
|
||||
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_INTERNAL_URL = os.getenv('ONLYOFFICE_INTERNAL_URL', os.getenv('ONLYOFFICE_URL'))
|
||||
ONLYOFFICE_JWT_SECRET = os.getenv('ONLYOFFICE_JWT_SECRET')
|
||||
|
||||
# Redis 快取配置
|
||||
CACHE_TYPE = "redis"
|
||||
CACHE_REDIS_URL = os.getenv('REDIS_URL', 'redis://redis:6379/0')
|
||||
CACHE_DEFAULT_TIMEOUT = 300 # 5分鐘
|
||||
|
||||
# 會話快取配置
|
||||
SESSION_TYPE = 'redis'
|
||||
SESSION_REDIS = None # 將在 app 初始化時設定
|
||||
SESSION_PERMANENT = False
|
||||
SESSION_USE_SIGNER = True
|
||||
SESSION_KEY_PREFIX = 'tempspec:'
|
||||
|
||||
# CDN 配置
|
||||
CDN_DOMAIN = os.getenv('CDN_DOMAIN', '')
|
||||
STATIC_URL_PATH = '/static'
|
||||
|
||||
# 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_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() in ['true', '1', 't']
|
||||
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'temp-spec-system@panjit.com.tw')
|
||||
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '') # Port 25 不需要密碼
|
||||
SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() in ['true', '1', 't']
|
61
docker-compose.prod.yml
Normal file
61
docker-compose.prod.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# 生產環境專用配置
|
||||
# 使用方式: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 在生產環境中擴展 app 服務
|
||||
app:
|
||||
deploy:
|
||||
replicas: 2 # 多個實例提升可用性
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
|
||||
# 啟用 Nginx 反向代理
|
||||
nginx:
|
||||
profiles: [] # 移除 production profile,使其在生產環境中自動啟動
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# Redis 生產優化
|
||||
redis:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# OnlyOffice 資源配置
|
||||
onlyoffice:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 4G
|
||||
reservations:
|
||||
cpus: '1.0'
|
||||
memory: 2G
|
138
docker-compose.yml
Normal file
138
docker-compose.yml
Normal file
@@ -0,0 +1,138 @@
|
||||
services:
|
||||
# Redis 快取服務
|
||||
redis:
|
||||
image: redis:7-alpine
|
||||
container_name: tempspec-redis
|
||||
restart: unless-stopped
|
||||
command: redis-server --appendonly yes --maxmemory 256mb --maxmemory-policy allkeys-lru
|
||||
volumes:
|
||||
- redis_data:/data
|
||||
networks:
|
||||
- tempspec-network
|
||||
healthcheck:
|
||||
test: ["CMD", "redis-cli", "ping"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# ONLYOFFICE Document Server
|
||||
onlyoffice:
|
||||
image: onlyoffice/documentserver:8.0
|
||||
container_name: tempspec-onlyoffice
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
JWT_ENABLED: "true"
|
||||
JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here}
|
||||
JWT_HEADER: "Authorization"
|
||||
JWT_IN_BODY: "true"
|
||||
ports:
|
||||
- "${ONLYOFFICE_PORT:-12011}:80"
|
||||
volumes:
|
||||
- onlyoffice_data:/var/www/onlyoffice/Data
|
||||
- onlyoffice_logs:/var/log/onlyoffice
|
||||
networks:
|
||||
- tempspec-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# Flask 應用程式
|
||||
app:
|
||||
build: .
|
||||
container_name: tempspec-app
|
||||
restart: unless-stopped
|
||||
environment:
|
||||
# Flask 設定
|
||||
FLASK_ENV: ${FLASK_ENV:-production}
|
||||
SECRET_KEY: ${SECRET_KEY:-your-secret-key-here}
|
||||
|
||||
# 使用外部資料庫 (與 .env 相同)
|
||||
DATABASE_URL: ${DATABASE_URL:-mysql+pymysql://user:pass@host:port/dbname}
|
||||
|
||||
# Redis 設定
|
||||
REDIS_URL: redis://redis:6379/0
|
||||
|
||||
# CDN 設定
|
||||
CDN_DOMAIN: ${CDN_DOMAIN:-}
|
||||
|
||||
# LDAP 設定
|
||||
LDAP_SERVER: ${LDAP_SERVER:-ldap://your-dc.company.com}
|
||||
LDAP_PORT: ${LDAP_PORT:-389}
|
||||
LDAP_USE_SSL: ${LDAP_USE_SSL:-False}
|
||||
LDAP_SEARCH_BASE: ${LDAP_SEARCH_BASE:-DC=company,DC=com}
|
||||
LDAP_BIND_USER_DN: ${LDAP_BIND_USER_DN:-CN=service,DC=company,DC=com}
|
||||
LDAP_BIND_USER_PASSWORD: ${LDAP_BIND_USER_PASSWORD:-service_password}
|
||||
LDAP_USER_LOGIN_ATTR: ${LDAP_USER_LOGIN_ATTR:-userPrincipalName}
|
||||
|
||||
# SMTP 郵件設定
|
||||
SMTP_SERVER: ${SMTP_SERVER:-smtp.company.com}
|
||||
SMTP_PORT: ${SMTP_PORT:-587}
|
||||
SMTP_USE_TLS: ${SMTP_USE_TLS:-True}
|
||||
SMTP_SENDER_EMAIL: ${SMTP_SENDER_EMAIL:-noreply@company.com}
|
||||
SMTP_SENDER_PASSWORD: ${SMTP_SENDER_PASSWORD:-smtp_password}
|
||||
|
||||
# ONLYOFFICE 設定
|
||||
ONLYOFFICE_URL: http://localhost:12011/
|
||||
ONLYOFFICE_INTERNAL_URL: http://onlyoffice:80
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here}
|
||||
|
||||
# 其他設定
|
||||
UPLOAD_FOLDER: uploads
|
||||
ports:
|
||||
- "${APP_PORT:-12010}:5000"
|
||||
volumes:
|
||||
- ./uploads:/app/uploads
|
||||
- ./static/generated:/app/static/generated
|
||||
- ./logs:/app/logs
|
||||
- ./template_with_placeholders.docx:/app/template_with_placeholders.docx:ro
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
reservations:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
depends_on:
|
||||
redis:
|
||||
condition: service_healthy
|
||||
onlyoffice:
|
||||
condition: service_healthy
|
||||
networks:
|
||||
- tempspec-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# Nginx 反向代理 (生產環境自動啟用)
|
||||
nginx:
|
||||
image: nginx:alpine
|
||||
container_name: tempspec-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "${NGINX_PORT:-12013}:80"
|
||||
- "${NGINX_SSL_PORT:-12014}:443"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- tempspec-network
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
driver: local
|
||||
onlyoffice_data:
|
||||
driver: local
|
||||
onlyoffice_logs:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
tempspec-network:
|
||||
driver: bridge
|
53
gunicorn.conf.py
Normal file
53
gunicorn.conf.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# Gunicorn 生產環境配置
|
||||
import multiprocessing
|
||||
import os
|
||||
|
||||
# 服務器設置
|
||||
bind = "0.0.0.0:5000"
|
||||
workers = min(multiprocessing.cpu_count() * 2 + 1, 8) # 最多8個worker
|
||||
worker_class = "sync"
|
||||
worker_connections = 1000
|
||||
max_requests = 1000
|
||||
max_requests_jitter = 50
|
||||
|
||||
# 超時設置
|
||||
timeout = 300
|
||||
keepalive = 5
|
||||
graceful_timeout = 300
|
||||
|
||||
# 日誌設置
|
||||
accesslog = "-" # stdout
|
||||
errorlog = "-" # stderr
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||
|
||||
# 進程設置
|
||||
preload_app = True
|
||||
daemon = False
|
||||
pidfile = "/tmp/gunicorn.pid"
|
||||
|
||||
# 性能調優
|
||||
worker_tmp_dir = "/dev/shm" # 使用內存作為臨時目錄
|
||||
|
||||
# 安全設置
|
||||
limit_request_line = 8190
|
||||
limit_request_fields = 100
|
||||
limit_request_field_size = 8190
|
||||
|
||||
def when_ready(server):
|
||||
server.log.info("Server is ready. Spawning workers")
|
||||
|
||||
def worker_int(worker):
|
||||
worker.log.info("worker received INT or QUIT signal")
|
||||
|
||||
def pre_fork(server, worker):
|
||||
server.log.info("Worker spawned (pid: %s)", worker.pid)
|
||||
|
||||
def post_fork(server, worker):
|
||||
server.log.info("Worker spawned (pid: %s)", worker.pid)
|
||||
|
||||
def post_worker_init(worker):
|
||||
worker.log.info("Worker initialized (pid: %s)", worker.pid)
|
||||
|
||||
def worker_abort(worker):
|
||||
worker.log.info("Worker aborted (pid: %s)", worker.pid)
|
56
init_db.py
Normal file
56
init_db.py
Normal file
@@ -0,0 +1,56 @@
|
||||
# -*- coding: utf-8 -*-
|
||||
from flask import Flask
|
||||
from models import db
|
||||
from config import Config
|
||||
|
||||
def create_admin_note():
|
||||
"""顯示 LDAP 管理員設定說明。"""
|
||||
print("✅ 資料庫已初始化!")
|
||||
print(" ===================================")
|
||||
print(" 📋 LDAP 管理員設定說明")
|
||||
print(" ===================================")
|
||||
print(" 由於系統使用 LDAP 驗證,管理員權限需要在首次登入後手動設定。")
|
||||
print(" ")
|
||||
print(" 步驟:")
|
||||
print(" 1. 使用 AD 帳號登入系統")
|
||||
print(" 2. 直接在資料庫中將該用戶的 role 更新為 'admin'")
|
||||
print(" 3. 或修改 auth.py 中新用戶的預設權限設定")
|
||||
print(" ")
|
||||
print(" SQL 範例:")
|
||||
print(" UPDATE ts_user SET role='admin' WHERE username='你的AD帳號';")
|
||||
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_admin_note()
|
||||
print("\n🎉 全部完成!")
|
||||
else:
|
||||
print("❌ 操作已取消。")
|
484
ldap_utils.py
Normal file
484
ldap_utils.py
Normal file
@@ -0,0 +1,484 @@
|
||||
from ldap3 import Server, Connection, ALL, Tls, SUBTREE
|
||||
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.
|
||||
要求使用完整的UPN格式帳號 (例如: user@panjit.com.tw)
|
||||
"""
|
||||
# 驗證帳號格式必須包含 @ 符號
|
||||
if '@' not in username:
|
||||
current_app.logger.error(f"Invalid username format: {username}. Must use full UPN format (e.g., user@domain.com)")
|
||||
return None
|
||||
|
||||
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:
|
||||
print(f"[DEBUG] LDAP 連線資訊:")
|
||||
print(f" - 伺服器: {ldap_server}:{ldap_port}")
|
||||
print(f" - SSL: {use_ssl}")
|
||||
print(f" - 使用者 UPN: {user_upn}")
|
||||
current_app.logger.info(f"Connecting to LDAP server: {ldap_server}:{ldap_port}")
|
||||
|
||||
# Attempt to bind with the user's credentials to authenticate
|
||||
print(f"[DEBUG] 嘗試 LDAP 連線綁定...")
|
||||
conn = Connection(server, user=user_upn, password=password, auto_bind=True)
|
||||
|
||||
if conn.bound:
|
||||
print(f"[DEBUG] LDAP 連線綁定成功!")
|
||||
current_app.logger.info(f"LDAP bind successful for: {user_upn}")
|
||||
|
||||
# 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})'
|
||||
|
||||
current_app.logger.debug(f"LDAP search - Base: {search_base}, Filter: {search_filter}")
|
||||
|
||||
conn.search(search_base, search_filter, attributes=['mail', 'displayName', 'sAMAccountName'])
|
||||
|
||||
if conn.entries:
|
||||
entry = conn.entries[0]
|
||||
user_info = {
|
||||
'dn': entry.entry_dn, # DN 直接從 entry 物件獲取
|
||||
'email': str(entry.mail) if 'mail' in entry and entry.mail else None,
|
||||
'display_name': str(entry.displayName) if 'displayName' in entry and entry.displayName else None,
|
||||
'username': user_upn # 使用原始UPN作為username
|
||||
}
|
||||
print(f"[DEBUG] 使用者詳細資訊:")
|
||||
print(f" - 顯示名稱: {user_info['display_name']}")
|
||||
print(f" - Email: {user_info['email']}")
|
||||
print(f" - 使用者名稱: {user_info['username']}")
|
||||
print(f" - DN: {user_info['dn']}")
|
||||
current_app.logger.info(f"User details retrieved: {user_info['display_name']} ({user_upn})")
|
||||
conn.unbind()
|
||||
return user_info
|
||||
else:
|
||||
# This case is unlikely if bind succeeded, but handle it just in case
|
||||
current_app.logger.warning(f"LDAP bind successful but user not found in search: {user_upn}")
|
||||
conn.unbind()
|
||||
return None
|
||||
else:
|
||||
# Authentication failed
|
||||
print(f"[DEBUG] LDAP 連線綁定失敗! 可能是帳號密碼錯誤")
|
||||
current_app.logger.warning(f"LDAP bind failed for: {user_upn}")
|
||||
return None
|
||||
|
||||
except Exception as e:
|
||||
# Log the exception with more detail
|
||||
print(f"[DEBUG] LDAP 連線發生異常: {str(e)}")
|
||||
print(f"[DEBUG] 伺服器設定 - {ldap_server}:{ldap_port}, SSL={use_ssl}")
|
||||
current_app.logger.error(f"LDAP authentication error for {user_upn}: {str(e)}")
|
||||
current_app.logger.error(f"LDAP server: {ldap_server}, Port: {ldap_port}, SSL: {use_ssl}")
|
||||
return None
|
||||
|
||||
|
||||
def get_ldap_group_members(group_name):
|
||||
"""
|
||||
Retrieves a list of email addresses for members of a given LDAP group or organizational unit.
|
||||
Uses the application's bind credentials for searching.
|
||||
Enhanced with detailed debugging.
|
||||
"""
|
||||
print(f"[GROUP DEBUG] 開始獲取群組成員: {group_name}")
|
||||
|
||||
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']
|
||||
|
||||
print(f"[GROUP DEBUG] LDAP 設定:")
|
||||
print(f"[GROUP DEBUG] - 伺服器: {ldap_server}:{ldap_port}")
|
||||
print(f"[GROUP DEBUG] - 搜尋基底: {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:
|
||||
print(f"[GROUP DEBUG] LDAP 服務帳號連線成功")
|
||||
|
||||
# 嘗試多種搜尋方式
|
||||
emails = []
|
||||
|
||||
# 1. 首先嘗試按 cn 搜尋群組
|
||||
print(f"[GROUP DEBUG] 嘗試搜尋群組 (cn): {group_name}")
|
||||
group_search_filter = f'(&(objectClass=group)(cn={group_name}))'
|
||||
conn.search(search_base, group_search_filter, attributes=['member', 'mail'])
|
||||
|
||||
if conn.entries:
|
||||
print(f"[GROUP DEBUG] 找到群組: {conn.entries[0].entry_dn}")
|
||||
members_dn = conn.entries[0].member.values if 'member' in conn.entries[0] else []
|
||||
print(f"[GROUP DEBUG] 群組有 {len(members_dn)} 個成員")
|
||||
|
||||
# 獲取成員郵件
|
||||
for i, member_dn in enumerate(members_dn):
|
||||
print(f"[GROUP DEBUG] 處理成員 {i+1}: {member_dn}")
|
||||
try:
|
||||
member_conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
member_conn.search(member_dn, '(objectClass=*)', attributes=['mail', 'sAMAccountName', 'displayName'])
|
||||
if member_conn.entries and 'mail' in member_conn.entries[0]:
|
||||
email = str(member_conn.entries[0].mail)
|
||||
emails.append(email)
|
||||
print(f"[GROUP DEBUG] 獲取成員郵件: {email}")
|
||||
elif member_conn.entries:
|
||||
# 如果沒有 mail 屬性,嘗試用 sAMAccountName 生成
|
||||
sam = str(member_conn.entries[0].sAMAccountName) if 'sAMAccountName' in member_conn.entries[0] else None
|
||||
if sam:
|
||||
email = f"{sam}@panjit.com.tw"
|
||||
emails.append(email)
|
||||
print(f"[GROUP DEBUG] 生成成員郵件: {email}")
|
||||
member_conn.unbind()
|
||||
except Exception as member_error:
|
||||
print(f"[GROUP DEBUG] 處理成員錯誤: {member_error}")
|
||||
else:
|
||||
# 2. 如果找不到群組,嘗試按 OU 搜尋組織單位
|
||||
print(f"[GROUP DEBUG] 群組未找到,嘗試搜尋組織單位: {group_name}")
|
||||
ou_search_filter = f'(&(objectClass=organizationalUnit)(|(ou=*{group_name}*)(name=*{group_name}*)))'
|
||||
conn.search(search_base, ou_search_filter, attributes=['ou', 'name'])
|
||||
|
||||
if conn.entries:
|
||||
print(f"[GROUP DEBUG] 找到組織單位: {conn.entries[0].entry_dn}")
|
||||
# 搜尋組織單位下的所有用戶
|
||||
ou_dn = conn.entries[0].entry_dn
|
||||
user_conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
user_conn.search(
|
||||
ou_dn,
|
||||
'(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))',
|
||||
attributes=['mail', 'sAMAccountName', 'displayName'],
|
||||
search_scope=SUBTREE,
|
||||
size_limit=50
|
||||
)
|
||||
|
||||
print(f"[GROUP DEBUG] 在組織單位下找到 {len(user_conn.entries)} 個用戶")
|
||||
for entry in user_conn.entries:
|
||||
if 'mail' in entry:
|
||||
email = str(entry.mail)
|
||||
emails.append(email)
|
||||
print(f"[GROUP DEBUG] 獲取用戶郵件: {email}")
|
||||
elif 'sAMAccountName' in entry:
|
||||
sam = str(entry.sAMAccountName)
|
||||
email = f"{sam}@panjit.com.tw"
|
||||
emails.append(email)
|
||||
print(f"[GROUP DEBUG] 生成用戶郵件: {email}")
|
||||
|
||||
user_conn.unbind()
|
||||
else:
|
||||
print(f"[GROUP DEBUG] 找不到群組或組織單位: {group_name}")
|
||||
|
||||
conn.unbind()
|
||||
print(f"[GROUP DEBUG] 最終獲取 {len(emails)} 個郵件地址")
|
||||
return emails
|
||||
else:
|
||||
print("[GROUP ERROR] Failed to bind to LDAP with service account.")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"LDAP group search error: {e}")
|
||||
return []
|
||||
|
||||
|
||||
def search_ldap_principals(search_term, limit=10):
|
||||
"""
|
||||
Searches for LDAP principals (users) based on a search term.
|
||||
Returns a list of dictionaries with 'name' and 'email' keys.
|
||||
Uses the application's bind credentials for searching.
|
||||
"""
|
||||
if not search_term or len(search_term.strip()) < 2:
|
||||
print(f"[DEBUG] search_ldap_principals: 搜尋詞無效 '{search_term}'")
|
||||
return []
|
||||
|
||||
print(f"[DEBUG] search_ldap_principals: 搜尋 '{search_term}'")
|
||||
|
||||
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:
|
||||
print(f"[DEBUG] LDAP 搜尋設定:")
|
||||
print(f" - 伺服器: {ldap_server}:{ldap_port}")
|
||||
print(f" - 搜尋基底: {search_base}")
|
||||
print(f" - 服務帳號: {bind_dn}")
|
||||
|
||||
# Bind with the service account
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
if conn.bound:
|
||||
print(f"[DEBUG] LDAP 服務帳號連線成功")
|
||||
|
||||
# Search for users matching the search term in multiple attributes
|
||||
search_filter = f'(&(objectClass=user)(objectCategory=person)(|(displayName=*{search_term}*)(sAMAccountName=*{search_term}*)(mail=*{search_term}*))(!(userAccountControl:1.2.840.113556.1.4.803:=2)))'
|
||||
print(f"[DEBUG] LDAP 搜尋篩選器: {search_filter}")
|
||||
|
||||
conn.search(
|
||||
search_base,
|
||||
search_filter,
|
||||
attributes=['displayName', 'mail', 'sAMAccountName'],
|
||||
size_limit=limit
|
||||
)
|
||||
|
||||
print(f"[DEBUG] LDAP 搜尋找到 {len(conn.entries)} 個條目")
|
||||
|
||||
results = []
|
||||
for i, entry in enumerate(conn.entries):
|
||||
display_name = str(entry.displayName) if 'displayName' in entry and entry.displayName else None
|
||||
email = str(entry.mail) if 'mail' in entry and entry.mail else None
|
||||
sam_account = str(entry.sAMAccountName) if 'sAMAccountName' in entry and entry.sAMAccountName else None
|
||||
|
||||
print(f"[DEBUG] 條目 {i+1}: {display_name}, {email}, {sam_account}")
|
||||
|
||||
# Include entries with display name, generate email if missing
|
||||
if display_name:
|
||||
# Generate email from SAM account if not provided
|
||||
if not email and sam_account:
|
||||
email = f"{sam_account}@panjit.com.tw"
|
||||
|
||||
result = {
|
||||
'name': display_name,
|
||||
'email': email or 'No Email',
|
||||
'username': sam_account
|
||||
}
|
||||
results.append(result)
|
||||
print(f"[DEBUG] 加入結果: {result}")
|
||||
else:
|
||||
print(f"[DEBUG] 跳過條目 (缺少顯示名稱)")
|
||||
|
||||
conn.unbind()
|
||||
print(f"[DEBUG] 最終返回 {len(results)} 個結果")
|
||||
return results
|
||||
else:
|
||||
print("[DEBUG] Failed to bind to LDAP with service account.")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] LDAP principal search error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def search_ldap_groups(search_term, limit=10):
|
||||
"""
|
||||
搜尋 LDAP 群組 (組織) 用於郵件發送
|
||||
Returns a list of dictionaries with 'name' and 'members' keys.
|
||||
"""
|
||||
if not search_term or len(search_term.strip()) < 2:
|
||||
print(f"[DEBUG] search_ldap_groups: 搜尋詞無效 '{search_term}'")
|
||||
return []
|
||||
|
||||
print(f"[DEBUG] search_ldap_groups: 搜尋群組 '{search_term}'")
|
||||
|
||||
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:
|
||||
print(f"[DEBUG] LDAP 群組搜尋設定:")
|
||||
print(f" - 伺服器: {ldap_server}:{ldap_port}")
|
||||
print(f" - 搜尋基底: {search_base}")
|
||||
|
||||
# Bind with the service account
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
if conn.bound:
|
||||
print(f"[DEBUG] LDAP 服務帳號連線成功")
|
||||
|
||||
# 先搜尋組織單位,再搜尋群組,分開處理避免複雜的篩選器語法
|
||||
results = []
|
||||
|
||||
# 1. 搜尋組織單位
|
||||
ou_filter = f'(&(objectClass=organizationalUnit)(|(ou=*{search_term}*)(name=*{search_term}*)))'
|
||||
print(f"[DEBUG] LDAP 組織單位搜尋篩選器: {ou_filter}")
|
||||
|
||||
try:
|
||||
conn.search(
|
||||
search_base,
|
||||
ou_filter,
|
||||
attributes=['ou', 'name', 'mail'],
|
||||
size_limit=limit//2
|
||||
)
|
||||
|
||||
print(f"[DEBUG] 找到 {len(conn.entries)} 個組織單位")
|
||||
|
||||
for i, entry in enumerate(conn.entries):
|
||||
ou = str(entry.ou) if 'ou' in entry and entry.ou else None
|
||||
name = str(entry.name) if 'name' in entry and entry.name else None
|
||||
group_mail = str(entry.mail) if 'mail' in entry and entry.mail else None
|
||||
|
||||
group_name = ou or name
|
||||
|
||||
if group_name:
|
||||
# 計算組織單位下的成員數
|
||||
member_count = 0
|
||||
try:
|
||||
# 使用新的連線來計算成員,避免覆蓋當前結果
|
||||
temp_conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
if temp_conn.bound:
|
||||
temp_conn.search(
|
||||
entry.entry_dn,
|
||||
'(&(objectClass=user)(!(userAccountControl:1.2.840.113556.1.4.803:=2)))',
|
||||
attributes=['sAMAccountName'],
|
||||
size_limit=100,
|
||||
search_scope=SUBTREE
|
||||
)
|
||||
member_count = len(temp_conn.entries)
|
||||
temp_conn.unbind()
|
||||
except Exception as member_err:
|
||||
print(f"[DEBUG] 計算組織成員數錯誤: {member_err}")
|
||||
member_count = 0
|
||||
|
||||
result = {
|
||||
'name': group_name,
|
||||
'email': group_mail,
|
||||
'type': 'organizationalUnit',
|
||||
'member_count': member_count,
|
||||
'dn': entry.entry_dn,
|
||||
'members': []
|
||||
}
|
||||
results.append(result)
|
||||
print(f"[DEBUG] 加入組織單位: {result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] 組織單位搜尋錯誤: {e}")
|
||||
|
||||
# 2. 搜尋群組
|
||||
group_filter = f'(&(objectClass=group)(|(cn=*{search_term}*)(displayName=*{search_term}*)))'
|
||||
print(f"[DEBUG] LDAP 群組搜尋篩選器: {group_filter}")
|
||||
|
||||
try:
|
||||
conn.search(
|
||||
search_base,
|
||||
group_filter,
|
||||
attributes=['cn', 'displayName', 'member', 'mail'],
|
||||
size_limit=limit//2
|
||||
)
|
||||
|
||||
print(f"[DEBUG] 找到 {len(conn.entries)} 個群組")
|
||||
|
||||
for i, entry in enumerate(conn.entries):
|
||||
cn = str(entry.cn) if 'cn' in entry and entry.cn else None
|
||||
display_name = str(entry.displayName) if 'displayName' in entry and entry.displayName else None
|
||||
group_mail = str(entry.mail) if 'mail' in entry and entry.mail else None
|
||||
members = entry.member.values if 'member' in entry else []
|
||||
|
||||
group_name = display_name or cn
|
||||
|
||||
print(f"[DEBUG] 群組 {i+1}: CN={cn}, DisplayName={display_name}")
|
||||
print(f"[DEBUG] 群組名稱: {group_name}, 成員數: {len(members)}")
|
||||
|
||||
if group_name:
|
||||
result = {
|
||||
'name': group_name,
|
||||
'email': group_mail,
|
||||
'type': 'group',
|
||||
'member_count': len(members),
|
||||
'dn': entry.entry_dn,
|
||||
'members': members[:5] # 只顯示前5個成員作為預覽
|
||||
}
|
||||
results.append(result)
|
||||
print(f"[DEBUG] 加入群組結果: {result}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] 群組搜尋錯誤: {e}")
|
||||
|
||||
conn.unbind()
|
||||
print(f"[DEBUG] 最終返回 {len(results)} 個群組結果")
|
||||
return results
|
||||
else:
|
||||
print("[DEBUG] Failed to bind to LDAP with service account.")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] LDAP group search error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return []
|
||||
|
||||
|
||||
def get_group_member_emails(group_dn):
|
||||
"""
|
||||
獲取群組內所有成員的郵件地址
|
||||
"""
|
||||
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']
|
||||
|
||||
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:
|
||||
conn = Connection(server, user=bind_dn, password=bind_password, auto_bind=True)
|
||||
|
||||
if conn.bound:
|
||||
# First get the group members
|
||||
conn.search(group_dn, '(objectClass=*)', attributes=['member'])
|
||||
|
||||
if conn.entries and 'member' in conn.entries[0]:
|
||||
members_dn = conn.entries[0].member.values
|
||||
emails = []
|
||||
|
||||
# For each member DN, fetch their email
|
||||
for member_dn in members_dn:
|
||||
conn.search(member_dn, '(objectClass=*)', attributes=['mail'])
|
||||
if conn.entries and 'mail' in conn.entries[0] and conn.entries[0].mail:
|
||||
emails.append(str(conn.entries[0].mail))
|
||||
|
||||
conn.unbind()
|
||||
return emails
|
||||
|
||||
conn.unbind()
|
||||
return []
|
||||
else:
|
||||
print("Failed to bind to LDAP with service account.")
|
||||
return []
|
||||
|
||||
except Exception as e:
|
||||
print(f"Get group member emails error: {e}")
|
||||
return []
|
60
models.py
Normal file
60
models.py
Normal file
@@ -0,0 +1,60 @@
|
||||
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)
|
||||
notification_emails = 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')
|
163
monitor.py
Normal file
163
monitor.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
生產環境監控腳本
|
||||
監控系統效能、Redis狀態、資料庫連接等
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
import redis
|
||||
import json
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
def check_app_health():
|
||||
"""檢查應用程式健康狀態"""
|
||||
try:
|
||||
response = requests.get('http://localhost:12010/', timeout=10)
|
||||
return {
|
||||
'status': 'healthy' if response.status_code == 200 else 'unhealthy',
|
||||
'status_code': response.status_code,
|
||||
'response_time': response.elapsed.total_seconds()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e),
|
||||
'response_time': None
|
||||
}
|
||||
|
||||
def check_redis_health():
|
||||
"""檢查 Redis 健康狀態"""
|
||||
try:
|
||||
r = redis.from_url('redis://localhost:6379/0')
|
||||
r.ping()
|
||||
info = r.info()
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'used_memory': info.get('used_memory_human'),
|
||||
'connected_clients': info.get('connected_clients'),
|
||||
'total_commands_processed': info.get('total_commands_processed'),
|
||||
'keyspace_hits': info.get('keyspace_hits'),
|
||||
'keyspace_misses': info.get('keyspace_misses')
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_onlyoffice_health():
|
||||
"""檢查 OnlyOffice 健康狀態"""
|
||||
try:
|
||||
response = requests.get('http://localhost:12011/healthcheck', timeout=10)
|
||||
return {
|
||||
'status': 'healthy' if response.status_code == 200 else 'unhealthy',
|
||||
'status_code': response.status_code,
|
||||
'response_time': response.elapsed.total_seconds()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e),
|
||||
'response_time': None
|
||||
}
|
||||
|
||||
def get_docker_stats():
|
||||
"""獲取 Docker 容器統計資訊"""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['docker', 'stats', '--no-stream', '--format',
|
||||
'table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
else:
|
||||
return f"Error: {result.stderr}"
|
||||
except Exception as e:
|
||||
return f"Error getting docker stats: {str(e)}"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='暫時規範系統監控工具')
|
||||
parser.add_argument('--json', action='store_true', help='以JSON格式輸出')
|
||||
parser.add_argument('--watch', '-w', type=int, metavar='SECONDS',
|
||||
help='持續監控,指定刷新間隔秒數')
|
||||
args = parser.parse_args()
|
||||
|
||||
def run_checks():
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# 執行各項健康檢查
|
||||
app_health = check_app_health()
|
||||
redis_health = check_redis_health()
|
||||
onlyoffice_health = check_onlyoffice_health()
|
||||
|
||||
results = {
|
||||
'timestamp': timestamp,
|
||||
'app': app_health,
|
||||
'redis': redis_health,
|
||||
'onlyoffice': onlyoffice_health
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
# 格式化輸出
|
||||
print(f"\n🕒 監控時間: {timestamp}")
|
||||
print("=" * 60)
|
||||
|
||||
# 應用程式狀態
|
||||
status_icon = "✅" if app_health['status'] == 'healthy' else "❌"
|
||||
print(f"{status_icon} 應用程式: {app_health['status']}")
|
||||
if app_health.get('response_time'):
|
||||
print(f" 響應時間: {app_health['response_time']:.3f}s")
|
||||
if app_health.get('error'):
|
||||
print(f" 錯誤: {app_health['error']}")
|
||||
|
||||
# Redis 狀態
|
||||
status_icon = "✅" if redis_health['status'] == 'healthy' else "❌"
|
||||
print(f"{status_icon} Redis: {redis_health['status']}")
|
||||
if redis_health['status'] == 'healthy':
|
||||
print(f" 記憶體使用: {redis_health['used_memory']}")
|
||||
print(f" 連接數: {redis_health['connected_clients']}")
|
||||
if redis_health['keyspace_hits'] and redis_health['keyspace_misses']:
|
||||
hit_rate = redis_health['keyspace_hits'] / (redis_health['keyspace_hits'] + redis_health['keyspace_misses']) * 100
|
||||
print(f" 快取命中率: {hit_rate:.2f}%")
|
||||
elif redis_health.get('error'):
|
||||
print(f" 錯誤: {redis_health['error']}")
|
||||
|
||||
# OnlyOffice 狀態
|
||||
status_icon = "✅" if onlyoffice_health['status'] == 'healthy' else "❌"
|
||||
print(f"{status_icon} OnlyOffice: {onlyoffice_health['status']}")
|
||||
if onlyoffice_health.get('response_time'):
|
||||
print(f" 響應時間: {onlyoffice_health['response_time']:.3f}s")
|
||||
if onlyoffice_health.get('error'):
|
||||
print(f" 錯誤: {onlyoffice_health['error']}")
|
||||
|
||||
# Docker 統計
|
||||
print("\n📊 容器資源使用:")
|
||||
print(get_docker_stats())
|
||||
|
||||
return results
|
||||
|
||||
try:
|
||||
if args.watch:
|
||||
while True:
|
||||
try:
|
||||
if not args.json:
|
||||
print("\033[H\033[J") # 清空終端
|
||||
run_checks()
|
||||
if args.json:
|
||||
print() # JSON輸出間的分隔
|
||||
time.sleep(args.watch)
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 監控已停止")
|
||||
sys.exit(0)
|
||||
else:
|
||||
run_checks()
|
||||
except Exception as e:
|
||||
print(f"❌ 監控執行錯誤: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
18
mysql/init/01-init.sql
Normal file
18
mysql/init/01-init.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 初始化暫時規範管理系統資料庫
|
||||
-- 此檔案會在 MySQL 容器啟動時自動執行
|
||||
|
||||
-- 設定字符集和排序規則
|
||||
ALTER DATABASE tempspec_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- 確保時區設定
|
||||
SET time_zone = '+08:00';
|
||||
|
||||
-- 創建必要的索引以提升效能
|
||||
-- 注意:資料表結構由 SQLAlchemy 自動創建,這裡只需要創建額外的索引
|
||||
|
||||
-- 記錄初始化完成
|
||||
INSERT INTO mysql.general_log (event_time, user_host, thread_id, server_id, command_type, argument)
|
||||
VALUES (NOW(), 'init_script', 0, 1, 'Query', 'TempSpec Database Initialized');
|
||||
|
||||
-- 輸出初始化資訊
|
||||
SELECT 'TempSpec System Database Initialized Successfully' as STATUS;
|
83
nginx/conf.d/default.conf
Normal file
83
nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,83 @@
|
||||
# 暫時規範管理系統主站
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 重定向到 HTTPS (可選,需要 SSL 證書)
|
||||
# return 301 https://$server_name$request_uri;
|
||||
|
||||
# 應用程式代理
|
||||
location / {
|
||||
proxy_pass http://app:5000;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# WebSocket 支援
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
|
||||
# 超時設定
|
||||
proxy_connect_timeout 60s;
|
||||
proxy_send_timeout 60s;
|
||||
proxy_read_timeout 60s;
|
||||
}
|
||||
|
||||
# ONLYOFFICE Document Server 代理
|
||||
location /onlyoffice/ {
|
||||
proxy_pass http://onlyoffice:80/;
|
||||
proxy_set_header Host $host;
|
||||
proxy_set_header X-Real-IP $remote_addr;
|
||||
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
|
||||
proxy_set_header X-Forwarded-Proto $scheme;
|
||||
|
||||
# ONLYOFFICE 特殊設定
|
||||
proxy_http_version 1.1;
|
||||
proxy_set_header Upgrade $http_upgrade;
|
||||
proxy_set_header Connection "upgrade";
|
||||
proxy_redirect off;
|
||||
|
||||
# 大檔案上傳支援
|
||||
client_max_body_size 100M;
|
||||
proxy_request_buffering off;
|
||||
}
|
||||
|
||||
# 靜態檔案快取
|
||||
location /static/ {
|
||||
proxy_pass http://app:5000/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 健康檢查
|
||||
location /health {
|
||||
proxy_pass http://app:5000/;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 錯誤頁面
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
}
|
||||
|
||||
# HTTPS 設定 (需要 SSL 證書)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name localhost;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_timeout 10m;
|
||||
#
|
||||
# # HSTS
|
||||
# add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
#
|
||||
# # 其餘配置與 HTTP 相同
|
||||
# # ...
|
||||
# }
|
71
nginx/nginx.conf
Normal file
71
nginx/nginx.conf
Normal file
@@ -0,0 +1,71 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日誌格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# 基本設定
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
# 檔案大小限制
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
# Gzip 壓縮
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/geo+json
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rdf+xml
|
||||
application/rss+xml
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/eot
|
||||
font/otf
|
||||
font/ttf
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
# 安全標頭
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# 包含站點配置
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
21
requirements.txt
Normal file
21
requirements.txt
Normal file
@@ -0,0 +1,21 @@
|
||||
flask
|
||||
flask-login
|
||||
flask-sqlalchemy
|
||||
pymysql
|
||||
werkzeug
|
||||
docx2pdf
|
||||
python-docx
|
||||
docxtpl
|
||||
beautifulsoup4
|
||||
lxml
|
||||
python-dotenv
|
||||
mistune
|
||||
PyJWT
|
||||
ldap3
|
||||
Flask-APScheduler
|
||||
Pillow
|
||||
requests
|
||||
cryptography
|
||||
gunicorn
|
||||
redis
|
||||
flask-caching
|
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
96
routes/admin.py
Normal file
96
routes/admin.py
Normal file
@@ -0,0 +1,96 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash
|
||||
from flask_login import login_required, current_user
|
||||
from models import User, db
|
||||
from utils import admin_required
|
||||
|
||||
admin_bp = Blueprint('admin', __name__, url_prefix='/admin')
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_list():
|
||||
"""顯示所有使用者列表,供管理員管理權限。"""
|
||||
# MySQL 不支援 nullslast(),改用 COALESCE 處理 NULL 值
|
||||
users = User.query.order_by(User.last_login.desc(), User.username).all()
|
||||
return render_template('user_management.html', users=users)
|
||||
|
||||
@admin_bp.route('/users/edit/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def edit_user_role(user_id):
|
||||
"""編輯使用者權限。僅允許修改角色。"""
|
||||
user = User.query.get_or_404(user_id)
|
||||
new_role = request.form.get('role')
|
||||
|
||||
if new_role not in ['viewer', 'editor', 'admin']:
|
||||
flash('無效的權限設定!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
# 防止管理員修改自己的角色導致失去管理權限
|
||||
if user.id == current_user.id and user.role == 'admin' and new_role != 'admin':
|
||||
flash('無法變更自己的管理員權限!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
old_role = user.role
|
||||
user.role = new_role
|
||||
db.session.commit()
|
||||
|
||||
flash(f"使用者 '{user.username}' 的權限已從 '{old_role}' 更新為 '{new_role}'。", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
@admin_bp.route('/users/delete/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def delete_user(user_id):
|
||||
"""刪除使用者帳號。"""
|
||||
# 避免管理員刪除自己
|
||||
if user_id == current_user.id:
|
||||
flash('無法刪除自己的帳號!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
user = User.query.get_or_404(user_id)
|
||||
username = user.username
|
||||
|
||||
# 檢查是否為最後一個管理員
|
||||
admin_count = User.query.filter_by(role='admin').count()
|
||||
if user.role == 'admin' and admin_count <= 1:
|
||||
flash('無法刪除最後一個管理員帳號!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
flash(f"使用者 '{username}' 已被刪除。", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
@admin_bp.route('/users/set-admin', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def set_admin():
|
||||
"""設定特定AD帳號為管理員權限。"""
|
||||
username = request.form.get('username', '').strip()
|
||||
|
||||
if not username:
|
||||
flash('請輸入有效的AD帳號!', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
# 查找或建立使用者
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if not user:
|
||||
# 建立新的使用者記錄
|
||||
user = User(
|
||||
username=username,
|
||||
password_hash='ldap_authenticated', # LDAP使用者不需要本地密碼
|
||||
role='admin'
|
||||
)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
flash(f"已為 AD 帳號 '{username}' 建立管理員權限。", 'success')
|
||||
else:
|
||||
# 更新現有使用者權限
|
||||
old_role = user.role
|
||||
user.role = 'admin'
|
||||
db.session.commit()
|
||||
flash(f"已將 '{username}' 的權限從 '{old_role}' 更新為 'admin'。", 'success')
|
||||
|
||||
return redirect(url_for('admin.user_list'))
|
80
routes/api.py
Normal file
80
routes/api.py
Normal file
@@ -0,0 +1,80 @@
|
||||
from flask import Blueprint, request, jsonify
|
||||
from flask_login import login_required
|
||||
from ldap_utils import search_ldap_principals, search_ldap_groups
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
@api_bp.route('/ldap-search', methods=['GET'])
|
||||
@login_required
|
||||
def ldap_search():
|
||||
"""
|
||||
API endpoint for searching LDAP principals.
|
||||
Returns JSON data for Tom Select dropdown.
|
||||
"""
|
||||
search_term = request.args.get('q', '').strip()
|
||||
|
||||
print(f"[DEBUG] LDAP API 搜尋請求: '{search_term}'")
|
||||
|
||||
if not search_term or len(search_term) < 2:
|
||||
print(f"[DEBUG] 搜尋詞太短,返回空結果")
|
||||
return jsonify([])
|
||||
|
||||
try:
|
||||
print(f"[DEBUG] 開始 LDAP 搜尋: '{search_term}'")
|
||||
|
||||
# 搜尋使用者
|
||||
user_results = search_ldap_principals(search_term, limit=15)
|
||||
print(f"[DEBUG] LDAP 使用者搜尋結果數量: {len(user_results)}")
|
||||
|
||||
# 搜尋群組
|
||||
group_results = search_ldap_groups(search_term, limit=5)
|
||||
print(f"[DEBUG] LDAP 群組搜尋結果數量: {len(group_results)}")
|
||||
|
||||
# Format results for Tom Select
|
||||
formatted_results = []
|
||||
|
||||
# 加入使用者結果
|
||||
for result in user_results:
|
||||
formatted_result = {
|
||||
'value': result['email'],
|
||||
'text': f"👤 {result['name']} ({result['email']})",
|
||||
'type': 'user'
|
||||
}
|
||||
formatted_results.append(formatted_result)
|
||||
print(f"[DEBUG] 格式化使用者結果: {result['name']} - {result['email']}")
|
||||
|
||||
# 加入群組結果
|
||||
for result in group_results:
|
||||
# 對群組,使用群組名稱作為 value,在發送郵件時再展開成員
|
||||
formatted_result = {
|
||||
'value': f"group:{result['name']}", # 特殊前綴表示這是群組
|
||||
'text': f"👥 {result['name']} ({result['member_count']} 成員)",
|
||||
'type': 'group'
|
||||
}
|
||||
formatted_results.append(formatted_result)
|
||||
print(f"[DEBUG] 格式化群組結果: {result['name']} - {result['member_count']} 成員")
|
||||
|
||||
print(f"[DEBUG] 返回 {len(formatted_results)} 個搜尋結果")
|
||||
return jsonify(formatted_results)
|
||||
|
||||
except Exception as e:
|
||||
print(f"[DEBUG] LDAP search API error: {e}")
|
||||
import traceback
|
||||
traceback.print_exc()
|
||||
return jsonify({'error': 'Search failed'}), 500
|
||||
|
||||
@api_bp.route('/debug-form', methods=['POST'])
|
||||
@login_required
|
||||
def debug_form():
|
||||
"""接收前端表單除錯資訊"""
|
||||
try:
|
||||
data = request.get_json()
|
||||
print(f"[FRONTEND DEBUG] 收到前端除錯資料:")
|
||||
print(f"[FRONTEND DEBUG] selectedValues: {data.get('selectedValues')}")
|
||||
print(f"[FRONTEND DEBUG] recipientValue: {data.get('recipientValue')}")
|
||||
print(f"[FRONTEND DEBUG] hiddenFieldValue: {data.get('hiddenFieldValue')}")
|
||||
print(f"[FRONTEND DEBUG] tomSelectValue: {data.get('tomSelectValue')}")
|
||||
return jsonify({'status': 'received'})
|
||||
except Exception as e:
|
||||
print(f"[FRONTEND DEBUG] 錯誤: {e}")
|
||||
return jsonify({'error': str(e)}), 400
|
99
routes/auth.py
Normal file
99
routes/auth.py
Normal file
@@ -0,0 +1,99 @@
|
||||
from flask import Blueprint, render_template, request, redirect, url_for, flash, current_app
|
||||
from flask_login import login_user, logout_user, login_required, current_user
|
||||
from ldap_utils import authenticate_ldap_user
|
||||
from models import User, db
|
||||
from datetime import datetime
|
||||
import logging
|
||||
|
||||
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'].strip()
|
||||
password = request.form['password']
|
||||
|
||||
# 記錄登入嘗試
|
||||
print(f"[DEBUG] 登入嘗試 - 帳號: {username}")
|
||||
current_app.logger.info(f"Login attempt for user: {username}")
|
||||
|
||||
# 驗證帳號格式
|
||||
if '@' not in username:
|
||||
print(f"[DEBUG] 帳號格式錯誤 - 缺少 @ 符號: {username}")
|
||||
current_app.logger.warning(f"Invalid username format (missing @): {username}")
|
||||
flash('請使用完整的 AD 帳號格式 (包含 @domain)', 'warning')
|
||||
return render_template('login.html')
|
||||
|
||||
try:
|
||||
# Step 1: Authenticate against LDAP
|
||||
print(f"[DEBUG] 準備進行 LDAP 驗證: {username}")
|
||||
current_app.logger.info(f"Attempting LDAP authentication for: {username}")
|
||||
|
||||
user_info = authenticate_ldap_user(username, password)
|
||||
print(f"[DEBUG] LDAP 驗證結果: {user_info}")
|
||||
|
||||
if user_info:
|
||||
print(f"[DEBUG] LDAP 驗證成功: {username}")
|
||||
current_app.logger.info(f"LDAP authentication successful for: {username}")
|
||||
|
||||
# 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:
|
||||
print(f"[DEBUG] 建立新的本地使用者帳號: {user_info['username']}")
|
||||
current_app.logger.info(f"Creating new local user account: {user_info['username']}")
|
||||
|
||||
# Create a new user in the local database
|
||||
# 預設權限為 viewer,特殊帳號設為 admin
|
||||
default_role = 'viewer' # 預設權限
|
||||
|
||||
# 特殊處理:設定特定帳號為管理員權限
|
||||
if user_info['username'].lower() == 'ymirliu@panjit.com.tw':
|
||||
default_role = 'admin'
|
||||
print(f"[DEBUG] 特殊帳號:{user_info['username']} 設定為管理員權限")
|
||||
|
||||
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=default_role
|
||||
)
|
||||
db.session.add(local_user)
|
||||
print(f"[DEBUG] 新使用者建立完成,權限: {default_role}")
|
||||
current_app.logger.info(f"New user created with role: {default_role}")
|
||||
else:
|
||||
print(f"[DEBUG] 找到現有使用者: {user_info['username']}")
|
||||
current_app.logger.info(f"Existing user found: {user_info['username']}")
|
||||
|
||||
# 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)
|
||||
print(f"[DEBUG] 使用者登入成功: {username}")
|
||||
current_app.logger.info(f"User successfully logged in: {username}")
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
else:
|
||||
# LDAP 驗證失敗
|
||||
print(f"[DEBUG] LDAP 驗證失敗: {username}")
|
||||
current_app.logger.warning(f"LDAP authentication failed for: {username}")
|
||||
flash('AD帳號或密碼錯誤,請檢查後重新輸入', 'danger')
|
||||
|
||||
except Exception as e:
|
||||
# 系統錯誤
|
||||
print(f"[DEBUG] 系統錯誤: {str(e)}")
|
||||
current_app.logger.error(f"Login system error for user {username}: {str(e)}")
|
||||
flash('系統登入發生錯誤,請稍後再試或聯繫系統管理員', 'danger')
|
||||
|
||||
return render_template('login.html')
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
488
routes/temp_spec.py
Normal file
488
routes/temp_spec.py
Normal file
@@ -0,0 +1,488 @@
|
||||
# -*- 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, process_recipients
|
||||
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'
|
||||
|
||||
# 儲存通知郵件清單到資料庫
|
||||
recipients_str = request.form.get('recipients')
|
||||
if recipients_str:
|
||||
spec.notification_emails = recipients_str.strip()
|
||||
|
||||
add_history_log(spec.id, '啟用', f"上傳已簽核檔案 '{filename}'")
|
||||
db.session.commit()
|
||||
flash(f"規範 '{spec.spec_code}' 已生效!", 'success')
|
||||
|
||||
# --- Start of Dynamic Email Notification ---
|
||||
if recipients_str:
|
||||
recipients = process_recipients(recipients_str)
|
||||
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>申請人: {spec.applicant}</p>
|
||||
<p>此為系統自動發送的通知郵件,請勿直接回覆。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(f"Sent activation notification for spec {spec.spec_code} to {len(recipients)} recipients.")
|
||||
# --- End of Dynamic Email Notification ---
|
||||
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}")
|
||||
|
||||
# --- Start of Dynamic Email Notification ---
|
||||
# 優先使用表單提交的收件者,如果沒有則使用資料庫中儲存的
|
||||
recipients_str = request.form.get('recipients')
|
||||
if not recipients_str and spec.notification_emails:
|
||||
recipients_str = spec.notification_emails
|
||||
if recipients_str:
|
||||
recipients = process_recipients(recipients_str)
|
||||
if recipients:
|
||||
subject = f"[暫規通知] 規範 '{spec.spec_code}' 已提早終止"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>您好,</p>
|
||||
<p>暫時規範 <b>{spec.spec_code} - {spec.title}</b> 已被提早終止。</p>
|
||||
<p>終止日期: <b>{spec.end_date.strftime('%Y-%m-%d')}</b></p>
|
||||
<p>申請人: {spec.applicant}</p>
|
||||
<p>終止原因: {reason}</p>
|
||||
<p>詳細資訊請登入系統查看。</p>
|
||||
<p>此為系統自動發送的通知郵件,請勿直接回覆。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(f"Sent termination notification for spec {spec.spec_code} to {len(recipients)} recipients.")
|
||||
# --- End of Dynamic Email Notification ---
|
||||
|
||||
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, saved_emails=spec.notification_emails)
|
||||
|
||||
@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)
|
||||
|
||||
# --- Start of Dynamic Email Notification ---
|
||||
# 優先使用表單提交的收件者,如果沒有則使用資料庫中儲存的
|
||||
recipients_str = request.form.get('recipients')
|
||||
if not recipients_str and spec.notification_emails:
|
||||
recipients_str = spec.notification_emails
|
||||
|
||||
# 如果使用者有更新郵件清單,儲存回資料庫
|
||||
if recipients_str:
|
||||
spec.notification_emails = recipients_str.strip()
|
||||
if recipients_str:
|
||||
recipients = process_recipients(recipients_str)
|
||||
if recipients:
|
||||
subject = f"[暫規通知] 規範 '{spec.spec_code}' 已展延"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>您好,</p>
|
||||
<p>暫時規範 <b>{spec.spec_code} - {spec.title}</b> 已成功展延。</p>
|
||||
<p>新的結束日期為: <b>{spec.end_date.strftime('%Y-%m-%d')}</b></p>
|
||||
<p>申請人: {spec.applicant}</p>
|
||||
<p>詳細資訊請登入系統查看。</p>
|
||||
<p>此為系統自動發送的通知郵件,請勿直接回覆。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(f"Sent extension notification for spec {spec.spec_code} to {len(recipients)} recipients.")
|
||||
# --- End of Dynamic Email Notification ---
|
||||
|
||||
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, saved_emails=spec.notification_emails)
|
||||
|
||||
@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})
|
102
start-production.sh
Normal file
102
start-production.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 暫時規範管理系統 V4 - 生產環境啟動腳本
|
||||
# 使用方式: ./start-production.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 暫時規範管理系統 V4 - 生產環境部署"
|
||||
echo "=================================================="
|
||||
|
||||
# 檢查必要檔案
|
||||
echo "📋 檢查必要檔案..."
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "❌ 錯誤: .env 檔案不存在"
|
||||
echo "請複製 .env.production 為 .env 並配置相應的值"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
echo "❌ 錯誤: docker-compose.yml 檔案不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 檢查 Docker 是否運行
|
||||
echo "🐳 檢查 Docker 服務..."
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "❌ 錯誤: Docker 服務未運行"
|
||||
echo "請啟動 Docker 服務後再試"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止舊的容器(如果存在)
|
||||
echo "🛑 停止現有容器..."
|
||||
docker-compose down || true
|
||||
|
||||
# 建構新的映像
|
||||
echo "🔨 建構應用程式映像..."
|
||||
docker-compose build --no-cache
|
||||
|
||||
# 啟動服務(生產環境配置)
|
||||
echo "🌟 啟動生產環境服務..."
|
||||
if [ -f "docker-compose.prod.yml" ]; then
|
||||
echo "使用生產環境配置檔案..."
|
||||
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
else
|
||||
echo "使用標準配置啟動..."
|
||||
docker-compose --profile production up -d
|
||||
fi
|
||||
|
||||
# 等待服務啟動
|
||||
echo "⏳ 等待服務啟動..."
|
||||
sleep 10
|
||||
|
||||
# 檢查服務狀態
|
||||
echo "📊 檢查服務狀態..."
|
||||
docker-compose ps
|
||||
|
||||
# 顯示服務 URL
|
||||
echo ""
|
||||
echo "✅ 部署完成!"
|
||||
echo "=================================================="
|
||||
echo "📍 服務存取點:"
|
||||
echo " 主應用程式: http://localhost:12010"
|
||||
echo " OnlyOffice: http://localhost:12011"
|
||||
echo ""
|
||||
echo "📊 管理命令:"
|
||||
echo " 查看日誌: docker-compose logs -f"
|
||||
echo " 停止服務: docker-compose down"
|
||||
echo " 重啟服務: docker-compose restart"
|
||||
echo ""
|
||||
echo "🔧 監控命令:"
|
||||
echo " 查看容器狀態: docker-compose ps"
|
||||
echo " 查看資源使用: docker stats"
|
||||
echo ""
|
||||
|
||||
# 檢查健康狀態
|
||||
echo "🏥 健康檢查..."
|
||||
sleep 5
|
||||
|
||||
# 檢查 Redis
|
||||
if docker-compose exec -T redis redis-cli ping > /dev/null 2>&1; then
|
||||
echo "✅ Redis: 健康"
|
||||
else
|
||||
echo "❌ Redis: 異常"
|
||||
fi
|
||||
|
||||
# 檢查應用程式
|
||||
if curl -f http://localhost:12010/ > /dev/null 2>&1; then
|
||||
echo "✅ 應用程式: 健康"
|
||||
else
|
||||
echo "❌ 應用程式: 異常"
|
||||
fi
|
||||
|
||||
# 檢查 OnlyOffice
|
||||
if curl -f http://localhost:12011/healthcheck > /dev/null 2>&1; then
|
||||
echo "✅ OnlyOffice: 健康"
|
||||
else
|
||||
echo "❌ OnlyOffice: 異常"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 生產環境啟動完成!"
|
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
static/generated/PE1140901.docx
Normal file
BIN
static/generated/PE1140901.docx
Normal file
Binary file not shown.
BIN
static/generated/PE1140902.docx
Normal file
BIN
static/generated/PE1140902.docx
Normal file
Binary file not shown.
BIN
static/generated/PE1140903.docx
Normal file
BIN
static/generated/PE1140903.docx
Normal file
Binary file not shown.
55
tasks.py
Normal file
55
tasks.py
Normal file
@@ -0,0 +1,55 @@
|
||||
from datetime import date, timedelta
|
||||
from models import TempSpec
|
||||
from utils import send_email
|
||||
from ldap_utils import get_ldap_group_members
|
||||
|
||||
def check_expiring_specs(app):
|
||||
"""
|
||||
每日執行的排程任務:檢查即將到期的暫規並發送提醒郵件。
|
||||
"""
|
||||
with app.app_context():
|
||||
print("Running scheduled task: Checking for expiring specs...")
|
||||
today = date.today()
|
||||
seven_days_later = today + timedelta(days=7)
|
||||
three_days_later = today + timedelta(days=3)
|
||||
|
||||
# 找出 7 天後 和 3 天後到期的暫規
|
||||
expiring_soon = TempSpec.query.filter(
|
||||
TempSpec.status == 'active',
|
||||
TempSpec.end_date.in_([seven_days_later, three_days_later])
|
||||
).all()
|
||||
|
||||
if not expiring_soon:
|
||||
print("No specs expiring in 3 or 7 days.")
|
||||
return
|
||||
|
||||
# 定義預設的通知對象
|
||||
# 可以根據需要修改群組名稱
|
||||
default_recipients = get_ldap_group_members('TempSpec_Admins')
|
||||
if not default_recipients:
|
||||
print("Warning: Could not find default recipients in AD group 'TempSpec_Admins'.")
|
||||
# 如果找不到預設群組,可以設定備用收件人
|
||||
default_recipients = ['admin@example.com'] # 請根據實際情況修改
|
||||
|
||||
for spec in expiring_soon:
|
||||
remaining_days = (spec.end_date - today).days
|
||||
|
||||
# 組合通知郵件
|
||||
subject = f"[暫規到期提醒] 規範 '{spec.spec_code}' 將於 {remaining_days} 天後到期"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>您好,</p>
|
||||
<p>此為自動提醒郵件。</p>
|
||||
<p>暫時規範 <b>{spec.spec_code} - {spec.title}</b> 即將到期。</p>
|
||||
<p><b>結束日期: {spec.end_date.strftime('%Y-%m-%d')} (剩餘 {remaining_days} 天)</b></p>
|
||||
<p>申請人: {spec.applicant}</p>
|
||||
<p>請及時處理,如需展延請登入系統操作。</p>
|
||||
<p>此為系統自動發送的通知郵件,請勿直接回覆。</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
# 發送郵件給預設群組
|
||||
send_email(default_recipients, subject, body)
|
||||
print(f"Sent expiry reminder for spec {spec.spec_code} to {len(default_recipients)} recipients.")
|
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 %}
|
151
templates/activate_spec.html
Normal file
151
templates/activate_spec.html
Normal file
@@ -0,0 +1,151 @@
|
||||
{% 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>
|
||||
|
||||
<!-- 郵件通知對象選擇 -->
|
||||
<div class="mb-3">
|
||||
<label for="recipients" class="form-label"><strong>郵件通知對象</strong></label>
|
||||
<select id="recipients" multiple placeholder="請輸入姓名或 Email 來搜尋...">
|
||||
</select>
|
||||
<input type="hidden" id="recipients-hidden" name="recipients" value="" />
|
||||
<div class="form-text">可搜尋姓名或 Email 地址,支援多人選擇</div>
|
||||
</div>
|
||||
|
||||
<button type="button" class="btn btn-success" onclick="submitFormWithDebug()">上傳並啟用</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
function updateHiddenField() {
|
||||
if (window.recipientTomSelect) {
|
||||
const selectedValues = window.recipientTomSelect.getValue();
|
||||
const hiddenField = document.getElementById('recipients-hidden');
|
||||
hiddenField.value = Array.isArray(selectedValues) ? selectedValues.join(',') : selectedValues;
|
||||
console.log('[DEBUG] 更新隱藏字段:', hiddenField.value);
|
||||
}
|
||||
}
|
||||
|
||||
function submitFormWithDebug() {
|
||||
// 確保隱藏字段有最新的值
|
||||
updateHiddenField();
|
||||
|
||||
const recipientSelect = document.getElementById('recipients');
|
||||
const hiddenField = document.getElementById('recipients-hidden');
|
||||
const selectedValues = Array.from(recipientSelect.selectedOptions).map(option => option.value);
|
||||
|
||||
console.log('[FORM DEBUG] 表單提交時選中的收件者:', selectedValues);
|
||||
console.log('[FORM DEBUG] Recipients input value:', recipientSelect.value);
|
||||
console.log('[FORM DEBUG] Hidden field value:', hiddenField.value);
|
||||
|
||||
// 確認 Tom Select 的值
|
||||
if (window.recipientTomSelect) {
|
||||
console.log('[FORM DEBUG] Tom Select getValue():', window.recipientTomSelect.getValue());
|
||||
}
|
||||
|
||||
// 發送除錯資訊到後端
|
||||
const debugData = {
|
||||
selectedValues: selectedValues,
|
||||
recipientValue: recipientSelect.value,
|
||||
hiddenFieldValue: hiddenField.value,
|
||||
tomSelectValue: window.recipientTomSelect ? window.recipientTomSelect.getValue() : null
|
||||
};
|
||||
|
||||
// 透過 fetch 發送除錯資訊
|
||||
fetch('/api/debug-form', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(debugData)
|
||||
}).then(() => {
|
||||
console.log('[FORM DEBUG] 除錯資訊已發送,準備提交表單...');
|
||||
// 延遲一點點後提交表單
|
||||
setTimeout(() => {
|
||||
document.querySelector('form').submit();
|
||||
}, 200);
|
||||
}).catch(error => {
|
||||
console.error('Debug sending failed:', error);
|
||||
// 即使除錯失敗也要提交表單
|
||||
document.querySelector('form').submit();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const recipientSelect = new TomSelect('#recipients', {
|
||||
valueField: 'value',
|
||||
labelField: 'text',
|
||||
searchField: 'text',
|
||||
placeholder: '請輸入姓名或 Email 來搜尋...',
|
||||
plugins: ['remove_button'],
|
||||
maxItems: null,
|
||||
create: false,
|
||||
load: function(query, callback) {
|
||||
if (!query || query.length < 2) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/ldap-search?q=${encodeURIComponent(query)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
callback(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('LDAP search error:', error);
|
||||
callback();
|
||||
});
|
||||
},
|
||||
onItemAdd: function(value, item) {
|
||||
// 選擇項目後清空搜尋框
|
||||
this.setTextboxValue('');
|
||||
this.refreshOptions(false);
|
||||
// 更新隱藏字段
|
||||
updateHiddenField();
|
||||
},
|
||||
onItemRemove: function(value) {
|
||||
// 移除項目時更新隱藏字段
|
||||
updateHiddenField();
|
||||
},
|
||||
render: {
|
||||
option: function(item, escape) {
|
||||
return `<div class="py-1">${escape(item.text)}</div>`;
|
||||
},
|
||||
item: function(item, escape) {
|
||||
// 移除藍底背景,改用淺灰背景
|
||||
return `<div class="badge bg-light text-dark border me-1">${escape(item.text)}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 將 Tom Select 實例儲存到全域變數以便偵錯
|
||||
window.recipientTomSelect = recipientSelect;
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
98
templates/base.html
Normal file
98
templates/base.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!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">
|
||||
<!-- Tom Select CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.bootstrap5.css" rel="stylesheet">
|
||||
</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>
|
||||
<!-- Tom Select JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.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 %}
|
117
templates/extend_spec.html
Normal file
117
templates/extend_spec.html
Normal file
@@ -0,0 +1,117 @@
|
||||
{% 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>
|
||||
|
||||
<!-- 郵件通知對象選擇 -->
|
||||
<div class="mb-3">
|
||||
<label for="recipients" class="form-label"><strong>郵件通知對象</strong></label>
|
||||
{% if saved_emails %}
|
||||
<div class="alert alert-info mb-2">
|
||||
<small>以下為生效時設定的通知對象,您可以直接使用或進行編輯。如果修改,展延後將更新為新的通知對象。</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<select id="recipients" name="recipients" multiple placeholder="請輸入姓名或 Email 來搜尋...">
|
||||
</select>
|
||||
<div class="form-text">可搜尋姓名或 Email 地址,支援多人選擇</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 %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 預先載入已儲存的郵件清單
|
||||
{% if saved_emails %}
|
||||
const savedEmails = "{{ saved_emails }}".split(';').filter(email => email.trim());
|
||||
{% else %}
|
||||
const savedEmails = [];
|
||||
{% endif %}
|
||||
|
||||
const recipientSelect = new TomSelect('#recipients', {
|
||||
valueField: 'value',
|
||||
labelField: 'text',
|
||||
searchField: 'text',
|
||||
placeholder: '請輸入姓名或 Email 來搜尋...',
|
||||
plugins: ['remove_button'],
|
||||
maxItems: null,
|
||||
create: false,
|
||||
load: function(query, callback) {
|
||||
if (!query || query.length < 2) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/ldap-search?q=${encodeURIComponent(query)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
callback(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('LDAP search error:', error);
|
||||
callback();
|
||||
});
|
||||
},
|
||||
onItemAdd: function(value, item) {
|
||||
// 選擇項目後清空搜尋框
|
||||
this.setTextboxValue('');
|
||||
this.refreshOptions(false);
|
||||
},
|
||||
render: {
|
||||
option: function(item, escape) {
|
||||
return `<div class="py-1">${escape(item.text)}</div>`;
|
||||
},
|
||||
item: function(item, escape) {
|
||||
// 移除藍底背景,改用淺灰背景
|
||||
return `<div class="badge bg-light text-dark border me-1">${escape(item.text)}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 預填已儲存的郵件
|
||||
if (savedEmails.length > 0) {
|
||||
savedEmails.forEach(email => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (trimmedEmail) {
|
||||
recipientSelect.addOption({value: trimmedEmail, text: trimmedEmail});
|
||||
recipientSelect.addItem(trimmedEmail);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
41
templates/login.html
Normal file
41
templates/login.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% 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="email" class="form-control" id="username" name="username"
|
||||
placeholder="請輸入完整AD帳號 (如: user@domain.com)" required>
|
||||
<div class="form-text text-light fw-bold">請輸入完整的 AD 帳號格式 (包含 @domain)</div>
|
||||
</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>
|
||||
</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 %}
|
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 %}
|
110
templates/terminate_spec.html
Normal file
110
templates/terminate_spec.html
Normal file
@@ -0,0 +1,110 @@
|
||||
{% 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>
|
||||
|
||||
<!-- 郵件通知對象選擇 -->
|
||||
<div class="mb-3">
|
||||
<label for="recipients" class="form-label"><strong>郵件通知對象</strong></label>
|
||||
{% if saved_emails %}
|
||||
<div class="alert alert-info mb-2">
|
||||
<small>以下為生效時設定的通知對象,您可以直接使用或進行編輯。</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<select id="recipients" name="recipients" multiple placeholder="請輸入姓名或 Email 來搜尋...">
|
||||
</select>
|
||||
<div class="form-text">可搜尋姓名或 Email 地址,支援多人選擇</div>
|
||||
</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 %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 預先載入已儲存的郵件清單
|
||||
{% if saved_emails %}
|
||||
const savedEmails = "{{ saved_emails }}".split(';').filter(email => email.trim());
|
||||
{% else %}
|
||||
const savedEmails = [];
|
||||
{% endif %}
|
||||
|
||||
const recipientSelect = new TomSelect('#recipients', {
|
||||
valueField: 'value',
|
||||
labelField: 'text',
|
||||
searchField: 'text',
|
||||
placeholder: '請輸入姓名或 Email 來搜尋...',
|
||||
plugins: ['remove_button'],
|
||||
maxItems: null,
|
||||
create: false,
|
||||
load: function(query, callback) {
|
||||
if (!query || query.length < 2) {
|
||||
callback();
|
||||
return;
|
||||
}
|
||||
|
||||
fetch(`/api/ldap-search?q=${encodeURIComponent(query)}`)
|
||||
.then(response => {
|
||||
if (!response.ok) {
|
||||
throw new Error('Network response was not ok');
|
||||
}
|
||||
return response.json();
|
||||
})
|
||||
.then(data => {
|
||||
callback(data);
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('LDAP search error:', error);
|
||||
callback();
|
||||
});
|
||||
},
|
||||
onItemAdd: function(value, item) {
|
||||
// 選擇項目後清空搜尋框
|
||||
this.setTextboxValue('');
|
||||
this.refreshOptions(false);
|
||||
},
|
||||
render: {
|
||||
option: function(item, escape) {
|
||||
return `<div class="py-1">${escape(item.text)}</div>`;
|
||||
},
|
||||
item: function(item, escape) {
|
||||
// 移除藍底背景,改用淺灰背景
|
||||
return `<div class="badge bg-light text-dark border me-1">${escape(item.text)}</div>`;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
// 預填已儲存的郵件
|
||||
if (savedEmails.length > 0) {
|
||||
savedEmails.forEach(email => {
|
||||
const trimmedEmail = email.trim();
|
||||
if (trimmedEmail) {
|
||||
recipientSelect.addOption({value: trimmedEmail, text: trimmedEmail});
|
||||
recipientSelect.addItem(trimmedEmail);
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
234
templates/user_management.html
Normal file
234
templates/user_management.html
Normal file
@@ -0,0 +1,234 @@
|
||||
{% 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 bg-primary text-white">
|
||||
<i class="bi bi-person-plus-fill"></i> 設定管理員權限
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('admin.set_admin') }}" method="post" class="row g-3">
|
||||
<div class="col-md-8">
|
||||
<label for="username" class="form-label">AD 帳號</label>
|
||||
<input type="text" name="username" class="form-control" id="username"
|
||||
placeholder="例如:user@panjit.com.tw 或 username" required>
|
||||
<div class="form-text">輸入需要設定為管理員的 AD 帳號</div>
|
||||
</div>
|
||||
<div class="col-md-4 d-flex align-items-end">
|
||||
<button type="submit" class="btn btn-primary w-100">
|
||||
<i class="bi bi-shield-check"></i> 設定為管理員
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 現有使用者權限管理 -->
|
||||
<div class="card">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<i class="bi bi-people-fill"></i> 現有使用者權限管理
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>AD 帳號</th>
|
||||
<th>目前權限</th>
|
||||
<th>上次登入</th>
|
||||
<th>權限管理</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr {% if user.id == current_user.id %}class="table-warning"{% endif %}>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>
|
||||
{{ user.username }}
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge bg-info ms-1">目前使用者</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<span class="badge
|
||||
{% if user.role == 'admin' %}bg-danger
|
||||
{% elif user.role == 'editor' %}bg-warning text-dark
|
||||
{% else %}bg-secondary
|
||||
{% endif %}">
|
||||
{% if user.role == 'admin' %}
|
||||
<i class="bi bi-shield-fill"></i> 管理員
|
||||
{% elif user.role == 'editor' %}
|
||||
<i class="bi bi-pencil-fill"></i> 編輯者
|
||||
{% else %}
|
||||
<i class="bi bi-eye-fill"></i> 檢視者
|
||||
{% endif %}
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.last_login %}
|
||||
{{ user.last_login.strftime('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">從未登入</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<form action="{{ url_for('admin.edit_user_role', user_id=user.id) }}"
|
||||
method="post" class="d-inline">
|
||||
<div class="input-group input-group-sm">
|
||||
<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>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm">
|
||||
<i class="bi bi-check-lg"></i>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.id != current_user.id %}
|
||||
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}"
|
||||
method="post"
|
||||
onsubmit="return confirm('確定要刪除使用者 {{ user.username }} 嗎?此操作無法復原!');"
|
||||
class="d-inline">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" disabled
|
||||
title="無法刪除自己的帳號">
|
||||
<i class="bi bi-shield-x"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i>
|
||||
目前沒有任何使用者記錄。使用者會在首次透過 AD 登入時自動建立。
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- 權限說明 -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle"></i> 權限等級說明
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h6><span class="badge bg-secondary"><i class="bi bi-eye-fill"></i> 檢視者 (Viewer)</span></h6>
|
||||
<ul class="small">
|
||||
<li>登入系統</li>
|
||||
<li>檢視規範列表</li>
|
||||
<li>下載已生效的 PDF 文件</li>
|
||||
<li>查看歷史記錄</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><span class="badge bg-warning text-dark"><i class="bi bi-pencil-fill"></i> 編輯者 (Editor)</span></h6>
|
||||
<ul class="small">
|
||||
<li>包含 Viewer 所有權限</li>
|
||||
<li>建立新的暫時規範</li>
|
||||
<li>編輯規範內容</li>
|
||||
<li>展延規範</li>
|
||||
<li>終止規範</li>
|
||||
<li>下載 Word 編輯檔案</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><span class="badge bg-danger"><i class="bi bi-shield-fill"></i> 管理員 (Admin)</span></h6>
|
||||
<ul class="small">
|
||||
<li>包含 Editor 所有權限</li>
|
||||
<li>啟用待生效的規範</li>
|
||||
<li>管理使用者權限</li>
|
||||
<li>刪除規範</li>
|
||||
<li>系統設定管理</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- LDAP 整合說明 -->
|
||||
<div class="card mt-4">
|
||||
<div class="card-header bg-info text-white">
|
||||
<i class="bi bi-diagram-3"></i> LDAP/AD 整合說明
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<p><strong>系統運作方式:</strong></p>
|
||||
<ol>
|
||||
<li>使用者首次使用 AD 帳號登入時,系統會自動建立本地使用者記錄,預設權限為 <code>viewer</code></li>
|
||||
<li>管理員可以在此頁面調整使用者權限等級</li>
|
||||
<li>使用者的身份認證完全由 Active Directory 處理,本系統不儲存密碼</li>
|
||||
<li>刪除本地使用者記錄不會影響 AD 帳號,使用者仍可重新登入(但會重置為 viewer 權限)</li>
|
||||
</ol>
|
||||
|
||||
<div class="alert alert-warning mt-3">
|
||||
<i class="bi bi-exclamation-triangle"></i>
|
||||
<strong>重要提醒:</strong>確保至少保留一個管理員帳號,避免無法進行權限管理。
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 自動提交表單當選擇權限改變時的確認
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
const roleSelects = document.querySelectorAll('select[name="role"]');
|
||||
roleSelects.forEach(select => {
|
||||
select.addEventListener('change', function() {
|
||||
const form = this.closest('form');
|
||||
const username = form.closest('tr').querySelector('td:nth-child(2)').textContent.trim();
|
||||
const newRole = this.value;
|
||||
|
||||
if (confirm(`確定要將使用者 "${username}" 的權限變更為 "${newRole}" 嗎?`)) {
|
||||
// 使用者確認後自動提交
|
||||
setTimeout(() => {
|
||||
form.submit();
|
||||
}, 100);
|
||||
} else {
|
||||
// 使用者取消,恢復原來的選擇
|
||||
this.selectedIndex = this.getAttribute('data-original-index') || 0;
|
||||
}
|
||||
});
|
||||
|
||||
// 記錄原始選擇索引
|
||||
select.setAttribute('data-original-index', select.selectedIndex);
|
||||
});
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
37
update_admin.py
Normal file
37
update_admin.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
更新使用者權限為管理員的腳本
|
||||
"""
|
||||
|
||||
from models import db, User
|
||||
from app import app
|
||||
|
||||
def update_user_to_admin(username):
|
||||
"""將指定使用者設為管理員權限"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
print(f'找到使用者: {user.username}')
|
||||
print(f'當前權限: {user.role}')
|
||||
|
||||
if user.role != 'admin':
|
||||
user.role = 'admin'
|
||||
db.session.commit()
|
||||
print(f'權限已更新為: {user.role}')
|
||||
else:
|
||||
print('使用者已經是管理員權限')
|
||||
return True
|
||||
else:
|
||||
print(f'使用者 {username} 不存在')
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
username = 'ymirliu@panjit.com.tw'
|
||||
print(f"正在更新使用者 {username} 的權限...")
|
||||
|
||||
success = update_user_to_admin(username)
|
||||
|
||||
if success:
|
||||
print("\n管理員權限設定完成!")
|
||||
else:
|
||||
print("\n請先使用該帳號登入一次以建立使用者記錄")
|
308
utils.py
Normal file
308
utils.py
Normal file
@@ -0,0 +1,308 @@
|
||||
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
|
||||
# Windows 專用模組,Linux 環境需要跨平台處理
|
||||
try:
|
||||
import pythoncom
|
||||
PYTHONCOM_AVAILABLE = True
|
||||
except ImportError:
|
||||
PYTHONCOM_AVAILABLE = False
|
||||
pythoncom = None
|
||||
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 = 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
|
||||
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:
|
||||
if PYTHONCOM_AVAILABLE:
|
||||
pythoncom.CoInitialize()
|
||||
convert(output_word_path, output_pdf_path)
|
||||
except Exception as e:
|
||||
print(f"PDF conversion failed: {e}")
|
||||
raise
|
||||
finally:
|
||||
if PYTHONCOM_AVAILABLE:
|
||||
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 process_recipients(recipients_str):
|
||||
"""
|
||||
處理收件者字串,支援個人郵件和群組展開
|
||||
輸入格式: "email1,email2,group:GroupName"
|
||||
返回: 展開後的郵件地址列表
|
||||
"""
|
||||
print(f"[RECIPIENTS DEBUG] 開始處理收件者: {recipients_str}")
|
||||
|
||||
if not recipients_str:
|
||||
print(f"[RECIPIENTS DEBUG] 收件者字串為空")
|
||||
return []
|
||||
|
||||
recipients = [item.strip() for item in recipients_str.split(',') if item.strip()]
|
||||
final_emails = []
|
||||
|
||||
for recipient in recipients:
|
||||
print(f"[RECIPIENTS DEBUG] 處理收件者項目: {recipient}")
|
||||
|
||||
if recipient.startswith('group:'):
|
||||
# 這是一個群組,需要展開
|
||||
group_name = recipient[6:] # 移除 'group:' 前綴
|
||||
print(f"[RECIPIENTS DEBUG] 發現群組: {group_name}")
|
||||
|
||||
try:
|
||||
from ldap_utils import get_ldap_group_members
|
||||
group_emails = get_ldap_group_members(group_name)
|
||||
print(f"[RECIPIENTS DEBUG] 群組 {group_name} 包含 {len(group_emails)} 個成員")
|
||||
|
||||
for email in group_emails:
|
||||
if email and email not in final_emails:
|
||||
final_emails.append(email)
|
||||
print(f"[RECIPIENTS DEBUG] 添加群組成員郵件: {email}")
|
||||
|
||||
except Exception as e:
|
||||
print(f"[RECIPIENTS ERROR] 群組 {group_name} 展開失敗: {e}")
|
||||
|
||||
else:
|
||||
# 這是個人郵件地址
|
||||
if recipient and recipient not in final_emails:
|
||||
final_emails.append(recipient)
|
||||
print(f"[RECIPIENTS DEBUG] 添加個人郵件: {recipient}")
|
||||
|
||||
print(f"[RECIPIENTS DEBUG] 最終收件者列表 ({len(final_emails)} 個): {final_emails}")
|
||||
return final_emails
|
||||
|
||||
def send_email(to_addrs, subject, body):
|
||||
"""
|
||||
Sends an email using the SMTP settings from the config.
|
||||
Supports both authenticated (Port 465/587) and unauthenticated (Port 25) methods.
|
||||
"""
|
||||
print(f"[EMAIL DEBUG] 開始發送郵件...")
|
||||
print(f"[EMAIL DEBUG] 收件者數量: {len(to_addrs)}")
|
||||
print(f"[EMAIL DEBUG] 收件者: {to_addrs}")
|
||||
print(f"[EMAIL DEBUG] 主旨: {subject}")
|
||||
|
||||
try:
|
||||
# 取得 SMTP 設定
|
||||
smtp_server = current_app.config['SMTP_SERVER']
|
||||
smtp_port = current_app.config['SMTP_PORT']
|
||||
use_tls = current_app.config.get('SMTP_USE_TLS', False)
|
||||
use_ssl = current_app.config.get('SMTP_USE_SSL', False)
|
||||
sender_email = current_app.config['SMTP_SENDER_EMAIL']
|
||||
sender_password = current_app.config.get('SMTP_SENDER_PASSWORD', '')
|
||||
auth_required = current_app.config.get('SMTP_AUTH_REQUIRED', False)
|
||||
|
||||
print(f"[EMAIL DEBUG] SMTP 設定:")
|
||||
print(f"[EMAIL DEBUG] - 伺服器: {smtp_server}:{smtp_port}")
|
||||
print(f"[EMAIL DEBUG] - 使用 TLS: {use_tls}")
|
||||
print(f"[EMAIL DEBUG] - 使用 SSL: {use_ssl}")
|
||||
print(f"[EMAIL DEBUG] - 寄件者: {sender_email}")
|
||||
print(f"[EMAIL DEBUG] - 需要認證: {auth_required}")
|
||||
print(f"[EMAIL DEBUG] - 有密碼: {'是' if sender_password else '否'}")
|
||||
|
||||
# 建立郵件內容
|
||||
print(f"[EMAIL DEBUG] 建立郵件內容...")
|
||||
msg = MIMEText(body, 'html', 'utf-8')
|
||||
msg['Subject'] = Header(subject, 'utf-8')
|
||||
msg['From'] = sender_email
|
||||
msg['To'] = ', '.join(to_addrs)
|
||||
print(f"[EMAIL DEBUG] 郵件內容建立完成")
|
||||
|
||||
# 連接 SMTP 伺服器
|
||||
if use_ssl and smtp_port == 465:
|
||||
# Port 465 使用 SSL
|
||||
print(f"[EMAIL DEBUG] 使用 SSL 連接 SMTP 伺服器 {smtp_server}:{smtp_port}...")
|
||||
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
|
||||
else:
|
||||
# Port 25 或 587 使用一般連接
|
||||
print(f"[EMAIL DEBUG] 連接 SMTP 伺服器 {smtp_server}:{smtp_port}...")
|
||||
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||
|
||||
print(f"[EMAIL DEBUG] SMTP 伺服器連接成功")
|
||||
|
||||
if use_tls and smtp_port == 587:
|
||||
print(f"[EMAIL DEBUG] 啟用 TLS...")
|
||||
server.starttls()
|
||||
print(f"[EMAIL DEBUG] TLS 啟用成功")
|
||||
|
||||
# 只在需要認證時才登入
|
||||
if auth_required and sender_password:
|
||||
print(f"[EMAIL DEBUG] 登入 SMTP 伺服器...")
|
||||
server.login(sender_email, sender_password)
|
||||
print(f"[EMAIL DEBUG] SMTP 登入成功")
|
||||
else:
|
||||
print(f"[EMAIL DEBUG] 使用匿名發送(Port 25 無需認證)")
|
||||
|
||||
# 發送郵件
|
||||
print(f"[EMAIL DEBUG] 發送郵件...")
|
||||
result = server.sendmail(sender_email, to_addrs, msg.as_string())
|
||||
print(f"[EMAIL DEBUG] 郵件發送結果: {result}")
|
||||
|
||||
server.quit()
|
||||
print(f"[EMAIL DEBUG] SMTP 連接已關閉")
|
||||
print(f"[EMAIL SUCCESS] 郵件成功發送至: {', '.join(to_addrs)}")
|
||||
return True
|
||||
|
||||
except smtplib.SMTPAuthenticationError as e:
|
||||
print(f"[EMAIL ERROR] SMTP 認證失敗: {e}")
|
||||
print(f"[EMAIL ERROR] 請檢查寄件者帳號和密碼設定")
|
||||
return False
|
||||
except smtplib.SMTPConnectError as e:
|
||||
print(f"[EMAIL ERROR] SMTP 連接失敗: {e}")
|
||||
print(f"[EMAIL ERROR] 請檢查 SMTP 伺服器設定")
|
||||
return False
|
||||
except smtplib.SMTPRecipientsRefused as e:
|
||||
print(f"[EMAIL ERROR] 收件者被拒絕: {e}")
|
||||
print(f"[EMAIL ERROR] 請檢查收件者郵件地址")
|
||||
return False
|
||||
except Exception as e:
|
||||
print(f"[EMAIL ERROR] 郵件發送失敗: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
print(f"[EMAIL ERROR] 詳細錯誤:")
|
||||
traceback.print_exc()
|
||||
return False
|
27
wsgi.py
Normal file
27
wsgi.py
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WSGI 生產環境入口點
|
||||
用於Gunicorn部署
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
from app import app
|
||||
|
||||
# 配置生產環境日誌
|
||||
if __name__ != "__main__":
|
||||
# 只在Gunicorn環境下配置日誌
|
||||
gunicorn_logger = logging.getLogger('gunicorn.error')
|
||||
app.logger.handlers = gunicorn_logger.handlers
|
||||
app.logger.setLevel(gunicorn_logger.level)
|
||||
|
||||
# 確保在生產環境
|
||||
os.environ['FLASK_ENV'] = 'production'
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 開發環境直接運行
|
||||
print("🚀 開發環境啟動")
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
else:
|
||||
# 生產環境通過Gunicorn
|
||||
print("🌟 生產環境啟動 (Gunicorn)")
|
Reference in New Issue
Block a user