commit a408ce402d4661f350afacefa107ff6df560d295 Author: beabigegg Date: Fri Sep 12 08:00:56 2025 +0800 check_ok diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..363b93e --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,9 @@ +{ + "permissions": { + "allow": [ + "Bash(chmod:*)" + ], + "deny": [], + "ask": [] + } +} \ No newline at end of file diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..98299af --- /dev/null +++ b/.dockerignore @@ -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 \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..0322a5b --- /dev/null +++ b/.env @@ -0,0 +1,35 @@ +# .env +# 將敏感資訊存放在此,並確保此檔案被加入 .gitignore,不要上傳到版本控制系統 + +# 資料庫連線字串 +# 格式: mysql+pymysql://:@:/ +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= \ No newline at end of file diff --git a/.env.docker.example b/.env.docker.example new file mode 100644 index 0000000..3e29e20 --- /dev/null +++ b/.env.docker.example @@ -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 \ No newline at end of file diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..0437064 --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.env.production b/.env.production new file mode 100644 index 0000000..dc7109a --- /dev/null +++ b/.env.production @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..a3d9b53 --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..4696a11 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 +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 \ No newline at end of file diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..689e2e9 --- /dev/null +++ b/Dockerfile @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..ca20466 --- /dev/null +++ b/README.md @@ -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 +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** - 讓企業文件管理更智慧、更高效! \ No newline at end of file diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..c0c1692 --- /dev/null +++ b/USER_MANUAL.md @@ -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!** +希望這個操作手冊能幫助您更有效地使用系統功能。 \ No newline at end of file diff --git a/app.py b/app.py new file mode 100644 index 0000000..e220ca0 --- /dev/null +++ b/app.py @@ -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) diff --git a/cache_utils.py b/cache_utils.py new file mode 100644 index 0000000..58d7cdf --- /dev/null +++ b/cache_utils.py @@ -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)} \ No newline at end of file diff --git a/cdn_utils.py b/cdn_utils.py new file mode 100644 index 0000000..a401aa1 --- /dev/null +++ b/cdn_utils.py @@ -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() \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..bb80348 --- /dev/null +++ b/config.py @@ -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'] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d04744c --- /dev/null +++ b/docker-compose.prod.yml @@ -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 \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..42af0aa --- /dev/null +++ b/docker-compose.yml @@ -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 \ No newline at end of file diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..95d81ae --- /dev/null +++ b/gunicorn.conf.py @@ -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) \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..780552a --- /dev/null +++ b/init_db.py @@ -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("❌ 操作已取消。") \ No newline at end of file diff --git a/ldap_utils.py b/ldap_utils.py new file mode 100644 index 0000000..63b1fe6 --- /dev/null +++ b/ldap_utils.py @@ -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 [] diff --git a/models.py b/models.py new file mode 100644 index 0000000..57f1c70 --- /dev/null +++ b/models.py @@ -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') \ No newline at end of file diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..0a503ee --- /dev/null +++ b/monitor.py @@ -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() \ No newline at end of file diff --git a/mysql/init/01-init.sql b/mysql/init/01-init.sql new file mode 100644 index 0000000..0a621ee --- /dev/null +++ b/mysql/init/01-init.sql @@ -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; \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..30782f4 --- /dev/null +++ b/nginx/conf.d/default.conf @@ -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 相同 +# # ... +# } \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..683b3dd --- /dev/null +++ b/nginx/nginx.conf @@ -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; +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..099bf19 --- /dev/null +++ b/requirements.txt @@ -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 \ No newline at end of file diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..12a00b7 --- /dev/null +++ b/routes/admin.py @@ -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/', 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/', 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')) \ No newline at end of file diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..11663ce --- /dev/null +++ b/routes/api.py @@ -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 \ No newline at end of file diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..dfc81f1 --- /dev/null +++ b/routes/auth.py @@ -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')) diff --git a/routes/temp_spec.py b/routes/temp_spec.py new file mode 100644 index 0000000..cfe8990 --- /dev/null +++ b/routes/temp_spec.py @@ -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/') +@editor_or_admin_required +def edit_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + doc_filename = f"{spec.spec_code}.docx" + + doc_physical_path = os.path.join(current_app.static_folder, 'generated', doc_filename) + if not os.path.exists(doc_physical_path): + flash(f'找不到文件檔案: {doc_filename}', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + # --- START: 修正文件下載與回呼的 URL --- + + # 1. 產生標準的文件 URL 和回呼 URL + doc_url = get_file_uri(doc_filename) + callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True) + + # 2. 如果是在開發環境,將 URL 中的 localhost 替換為 Docker 可存取的地址 + if '127.0.0.1' in doc_url or 'localhost' in doc_url: + # 同時修正 doc_url 和 callback_url + doc_url = doc_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal') + callback_url = callback_url.replace('127.0.0.1', 'host.docker.internal').replace('localhost', 'host.docker.internal') + + # --- END: 修正文件下載與回呼的 URL --- + + oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET'] + + payload = { + "document": { + "fileType": "docx", + "key": f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}", + "title": doc_filename, + "url": doc_url # <-- 使用修正後的 doc_url + }, + "documentType": "word", + "editorConfig": { + "callbackUrl": callback_url, # <-- 使用修正後的回呼 URL + "user": { "id": str(current_user.id), "name": current_user.username }, + "customization": { "autosave": True, "forcesave": True } + } + } + + token = jwt.encode(payload, oo_secret, algorithm='HS256') + + config = payload.copy() + config['token'] = token + + return render_template( + 'onlyoffice_editor.html', + spec=spec, + config=config, + onlyoffice_url=current_app.config['ONLYOFFICE_URL'] + ) + +# 這個路由不需要登入驗證,因為是 ONLYOFFICE Server 在呼叫它 +@temp_spec_bp.route('/onlyoffice-callback/', methods=['POST']) +def onlyoffice_callback(spec_id): + data = request.json + + if data.get('status') == 2: + try: + response = requests.get(data['url'], timeout=10) + response.raise_for_status() + + spec = TempSpec.query.get_or_404(spec_id) + doc_filename = f"{spec.spec_code}.docx" + file_path = os.path.join(current_app.static_folder, 'generated', doc_filename) + + with open(file_path, 'wb') as f: + f.write(response.content) + except Exception as e: + current_app.logger.error(f"ONLYOFFICE callback error for spec {spec_id}: {e}") + return jsonify({"error": 1, "message": str(e)}) + + return jsonify({"error": 0}) + +# --- 其他既有路由 --- + +@temp_spec_bp.route('/list') +@login_required # 補上登入驗證 +def spec_list(): + page = request.args.get('page', 1, type=int) + query = request.args.get('query', '') + status_filter = request.args.get('status', '') + specs_query = TempSpec.query + + if query: + search_term = f"%{query}%" + specs_query = specs_query.filter( + db.or_( + TempSpec.spec_code.ilike(search_term), + TempSpec.title.ilike(search_term) + ) + ) + + if status_filter: + specs_query = specs_query.filter(TempSpec.status == status_filter) + + pagination = specs_query.order_by(TempSpec.created_at.desc()).paginate( + page=page, per_page=15, error_out=False + ) + + specs = pagination.items + # --- START: 新增的程式碼 --- + # 取得今天的日期,並傳給模板 + from datetime import date + today = date.today() + # --- END: 新增的程式碼 --- + + return render_template( + 'spec_list.html', + specs=specs, + pagination=pagination, + query=query, + status=status_filter, + today=today # <-- 將 today 傳遞到模板 + ) + +@temp_spec_bp.route('/activate/', methods=['GET', 'POST']) +@admin_required +def activate_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + if request.method == 'POST': + uploaded_file = request.files.get('signed_file') + if not uploaded_file or uploaded_file.filename == '': + flash('您必須上傳一個檔案。', 'danger') + return redirect(url_for('temp_spec.activate_spec', spec_id=spec.id)) + + filename = secure_filename(f"{spec.spec_code}_signed_{datetime.now().strftime('%Y%m%d%H%M%S')}.pdf") + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + os.makedirs(upload_folder, exist_ok=True) + file_path = os.path.join(upload_folder, filename) + uploaded_file.save(file_path) + + new_upload = Upload( + temp_spec_id=spec.id, + filename=filename, + upload_time=datetime.now() + ) + db.session.add(new_upload) + + spec.status = 'active' + + # 儲存通知郵件清單到資料庫 + 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""" + + +

您好,

+

暫時規範 {spec.spec_code} - {spec.title} 已由管理員啟用,並正式生效。

+

詳細資訊請登入系統查看。

+

生效日期: {spec.start_date.strftime('%Y-%m-%d')}
+ 結束日期: {spec.end_date.strftime('%Y-%m-%d')}

+

申請人: {spec.applicant}

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + 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/', 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""" + + +

您好,

+

暫時規範 {spec.spec_code} - {spec.title} 已被提早終止。

+

終止日期: {spec.end_date.strftime('%Y-%m-%d')}

+

申請人: {spec.applicant}

+

終止原因: {reason}

+

詳細資訊請登入系統查看。

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + 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/') +@login_required +def download_initial_word(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + if current_user.role not in ['editor', 'admin']: + flash('權限不足,無法下載 Word 檔案。', 'danger') + abort(403) + + generated_folder = os.path.join(current_app.static_folder, 'generated') + word_path = os.path.join(generated_folder, f"{spec.spec_code}.docx") + + if not os.path.exists(word_path): + flash('找不到最初產生的 Word 檔案,可能已被刪除或移動。', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + return send_file(word_path, as_attachment=True) + +@temp_spec_bp.route('/download_signed/') +@login_required # 補上登入驗證 +def download_signed_pdf(spec_id): + latest_upload = Upload.query.filter_by(temp_spec_id=spec_id).order_by(Upload.upload_time.desc()).first() + + if not latest_upload: + flash('找不到任何已上傳的簽核檔案。', 'danger') + return redirect(url_for('temp_spec.spec_list')) + + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + return send_file(os.path.join(upload_folder, latest_upload.filename), as_attachment=True) + +@temp_spec_bp.route('/extend/', methods=['GET', 'POST']) +@editor_or_admin_required +def extend_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + + if request.method == 'POST': + new_end_date_str = request.form.get('new_end_date') + uploaded_file = request.files.get('new_file') + + if not uploaded_file or uploaded_file.filename == '': + flash('您必須上傳新的佐證檔案才能展延。', 'danger') + return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id)) + + if not new_end_date_str: + flash('請選擇新的結束日期', 'danger') + return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id)) + + spec.end_date = datetime.strptime(new_end_date_str, '%Y-%m-%d').date() + spec.extension_count += 1 + spec.status = 'active' + + filename = secure_filename(f"{spec.spec_code}_extension_{spec.extension_count}_{datetime.now().strftime('%Y%m%d')}.pdf") + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + os.makedirs(upload_folder, exist_ok=True) + file_path = os.path.join(upload_folder, filename) + uploaded_file.save(file_path) + + new_upload = Upload( + temp_spec_id=spec.id, + filename=filename, + upload_time=datetime.now() + ) + db.session.add(new_upload) + + details = f"展延結束日期至 {spec.end_date.strftime('%Y-%m-%d')}" + details += f",並上傳新檔案 '{new_upload.filename}'" + add_history_log(spec.id, '展延', details) + + # --- 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""" + + +

您好,

+

暫時規範 {spec.spec_code} - {spec.title} 已成功展延。

+

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

+

申請人: {spec.applicant}

+

詳細資訊請登入系統查看。

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + 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/') +@login_required # 補上登入驗證 +def spec_history(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + history = SpecHistory.query.filter_by(spec_id=spec_id).order_by(SpecHistory.timestamp.desc()).all() + return render_template('spec_history.html', spec=spec, history=history) + +@temp_spec_bp.route('/delete/', methods=['POST']) +@admin_required +def delete_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + spec_code = spec.spec_code + + files_to_delete = [] + generated_folder = os.path.join(current_app.static_folder, 'generated') + files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx")) + + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + for upload_record in spec.uploads: + files_to_delete.append(os.path.join(upload_folder, upload_record.filename)) + + for f_path in files_to_delete: + try: + if os.path.exists(f_path): + os.remove(f_path) + except Exception as e: + current_app.logger.error(f"刪除檔案失敗: {f_path}, 原因: {e}") + + db.session.delete(spec) + db.session.commit() + + flash(f"規範 '{spec_code}' 及其所有相關檔案已成功刪除。", 'success') + return redirect(url_for('temp_spec.spec_list')) \ No newline at end of file diff --git a/routes/upload.py b/routes/upload.py new file mode 100644 index 0000000..989dc92 --- /dev/null +++ b/routes/upload.py @@ -0,0 +1,29 @@ +from flask import Blueprint, request, jsonify, current_app +from werkzeug.utils import secure_filename +import os +import time + +upload_bp = Blueprint('upload', __name__) + +@upload_bp.route('/image', methods=['POST']) +def upload_image(): + file = request.files.get('file') + if not file: + return jsonify({'error': 'No file part'}), 400 + + # 建立一個獨特的檔名 + extension = os.path.splitext(file.filename)[1] + filename = f"{int(time.time())}_{secure_filename(file.filename)}" + + # 確保上傳資料夾存在 + # 為了讓圖片能被網頁存取,我們將它存在 static 資料夾下 + image_folder = os.path.join(current_app.static_folder, 'uploads', 'images') + os.makedirs(image_folder, exist_ok=True) + + file_path = os.path.join(image_folder, filename) + file.save(file_path) + + # 回傳 TinyMCE 需要的 JSON 格式 + # 路徑必須是相對於網域根目錄的 URL + location = f"/static/uploads/images/{filename}" + return jsonify({'location': location}) diff --git a/start-production.sh b/start-production.sh new file mode 100644 index 0000000..d21e145 --- /dev/null +++ b/start-production.sh @@ -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 "🎉 生產環境啟動完成!" \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b1b3371 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,203 @@ +/* --- 全域與背景設定 --- */ +body { + background-color: #0d1117; + background-image: linear-gradient(180deg, #161b22 0%, #0d1117 100%); + background-attachment: fixed; + color: #c9d1d9; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* --- 強制設定通用元素的文字顏色 --- */ +p, label, th, td, .form-label, .form-check-label, .card-body { + color: #c9d1d9; +} + +/* --- 導覽列 --- */ +.navbar { + background-color: rgba(13, 17, 23, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid #30363d; +} + +/* --- 標題 --- */ +h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { + color: #f0f6fc; +} + +/* --- 卡片與容器 --- */ +.card { + background-color: #161b22; + border: 1px solid #30363d; + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} +.card-header, .card-footer { + background-color: rgba(22, 27, 34, 0.7); + border-bottom: 1px solid #30363d; + color: #f0f6fc; +} + +/* --- 按鈕 --- */ +.btn-primary { + background-color: #5865f2; border-color: #5865f2; color: #ffffff; + transition: all 0.2s ease-in-out; +} +.btn-primary:hover, .btn-primary:focus { + background-color: #4752c4; border-color: #4752c4; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.5); +} +.btn-success { background-color: #2ea043; border-color: #2ea043; color: #fff; } +.btn-success:hover { background-color: #268839; border-color: #268839; color: #fff;} +.btn-danger { background-color: #da3633; border-color: #da3633; color: #fff;} +.btn-danger:hover { background-color: #b92d2b; border-color: #b92d2b; color: #fff;} +.btn-warning { background-color: #f0ad4e; border-color: #f0ad4e; color: #0d1117; } +.btn-warning:hover { background-color: #e39b37; border-color: #e39b37; color: #0d1117;} +.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #0d1117; } +.btn-info:hover { background-color: #0baccc; border-color: #0baccc; color: #0d1117;} + +/* --- 表單輸入框 --- */ +.form-control, .form-select { + background-color: #0d1117; color: #c9d1d9; + border: 1px solid #30363d; border-radius: 0.375rem; +} +.form-control:focus, .form-select:focus { + background-color: #0d1117; color: #c9d1d9; + border-color: #5865f2; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); +} +.form-control::placeholder { color: #8b949e; } +.form-control[readonly] { background-color: #161b22; opacity: 0.7; } +.input-group-text { + background-color: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; +} + +/* --- 表格 --- */ +.table { + --bs-table-color: #c9d1d9; + --bs-table-bg: #161b22; + --bs-table-border-color: #30363d; + --bs-table-striped-color: #c9d1d9; + --bs-table-striped-bg: #21262d; + --bs-table-hover-color: #f0f6fc; + --bs-table-hover-bg: #30363d; + border-color: var(--bs-table-border-color); +} +.table > thead { + color: #f0f6fc; +} + +/* --- 分頁 --- */ +.pagination { + --bs-pagination-color: #58a6ff; + --bs-pagination-bg: #0d1117; /* 最深的背景色,使其與容器分離 */ + --bs-pagination-border-color: #30363d; + --bs-pagination-hover-color: #80b6ff; + --bs-pagination-hover-bg: #21262d; /* 懸停時變亮 */ + --bs-pagination-hover-border-color: #4d555e; + --bs-pagination-focus-color: #80b6ff; + --bs-pagination-focus-bg: #21262d; + --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: #5865f2; + --bs-pagination-active-border-color: #5865f2; + --bs-pagination-disabled-color: #8b949e; + --bs-pagination-disabled-bg: #161b22; /* 禁用的背景色,使其看起來凹陷 */ + --bs-pagination-disabled-border-color: #30363d; +} + +/* --- 提示訊息 (Alerts) --- */ +.alert { border-width: 1px; border-style: solid; } +.alert-danger { background-color: rgba(218, 54, 51, 0.15); border-color: #da3633; color: #ff8986; } +.alert-success { background-color: rgba(46, 160, 67, 0.15); border-color: #2ea043; color: #7ce38f; } +.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #6be2fa; } +.alert-warning { background-color: rgba(240, 173, 78, 0.15); border-color: #f0ad4e; color: #f0ad4e; } + +/* --- 狀態標籤 (Badges) --- */ +.badge { --bs-badge-font-size: 0.8em; --bs-badge-padding-y: 0.4em; --bs-badge-padding-x: 0.7em; } +.bg-success { background-color: #2ea043 !important; } +.bg-info { background-color: #0dcaf0 !important; color: #0d1117 !important; } +.bg-warning { background-color: #f0ad4e !important; color: #0d1117 !important; } +.bg-secondary { background-color: #8b949e !important; } + +/* --- 連結 --- */ +a { color: #58a6ff; } +a:hover { color: #80b6ff; } + +/* 頁面切換的淡入效果 */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +main.container { animation: fadeIn 0.5s ease-in-out; } + +/* --- 通知 (Toast) --- */ +.toast { + background-color: #21262d; /* 使用比卡片稍亮的深色背景 */ + border: 1px solid #30363d; + color: #c9d1d9; +} + +.toast-header { + background-color: #161b22; /* 使用與卡片相同的深色背景 */ + color: #f0f6fc; /* 標題使用較亮的白色文字 */ + border-bottom: 1px solid #30363d; +} + +.toast-body { + color: #c9d1d9; /* 內文使用標準的灰色文字 */ +} + +/* 讓關閉按鈕在深色背景下可見 */ +.btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* --- 列表群組 (List Group for History Page) --- */ +.list-group-flush .list-group-item { + background-color: transparent; /* 在 card 中使用透明背景 */ + border-color: #30363d; +} + +.list-group-item { + background-color: #161b22; + border-color: #30363d; +} + +/* 確保列表內的文字顏色正確 */ +.list-group-item, +.list-group-item p, +.list-group-item small { + color: #c9d1d9; /* 標準灰色文字 */ +} + +/* 讓使用者名稱等重要文字更亮 */ +.list-group-item h5 strong { + color: #f0f6fc; +} + +/* --- 剩餘天數標籤 (Days Remaining Badge) --- */ +.days-badge { + padding: 0.3em 0.6em; + border-radius: 0.375rem; + font-weight: 500; + font-size: 0.85em; + color: #0d1117; /* 預設使用深色文字 */ +} + +.days-safe { + background-color: #2ea043; /* 綠色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-warning { + background-color: #f0ad4e; /* 黃色 */ +} + +.days-critical { + background-color: #da3633; /* 紅色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-expired { + background-color: #8b949e; /* 灰色 */ + color: #ffffff; +} \ No newline at end of file diff --git a/static/generated/PE1140901.docx b/static/generated/PE1140901.docx new file mode 100644 index 0000000..84cab68 Binary files /dev/null and b/static/generated/PE1140901.docx differ diff --git a/static/generated/PE1140902.docx b/static/generated/PE1140902.docx new file mode 100644 index 0000000..46f4015 Binary files /dev/null and b/static/generated/PE1140902.docx differ diff --git a/static/generated/PE1140903.docx b/static/generated/PE1140903.docx new file mode 100644 index 0000000..16ba89c Binary files /dev/null and b/static/generated/PE1140903.docx differ diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..75e8854 --- /dev/null +++ b/tasks.py @@ -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""" + + +

您好,

+

此為自動提醒郵件。

+

暫時規範 {spec.spec_code} - {spec.title} 即將到期。

+

結束日期: {spec.end_date.strftime('%Y-%m-%d')} (剩餘 {remaining_days} 天)

+

申請人: {spec.applicant}

+

請及時處理,如需展延請登入系統操作。

+

此為系統自動發送的通知郵件,請勿直接回覆。

+ + + """ + + # 發送郵件給預設群組 + send_email(default_recipients, subject, body) + print(f"Sent expiry reminder for spec {spec.spec_code} to {len(default_recipients)} recipients.") \ No newline at end of file diff --git a/template_with_placeholders.docx b/template_with_placeholders.docx new file mode 100644 index 0000000..7bef1e3 Binary files /dev/null and b/template_with_placeholders.docx differ diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..87e6be7 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}權限不足{% endblock %} + +{% block content %} +
+

403

+

權限不足 (Forbidden)

+

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

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

404

+

找不到頁面 (Not Found)

+

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

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

上傳簽核檔案以啟用規範

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

主題: {{ spec.title }}

+
+ 請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。 +
+
+ + +
+ + +
+ + + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d4ab161 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,98 @@ + + + + + + {% block title %}暫時規範系統{% endblock %} + + + + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/create_temp_spec_form.html b/templates/create_temp_spec_form.html new file mode 100644 index 0000000..abce4d0 --- /dev/null +++ b/templates/create_temp_spec_form.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}建立新的暫時規範{% endblock %} + +{% block content %} +
+
+

建立新的暫時規範

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

展延暫時規範

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

主題: {{ spec.title }}

+

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

+ +
+ + +
預設為原結束日期後一個月。
+
+ +
+ + +
請上傳展延申請的相關佐證文件 (PDF 格式)。
+
+ + +
+ + {% if saved_emails %} +
+ 以下為生效時設定的通知對象,您可以直接使用或進行編輯。如果修改,展延後將更新為新的通知對象。 +
+ {% endif %} + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..c21ecb6 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,41 @@ +{% extends "base.html" %} + +{% block title %}登入 - 暫時規範系統{% endblock %} + +{% block content %} +
+
+

登入

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
請輸入完整的 AD 帳號格式 (包含 @domain)
+
+
+ + +
+
+ +
+
+
+
+
+{% endblock %} diff --git a/templates/onlyoffice_editor.html b/templates/onlyoffice_editor.html new file mode 100644 index 0000000..510c9ba --- /dev/null +++ b/templates/onlyoffice_editor.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

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

+

主題: {{ spec.title }}

+
+ 返回總表 +
+ +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/spec_history.html b/templates/spec_history.html new file mode 100644 index 0000000..b37ccd6 --- /dev/null +++ b/templates/spec_history.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

操作歷史紀錄

+

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

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

    {{ entry.details }}

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

暫時規範總表

+ {% if current_user.role in ['editor', 'admin'] %} + 建立新規範 + {% endif %} +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + {% for spec in specs %} + + + + + + + + + + + + + + {% endfor %} + +
編號主題申請者建立日期結束日期剩餘天數狀態操作
{{ spec.spec_code }}{{ spec.title }}{{ spec.applicant }}{{ spec.created_at.strftime('%Y-%m-%d') }}{{ spec.end_date.strftime('%Y-%m-%d') }} + {% if spec.status in ['active', 'expired'] %} + {% set remaining_days = (spec.end_date - today).days %} + {% if remaining_days < 0 %} + {% set color_class = 'days-expired' %} + {% elif remaining_days <= 3 %} + {% set color_class = 'days-critical' %} + {% elif remaining_days <= 7 %} + {% set color_class = 'days-warning' %} + {% else %} + {% set color_class = 'days-safe' %} + {% endif %} + + {{ remaining_days if remaining_days >= 0 else '已過期' }} + + {% else %} + - + {% endif %} + + {% if spec.status == 'active' %} + 已生效 + {% elif spec.status == 'pending_approval' %} + 待生效 + {% elif spec.status == 'terminated' %} + 已終止 + {% else %} + 已過期 + {% endif %} + + {% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %} + + {% endif %} + + {% if current_user.role == 'admin' and spec.status == 'pending_approval' %} + + {% endif %} + + {% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %} + + + {% endif %} + + {% if current_user.role == 'admin' %} +
+ +
+ {% endif %} + + {% if spec.status == 'pending_approval' %} + {% if current_user.role in ['editor', 'admin'] %} + + {% endif %} + {% elif spec.uploads %} + + {% endif %} + +
+
+
+ + +
+{% endblock %} \ No newline at end of file diff --git a/templates/terminate_spec.html b/templates/terminate_spec.html new file mode 100644 index 0000000..1bfbb74 --- /dev/null +++ b/templates/terminate_spec.html @@ -0,0 +1,110 @@ +{% extends "base.html" %} + +{% block title %}提早結束暫時規範{% endblock %} + +{% block content %} +

提早結束暫時規範

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

主題: {{ spec.title }}

+
+ 執行此操作將會立即終止這份暫時規範,狀態將變為「已終止」,結束日期會更新為今天。 +
+
+ + +
+ + +
+ + {% if saved_emails %} +
+ 以下為生效時設定的通知對象,您可以直接使用或進行編輯。 +
+ {% endif %} + +
可搜尋姓名或 Email 地址,支援多人選擇
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100644 index 0000000..78a032c --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,234 @@ +{% extends "base.html" %} + +{% block title %}權限管理{% endblock %} + +{% block content %} +

權限管理

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} +{% endwith %} + + +
+
+ 設定管理員權限 +
+
+
+
+ + +
輸入需要設定為管理員的 AD 帳號
+
+
+ +
+
+
+
+ + +
+
+ 現有使用者權限管理 +
+
+ {% if users %} +
+ + + + + + + + + + + + + {% for user in users %} + + + + + + + + + {% endfor %} + +
IDAD 帳號目前權限上次登入權限管理操作
{{ user.id }} + {{ user.username }} + {% if user.id == current_user.id %} + 目前使用者 + {% endif %} + + + {% if user.role == 'admin' %} + 管理員 + {% elif user.role == 'editor' %} + 編輯者 + {% else %} + 檢視者 + {% endif %} + + + {% if user.last_login %} + {{ user.last_login.strftime('%Y-%m-%d %H:%M') }} + {% else %} + 從未登入 + {% endif %} + +
+
+ + +
+
+
+ {% if user.id != current_user.id %} +
+ +
+ {% else %} + + {% endif %} +
+
+ {% else %} +
+ + 目前沒有任何使用者記錄。使用者會在首次透過 AD 登入時自動建立。 +
+ {% endif %} +
+
+ + +
+
+ 權限等級說明 +
+
+
+
+
檢視者 (Viewer)
+
    +
  • 登入系統
  • +
  • 檢視規範列表
  • +
  • 下載已生效的 PDF 文件
  • +
  • 查看歷史記錄
  • +
+
+
+
編輯者 (Editor)
+
    +
  • 包含 Viewer 所有權限
  • +
  • 建立新的暫時規範
  • +
  • 編輯規範內容
  • +
  • 展延規範
  • +
  • 終止規範
  • +
  • 下載 Word 編輯檔案
  • +
+
+
+
管理員 (Admin)
+
    +
  • 包含 Editor 所有權限
  • +
  • 啟用待生效的規範
  • +
  • 管理使用者權限
  • +
  • 刪除規範
  • +
  • 系統設定管理
  • +
+
+
+
+
+ + +
+
+ LDAP/AD 整合說明 +
+
+

系統運作方式:

+
    +
  1. 使用者首次使用 AD 帳號登入時,系統會自動建立本地使用者記錄,預設權限為 viewer
  2. +
  3. 管理員可以在此頁面調整使用者權限等級
  4. +
  5. 使用者的身份認證完全由 Active Directory 處理,本系統不儲存密碼
  6. +
  7. 刪除本地使用者記錄不會影響 AD 帳號,使用者仍可重新登入(但會重置為 viewer 權限)
  8. +
+ +
+ + 重要提醒:確保至少保留一個管理員帳號,避免無法進行權限管理。 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/update_admin.py b/update_admin.py new file mode 100644 index 0000000..420daff --- /dev/null +++ b/update_admin.py @@ -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請先使用該帳號登入一次以建立使用者記錄") \ No newline at end of file diff --git a/utils.py b/utils.py new file mode 100644 index 0000000..fd6c234 --- /dev/null +++ b/utils.py @@ -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 diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d411e05 --- /dev/null +++ b/wsgi.py @@ -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)") \ No newline at end of file