REMOVE LDAP
This commit is contained in:
67
.dockerignore
Normal file
67
.dockerignore
Normal file
@@ -0,0 +1,67 @@
|
||||
# Version control
|
||||
.git
|
||||
.gitignore
|
||||
|
||||
# Python cache
|
||||
__pycache__
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
.Python
|
||||
pip-log.txt
|
||||
|
||||
# Virtual environment
|
||||
venv/
|
||||
env/
|
||||
.venv/
|
||||
.env/
|
||||
|
||||
# IDE
|
||||
.vscode/
|
||||
.idea/
|
||||
*.swp
|
||||
*.swo
|
||||
*~
|
||||
|
||||
# OS generated files
|
||||
.DS_Store
|
||||
.DS_Store?
|
||||
._*
|
||||
.Spotlight-V100
|
||||
.Trashes
|
||||
ehthumbs.db
|
||||
Thumbs.db
|
||||
|
||||
# Logs
|
||||
logs/
|
||||
*.log
|
||||
|
||||
# Runtime data
|
||||
uploads/*
|
||||
!uploads/.gitkeep
|
||||
static/generated/*
|
||||
!static/generated/.gitkeep
|
||||
|
||||
# Docker
|
||||
Dockerfile
|
||||
docker-compose*.yml
|
||||
.dockerignore
|
||||
|
||||
# Documentation
|
||||
*.md
|
||||
docs/
|
||||
|
||||
# Test files
|
||||
tests/
|
||||
test_*
|
||||
*_test.py
|
||||
|
||||
# Build artifacts
|
||||
dist/
|
||||
build/
|
||||
*.egg-info/
|
||||
|
||||
# Database
|
||||
*.db
|
||||
*.sqlite
|
||||
*.sqlite3
|
29
.env
Normal file
29
.env
Normal file
@@ -0,0 +1,29 @@
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=933f9064329f29e642b20089e6ee16b3dd15da6acb6fdd98
|
||||
|
||||
# Database
|
||||
DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060
|
||||
|
||||
# ONLYOFFICE
|
||||
ONLYOFFICE_URL=http://localhost:12015/
|
||||
ONLYOFFICE_INTERNAL_URL=http://onlyoffice:80
|
||||
ONLYOFFICE_JWT_SECRET=933f9064330f29e642b20089e6ee16b3dd15da6acb6fdd98
|
||||
|
||||
# Redis / CDN
|
||||
REDIS_URL=redis://redis:6379/0
|
||||
CDN_DOMAIN=
|
||||
|
||||
# Notification defaults (semicolon-separated list, optional)
|
||||
DEFAULT_NOTIFICATION_EMAILS=
|
||||
|
||||
# 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=
|
||||
|
||||
# Try using a working Python base image from existing
|
||||
PY_BASE=python:3.10-slim
|
33
.env.example
Normal file
33
.env.example
Normal file
@@ -0,0 +1,33 @@
|
||||
# Temp Spec Management System V4 - Environment Configuration
|
||||
# Copy this file to .env and update the values
|
||||
|
||||
# Flask Configuration
|
||||
FLASK_ENV=production
|
||||
SECRET_KEY=your-secret-key-here
|
||||
|
||||
# Database Configuration
|
||||
DATABASE_URL=mysql+pymysql://A060:WLeSCi0yhtc7@mysql.theaken.com:33306/db_A060?charset=utf8mb4
|
||||
|
||||
# CDN Configuration (optional)
|
||||
CDN_DOMAIN=
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_SERVER=mail.panjit.com.tw
|
||||
SMTP_PORT=25
|
||||
SMTP_USE_TLS=false
|
||||
SMTP_USE_SSL=false
|
||||
SMTP_SENDER_EMAIL=temp-spec-system@panjit.com.tw
|
||||
SMTP_SENDER_PASSWORD=
|
||||
|
||||
# OnlyOffice Configuration
|
||||
ONLYOFFICE_PORT=12015
|
||||
ONLYOFFICE_JWT_SECRET=your_jwt_secret_key_here
|
||||
|
||||
# Default Email Recipients (semicolon separated)
|
||||
DEFAULT_NOTIFICATION_EMAILS=
|
||||
|
||||
# Docker Build Configuration
|
||||
# If you encounter Docker Hub rate limiting, uncomment and modify:
|
||||
# PY_BASE=mirror.gcr.io/library/python:3.10-slim
|
||||
# or use an alternative registry like:
|
||||
# PY_BASE=registry.cn-hangzhou.aliyuncs.com/acs/python:3.10-slim
|
56
.gitignore
vendored
Normal file
56
.gitignore
vendored
Normal file
@@ -0,0 +1,56 @@
|
||||
# --- 敏感資訊 (Sensitive Information) ---
|
||||
# 忽略包含所有密鑰和資料庫連線資訊的環境變數檔案。
|
||||
|
||||
# --- Python 相關 (Python Related) ---
|
||||
# 忽略虛擬環境目錄。
|
||||
.venv/
|
||||
venv/
|
||||
|
||||
# 忽略 Python 的位元組碼和快取檔案。
|
||||
__pycache__/
|
||||
*.pyc
|
||||
*.pyo
|
||||
*.pyd
|
||||
|
||||
# --- 使用者上傳與系統產生的檔案 (User Uploads & Generated Files) ---
|
||||
# 忽略上傳的已簽核文件 (PDFs)。
|
||||
/uploads/
|
||||
static/generated/
|
||||
|
||||
# 忽略系統自動產生的暫時規範文件 (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
|
||||
|
||||
static/generated/PE1140901.docx
|
93
DEPLOYMENT.md
Normal file
93
DEPLOYMENT.md
Normal file
@@ -0,0 +1,93 @@
|
||||
# Deployment Guide — Temp Spec Management System V4
|
||||
|
||||
This guide describes how to deploy the system with Docker Compose and lists the configuration changes introduced in V4.
|
||||
|
||||
## Release Highlights
|
||||
- Local MySQL account store with self‑registration and admin CRUD tools.
|
||||
- Notification inputs accept semicolon‑separated email lists stored in the database.
|
||||
- New `DEFAULT_NOTIFICATION_EMAILS` environment variable for scheduled reminders.
|
||||
- Database tables renamed with `tst_` prefix; `User` adds `name`; passwords are hashed.
|
||||
- LDAP dependencies removed from code and configuration.
|
||||
|
||||
## Table of Contents
|
||||
1. Environment requirements
|
||||
2. Deployment steps
|
||||
3. Important environment variables
|
||||
4. Upgrade considerations
|
||||
5. Operations checklist
|
||||
6. Troubleshooting
|
||||
|
||||
## 1) Environment Requirements
|
||||
- Docker 20.10 or newer
|
||||
- Docker Compose 2.0 or newer
|
||||
- Reachable MySQL 8.0 (or equivalent) database
|
||||
- SMTP server (ports 25, 465, or 587)
|
||||
- At least 10 GB free disk space
|
||||
|
||||
Default exposed ports:
|
||||
- 12010 — Flask web service
|
||||
- 12011 — ONLYOFFICE Document Server
|
||||
- 12012 — Redis (restrict if not needed externally)
|
||||
- 12013 — Nginx reverse proxy (if enabled)
|
||||
|
||||
## 2) Deployment Steps
|
||||
1. Clone the repository
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd TEMP_spec_system_noad
|
||||
```
|
||||
2. Configure environment variables
|
||||
Edit the `.env` in the project root and set database, SMTP, ONLYOFFICE, and optional notification values.
|
||||
3. Review `.env` values
|
||||
- `DATABASE_URL` e.g. `mysql+pymysql://user:pass@host:port/dbname`
|
||||
- `DEFAULT_NOTIFICATION_EMAILS` optional fallback recipients (semicolon‑separated)
|
||||
- SMTP settings (server, port, TLS/SSL toggle, credentials)
|
||||
- ONLYOFFICE URLs and JWT secret (if the service runs elsewhere)
|
||||
4. Start the stack
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
5. Initialize the database (destructive — drops and recreates tables)
|
||||
```bash
|
||||
docker-compose exec app python init_db.py
|
||||
```
|
||||
6. Sign in
|
||||
Use the seeded `egg / 123` account (name: 念萱, role: Viewer), then promote an account to Admin and create additional users.
|
||||
|
||||
Optional: if Docker Hub rate limits or requires auth on your host, set a mirror for the Python base image before building. For example:
|
||||
```bash
|
||||
set PY_BASE=mirror.gcr.io/library/python:3.10-slim # Windows PowerShell
|
||||
docker-compose up -d --build
|
||||
```
|
||||
|
||||
## 3) Important Environment Variables
|
||||
| Variable | Description |
|
||||
|----------|-------------|
|
||||
| `DATABASE_URL` | SQLAlchemy connection string |
|
||||
| `DEFAULT_NOTIFICATION_EMAILS` | Optional default recipients for scheduled reminders |
|
||||
| `SMTP_*` | Mail server configuration |
|
||||
| `ONLYOFFICE_URL` / `ONLYOFFICE_INTERNAL_URL` | Document server endpoints |
|
||||
| `ONLYOFFICE_JWT_SECRET` | JWT shared secret for document editing |
|
||||
| `SECRET_KEY` | Flask secret key |
|
||||
| `REDIS_URL` | Redis connection string used by caching and scheduling |
|
||||
|
||||
## 4) Upgrade Considerations
|
||||
1. `init_db.py` truncates data; replace with migrations in production environments.
|
||||
2. Migrating from LDAP requires importing user records into `tst_user`, supplying `name`, and setting passwords.
|
||||
3. Replace any old LDAP‑driven notification lists with explicit email addresses.
|
||||
4. Remove legacy `LDAP_*` variables from deployment manifests and set `DEFAULT_NOTIFICATION_EMAILS` if needed.
|
||||
|
||||
## 5) Operations Checklist
|
||||
- Verify APScheduler jobs run successfully (check logs for `Running scheduled task`).
|
||||
- Back up the MySQL database and the `uploads/` and `static/generated/` directories.
|
||||
- Monitor CPU, memory, disk usage, and container health within existing monitoring tools.
|
||||
- Enforce HTTPS via Nginx, apply strong password policies, and restrict Redis/ONLYOFFICE exposure.
|
||||
|
||||
## 6) Troubleshooting
|
||||
| Issue | Possible cause | Suggested action |
|
||||
|-------|----------------|------------------|
|
||||
| Docker build 401 on base image | Registry rate limit or auth needed | Run `docker login` in Docker Desktop/CLI; retry later due to rate limiting; or set `PY_BASE=mirror.gcr.io/library/python:3.10-slim` and rebuild |
|
||||
| Cannot log in | Bad credentials or disabled account | Reset the password via the admin console |
|
||||
| Emails not delivered | Wrong SMTP settings or recipients | Review `.env` values and mail server logs |
|
||||
| Scheduler not running | Redis or APScheduler misconfigured | Inspect container logs and Redis connectivity |
|
||||
| ONLYOFFICE fails to load | Document server unavailable | Confirm the container is healthy and URLs are correct |
|
49
Dockerfile
Normal file
49
Dockerfile
Normal file
@@ -0,0 +1,49 @@
|
||||
# 使用官方 Python 3.10 運行時作為基礎映像
|
||||
# 可通過 build args 覆蓋,例如: --build-arg PY_BASE=python:3.10-slim
|
||||
ARG PY_BASE=python:3.10-slim
|
||||
FROM ${PY_BASE}
|
||||
|
||||
# 設定環境變數
|
||||
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"]
|
||||
|
17
Dockerfile.redis
Normal file
17
Dockerfile.redis
Normal file
@@ -0,0 +1,17 @@
|
||||
# Redis for PANJIT Temp Spec System
|
||||
FROM redis:7-alpine
|
||||
|
||||
# Set container labels for identification
|
||||
LABEL application="panjit-temp-spec-system"
|
||||
LABEL component="redis"
|
||||
LABEL version="v4.0"
|
||||
LABEL maintainer="PANJIT IT Team"
|
||||
|
||||
# Copy custom redis configuration if needed
|
||||
# COPY redis.conf /usr/local/etc/redis/redis.conf
|
||||
|
||||
# Expose the default Redis port
|
||||
EXPOSE 6379
|
||||
|
||||
# Use the default Redis entrypoint
|
||||
# CMD ["redis-server", "/usr/local/etc/redis/redis.conf"]
|
89
README.md
Normal file
89
README.md
Normal file
@@ -0,0 +1,89 @@
|
||||
# Temp Spec Management System V4
|
||||
|
||||
This project manages the lifecycle of temporary specifications: drafting, approval, extension, termination, and document retention. The application now uses MySQL accounts (no LDAP) and requires users to input explicit email lists for notifications.
|
||||
|
||||
## Key Features
|
||||
- Local account management with self‑registration and full admin CRUD tools.
|
||||
- Online document editing through ONLYOFFICE with version history.
|
||||
- Flexible email notifications using semicolon‑separated address lists.
|
||||
- Scheduled reminders for specs expiring in 3 or 7 days.
|
||||
- Asia/Taipei time handling and a two‑extension limit per spec.
|
||||
- Docker‑based deployment with Redis and optional Nginx reverse proxy.
|
||||
|
||||
## System Architecture
|
||||
```
|
||||
Temp Spec System V4
|
||||
• Web UI: Flask + Bootstrap 5
|
||||
• Business Logic: Flask + SQLAlchemy
|
||||
• Accounts: MySQL (tst_ tables)
|
||||
• Document Editing: ONLYOFFICE
|
||||
• Cache/Scheduler: Redis + APScheduler
|
||||
• Storage: uploads/ and static/generated/
|
||||
• Email: SMTP
|
||||
• Reverse Proxy: Nginx
|
||||
```
|
||||
|
||||
## Quick Start (Docker)
|
||||
1. Clone the repository
|
||||
```bash
|
||||
git clone <repository-url>
|
||||
cd TEMP_spec_system_noad
|
||||
```
|
||||
2. Configure environment variables
|
||||
Edit the `.env` file in the project root and set database, ONLYOFFICE, SMTP and optional default notification values.
|
||||
3. Verify `.env` values at minimum
|
||||
- `DATABASE_URL` e.g. `mysql+pymysql://user:pass@host:port/dbname`
|
||||
- `DEFAULT_NOTIFICATION_EMAILS` optional semicolon‑separated default recipients
|
||||
- SMTP settings matching the corporate mail server (port, TLS/SSL, credentials)
|
||||
4. Start the stack
|
||||
```bash
|
||||
docker-compose up -d --build
|
||||
```
|
||||
5. Initialize the database (DROPs and recreates tables)
|
||||
```bash
|
||||
docker-compose exec app python init_db.py
|
||||
```
|
||||
6. Visit the application
|
||||
- Web UI: `http://localhost:12010`
|
||||
- ONLYOFFICE: `http://localhost:12011`
|
||||
|
||||
## Default Accounts and Roles
|
||||
- The initialization script creates `egg / 123 / 念萱` with the Viewer role.
|
||||
- Self‑registration and admin‑created accounts must use passwords with at least six characters.
|
||||
- Administrators cannot delete the last Admin or demote themselves.
|
||||
|
||||
## Email Notification Rules
|
||||
- Enter full addresses separated by semicolons, e.g. `mail1@company.com; mail2@company.com`.
|
||||
- When no addresses are supplied the scheduler will use `DEFAULT_NOTIFICATION_EMAILS` from `.env`.
|
||||
- Ports 25, 465 and 587 are supported; enable TLS by setting `SMTP_USE_TLS=true` or SSL with `SMTP_USE_SSL=true`.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
### Docker Hub Rate Limiting (401 Unauthorized)
|
||||
If you encounter "401 Unauthorized" errors when building:
|
||||
|
||||
1. **Login to Docker Hub** (recommended):
|
||||
```bash
|
||||
docker login
|
||||
```
|
||||
|
||||
2. **Use alternative registry** by setting in `.env`:
|
||||
```bash
|
||||
# Google Container Registry Mirror
|
||||
PY_BASE=mirror.gcr.io/library/python:3.10-slim
|
||||
|
||||
# Or Alibaba Cloud Registry (for users in China)
|
||||
PY_BASE=registry.cn-hangzhou.aliyuncs.com/acs/python:3.10-slim
|
||||
```
|
||||
|
||||
3. **Wait and retry**: Rate limits reset every 6 hours
|
||||
|
||||
### Other Issues
|
||||
- 500 on `/list` with Jinja date filter: fixed by timezone utils; ensure containers are rebuilt after updates.
|
||||
- OnlyOffice callback 302 redirect: Fixed by excluding callback endpoint from authentication check.
|
||||
|
||||
## Related Documents
|
||||
- `USER_MANUAL.md` — user workflow reference.
|
||||
- `DEPLOYMENT.md` — deployment and maintenance guide.
|
||||
- `docker-compose.yml` and `.env` — deployment configuration.
|
||||
|
80
USER_MANUAL.md
Normal file
80
USER_MANUAL.md
Normal file
@@ -0,0 +1,80 @@
|
||||
# Temp Spec Management System V4 User Manual
|
||||
|
||||
This document summarises the day-to-day tasks for end users and administrators. The system now authenticates with local MySQL accounts and requires explicit email addresses for notifications.
|
||||
|
||||
## Contents
|
||||
1. System overview
|
||||
2. Sign in and registration
|
||||
3. Core workflows
|
||||
4. Notification settings
|
||||
5. Roles and permissions
|
||||
6. Frequently asked questions
|
||||
|
||||
---
|
||||
|
||||
## 1. System overview
|
||||
The platform manages the full lifecycle of temporary specifications: drafting, approval, extension, termination, and archival. ONLYOFFICE is used for online editing and Redis APScheduler handles periodic reminders.
|
||||
|
||||
## 2. Sign in and registration
|
||||
### 2.1 Self registration
|
||||
1. Open the login page and click "Create account".
|
||||
2. Enter an email (recommended), display name, and a password with at least six characters.
|
||||
3. The new account is created with the Viewer role and the user is logged in immediately.
|
||||
|
||||
### 2.2 Admin created accounts
|
||||
Administrators can create accounts from the "Account Management" page, including role selection and an initial password. Users should change the password after the first sign in.
|
||||
|
||||
### 2.3 Password resets
|
||||
There is no automated password reset flow. Administrators can update a password from the management page on request.
|
||||
|
||||
## 3. Core workflows
|
||||
### 3.1 Create a temporary spec
|
||||
1. Editors or admins can open "Create Spec" from the spec list.
|
||||
2. Fill in the required fields and submit the form.
|
||||
3. The system creates a Word template that can be edited through ONLYOFFICE.
|
||||
|
||||
### 3.2 Activate a spec
|
||||
1. Upload the signed PDF.
|
||||
2. Provide a semicolon separated list of email recipients.
|
||||
3. After saving, the spec status becomes `active` and notifications are sent.
|
||||
|
||||
### 3.3 Extend a spec
|
||||
1. Each spec can be extended at most twice (90 days total).
|
||||
2. Upload the supporting PDF and supply a new notification list.
|
||||
3. The history records the extension count and file name.
|
||||
|
||||
### 3.4 Terminate a spec
|
||||
1. Enter a termination reason and submit.
|
||||
2. Notifications are sent and the status becomes `terminated`.
|
||||
|
||||
### 3.5 History view
|
||||
The spec detail page contains a chronological history with the action, actor, timestamp, and notes.
|
||||
|
||||
## 4. Notification settings
|
||||
- Enter complete email addresses separated by semicolons, for example `user1@company.com; user2@company.com`.
|
||||
- If the field is empty the scheduler falls back to the `DEFAULT_NOTIFICATION_EMAILS` value in `.env`.
|
||||
- Review saved lists periodically to avoid sending to outdated addresses.
|
||||
|
||||
## 5. Roles and permissions
|
||||
| Role | Capabilities |
|
||||
|--------|--------------|
|
||||
| Viewer | Sign in, browse specs, download attachments, view history |
|
||||
| Editor | Viewer capabilities plus create/edit specs, extend, terminate, download Word templates |
|
||||
| Admin | Editor capabilities plus approve pending items, manage accounts, delete specs |
|
||||
|
||||
Administrators cannot delete the final Admin account and cannot demote themselves from Admin. Create a second admin before performing role changes.
|
||||
|
||||
## 6. Frequently asked questions
|
||||
**Q1: Why does login fail with an "invalid credentials" message?**
|
||||
Confirm the email format and password casing. Ask an administrator to reset the password if you are locked out.
|
||||
|
||||
**Q2: Emails are not delivered. What should I check?**
|
||||
Validate the recipient list, confirm SMTP configuration, and check corporate spam filters or blacklists.
|
||||
|
||||
**Q3: The spec already reached the maximum number of extensions. What can I do?**
|
||||
Create a new spec with the updated schedule and reference the previous case in the notes section.
|
||||
|
||||
**Q4: How do I verify the scheduled reminder is running?**
|
||||
Review container logs for the string `Running scheduled task` or monitor Redis for job activity.
|
||||
|
||||
For other issues, contact the maintenance team.
|
129
app.py
Normal file
129
app.py
Normal file
@@ -0,0 +1,129 @@
|
||||
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)
|
||||
|
||||
# 註冊自訂模板 filter
|
||||
from utils.timezone import format_taiwan_time
|
||||
|
||||
@app.template_filter('taiwan_time')
|
||||
def taiwan_time_filter(dt, format_str='%Y-%m-%d %H:%M:%S'):
|
||||
"""將 datetime 轉換為台灣時間格式字符串"""
|
||||
return format_taiwan_time(dt, format_str)
|
||||
|
||||
@app.template_filter('taiwan_date')
|
||||
def taiwan_date_filter(dt):
|
||||
"""將 datetime 轉換為台灣日期格式字符串"""
|
||||
return format_taiwan_time(dt, '%Y-%m-%d')
|
||||
|
||||
# 導入任務
|
||||
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
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format='%(asctime)s %(levelname)s %(name)s: %(message)s',
|
||||
handlers=[
|
||||
logging.StreamHandler(sys.stdout)
|
||||
]
|
||||
)
|
||||
|
||||
app.logger.setLevel(logging.INFO)
|
||||
app.logger.addHandler(logging.StreamHandler(sys.stdout))
|
||||
|
||||
print('=== 暫規系統 V4 啟動 ===')
|
||||
print('📘 Log 等級: 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('🔐 提示: 請使用系統註冊帳號登入')
|
||||
print('=' * 50)
|
||||
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
97
cache_utils.py
Normal file
97
cache_utils.py
Normal file
@@ -0,0 +1,97 @@
|
||||
"""
|
||||
快取輔助函數
|
||||
用於提升應用程式效能
|
||||
"""
|
||||
from functools import wraps
|
||||
from flask import request, current_app
|
||||
from app import cache
|
||||
import hashlib
|
||||
import json
|
||||
|
||||
def cache_key(*args, **kwargs):
|
||||
"""生成快取鍵值"""
|
||||
key_data = {
|
||||
'args': args,
|
||||
'kwargs': kwargs,
|
||||
'user_id': getattr(request, 'user_id', 'anonymous'),
|
||||
'path': request.path if hasattr(request, 'path') else ''
|
||||
}
|
||||
key_string = json.dumps(key_data, sort_keys=True, default=str)
|
||||
return hashlib.md5(key_string.encode('utf-8')).hexdigest()
|
||||
|
||||
def cached_route(timeout=300):
|
||||
"""路由快取裝飾器"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_app.config.get('CACHE_TYPE') or current_app.debug:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
key = f"route:{f.__name__}:{cache_key(*args, **kwargs)}"
|
||||
|
||||
# 嘗試從快取獲取
|
||||
cached_result = cache.get(key)
|
||||
if cached_result is not None:
|
||||
current_app.logger.debug(f"快取命中: {key}")
|
||||
return cached_result
|
||||
|
||||
# 執行函數並快取結果
|
||||
result = f(*args, **kwargs)
|
||||
cache.set(key, result, timeout=timeout)
|
||||
current_app.logger.debug(f"快取設定: {key}")
|
||||
|
||||
return result
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def cached_query(timeout=300):
|
||||
"""資料庫查詢快取裝飾器"""
|
||||
def decorator(f):
|
||||
@wraps(f)
|
||||
def decorated_function(*args, **kwargs):
|
||||
if not current_app.config.get('CACHE_TYPE') or current_app.debug:
|
||||
return f(*args, **kwargs)
|
||||
|
||||
key = f"query:{f.__name__}:{cache_key(*args, **kwargs)}"
|
||||
|
||||
# 嘗試從快取獲取
|
||||
cached_result = cache.get(key)
|
||||
if cached_result is not None:
|
||||
current_app.logger.debug(f"查詢快取命中: {key}")
|
||||
return cached_result
|
||||
|
||||
# 執行查詢並快取結果
|
||||
result = f(*args, **kwargs)
|
||||
cache.set(key, result, timeout=timeout)
|
||||
current_app.logger.debug(f"查詢快取設定: {key}")
|
||||
|
||||
return result
|
||||
return decorated_function
|
||||
return decorator
|
||||
|
||||
def invalidate_cache(pattern):
|
||||
"""清除快取"""
|
||||
try:
|
||||
if hasattr(cache, 'delete_many'):
|
||||
# Redis backend
|
||||
keys = cache.cache._read_clients.keys(f"flask_cache_{pattern}*")
|
||||
if keys:
|
||||
cache.delete_many(*keys)
|
||||
current_app.logger.info(f"清除快取: {len(keys)} 個項目")
|
||||
else:
|
||||
# Simple cache backend
|
||||
cache.clear()
|
||||
current_app.logger.info("清除所有快取")
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"清除快取失敗: {e}")
|
||||
|
||||
# 快取統計
|
||||
def cache_stats():
|
||||
"""獲取快取統計資訊"""
|
||||
try:
|
||||
if hasattr(cache.cache, 'info'):
|
||||
return cache.cache.info()
|
||||
else:
|
||||
return {"status": "simple cache", "info": "無統計資訊"}
|
||||
except Exception as e:
|
||||
return {"error": str(e)}
|
66
cdn_utils.py
Normal file
66
cdn_utils.py
Normal file
@@ -0,0 +1,66 @@
|
||||
"""
|
||||
CDN 工具函數
|
||||
用於靜態資源加速
|
||||
"""
|
||||
from flask import current_app, url_for as flask_url_for
|
||||
import os
|
||||
|
||||
def cdn_url_for(endpoint, **values):
|
||||
"""
|
||||
CDN化的 url_for 函數
|
||||
自動將靜態資源指向CDN域名
|
||||
"""
|
||||
if endpoint == 'static':
|
||||
cdn_domain = current_app.config.get('CDN_DOMAIN', '').strip()
|
||||
if cdn_domain:
|
||||
# 確保CDN域名格式正確
|
||||
if not cdn_domain.startswith(('http://', 'https://')):
|
||||
cdn_domain = f"https://{cdn_domain}"
|
||||
|
||||
filename = values.get('filename', '')
|
||||
if filename:
|
||||
# 移除開頭的斜線
|
||||
filename = filename.lstrip('/')
|
||||
return f"{cdn_domain.rstrip('/')}/static/{filename}"
|
||||
|
||||
# 非靜態資源或未配置CDN時使用原始url_for
|
||||
return flask_url_for(endpoint, **values)
|
||||
|
||||
def get_static_url(filename):
|
||||
"""
|
||||
獲取靜態資源URL
|
||||
自動判斷使用CDN還是本地路徑
|
||||
"""
|
||||
return cdn_url_for('static', filename=filename)
|
||||
|
||||
def is_cdn_enabled():
|
||||
"""檢查是否啟用CDN"""
|
||||
return bool(current_app.config.get('CDN_DOMAIN', '').strip())
|
||||
|
||||
class CDNHelper:
|
||||
"""CDN輔助類"""
|
||||
|
||||
def __init__(self, app=None):
|
||||
if app:
|
||||
self.init_app(app)
|
||||
|
||||
def init_app(self, app):
|
||||
"""初始化應用"""
|
||||
app.jinja_env.globals['cdn_url_for'] = cdn_url_for
|
||||
app.jinja_env.globals['get_static_url'] = get_static_url
|
||||
app.jinja_env.globals['is_cdn_enabled'] = is_cdn_enabled
|
||||
|
||||
# 添加模板過濾器
|
||||
app.jinja_env.filters['cdn'] = self._cdn_filter
|
||||
|
||||
def _cdn_filter(self, filename):
|
||||
"""Jinja2過濾器:將靜態檔案路徑轉換為CDN URL"""
|
||||
if filename.startswith('/static/'):
|
||||
filename = filename[8:] # 移除 '/static/' 前綴
|
||||
elif filename.startswith('static/'):
|
||||
filename = filename[7:] # 移除 'static/' 前綴
|
||||
|
||||
return get_static_url(filename)
|
||||
|
||||
# 全局CDN輔助實例
|
||||
cdn_helper = CDNHelper()
|
43
config.py
Normal file
43
config.py
Normal file
@@ -0,0 +1,43 @@
|
||||
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'
|
||||
|
||||
DEFAULT_NOTIFICATION_EMAILS = os.getenv('DEFAULT_NOTIFICATION_EMAILS', '')
|
||||
|
||||
# SMTP Configuration
|
||||
SMTP_SERVER = os.getenv('SMTP_SERVER', 'mail.panjit.com.tw')
|
||||
SMTP_PORT = int(os.getenv('SMTP_PORT', 25))
|
||||
SMTP_USE_TLS = os.getenv('SMTP_USE_TLS', 'false').lower() in ['true', '1', 't']
|
||||
SMTP_USE_SSL = os.getenv('SMTP_USE_SSL', 'false').lower() in ['true', '1', 't']
|
||||
SMTP_SENDER_EMAIL = os.getenv('SMTP_SENDER_EMAIL', 'temp-spec-system@panjit.com.tw')
|
||||
SMTP_SENDER_PASSWORD = os.getenv('SMTP_SENDER_PASSWORD', '') # Port 25 銝?閬?蝣?
|
||||
SMTP_AUTH_REQUIRED = os.getenv('SMTP_AUTH_REQUIRED', 'false').lower() in ['true', '1', 't']
|
61
docker-compose.prod.yml
Normal file
61
docker-compose.prod.yml
Normal file
@@ -0,0 +1,61 @@
|
||||
# 生產環境專用配置
|
||||
# 使用方式: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
|
||||
version: '3.8'
|
||||
|
||||
services:
|
||||
# 在生產環境中擴展 app 服務
|
||||
app:
|
||||
deploy:
|
||||
replicas: 2 # 多個實例提升可用性
|
||||
update_config:
|
||||
parallelism: 1
|
||||
delay: 10s
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
max_attempts: 3
|
||||
restart_policy:
|
||||
condition: on-failure
|
||||
delay: 5s
|
||||
max_attempts: 3
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 2G
|
||||
reservations:
|
||||
cpus: '1.0'
|
||||
memory: 1G
|
||||
|
||||
# 啟用 Nginx 反向代理
|
||||
nginx:
|
||||
profiles: [] # 移除 production profile,使其在生產環境中自動啟動
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# Redis 生產優化
|
||||
redis:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '0.5'
|
||||
memory: 512M
|
||||
reservations:
|
||||
cpus: '0.25'
|
||||
memory: 256M
|
||||
|
||||
# OnlyOffice 資源配置
|
||||
onlyoffice:
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
cpus: '2.0'
|
||||
memory: 4G
|
||||
reservations:
|
||||
cpus: '1.0'
|
||||
memory: 2G
|
156
docker-compose.yml
Normal file
156
docker-compose.yml
Normal file
@@ -0,0 +1,156 @@
|
||||
services:
|
||||
# Redis 敹怠???
|
||||
redis:
|
||||
image: panjit-tempspec:redis
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile.redis
|
||||
container_name: panjit-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.1
|
||||
container_name: panjit-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"
|
||||
# 雿輻?批遣鞈?摨恬?銝?閬???PostgreSQL
|
||||
AMQP_TYPE: "0" # 蝳RabbitMQ隞亦???皞?
|
||||
# ??閮剖?
|
||||
TZ: Asia/Taipei
|
||||
ports:
|
||||
- "${ONLYOFFICE_PORT:-12015}:80"
|
||||
volumes:
|
||||
- onlyoffice_data:/var/www/onlyoffice/Data
|
||||
- onlyoffice_logs:/var/log/onlyoffice
|
||||
deploy:
|
||||
resources:
|
||||
limits:
|
||||
memory: 3G
|
||||
cpus: '2.0'
|
||||
reservations:
|
||||
memory: 1.5G
|
||||
cpus: '1.0'
|
||||
networks:
|
||||
- tempspec-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost/healthcheck"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# Flask ?蝔?
|
||||
app:
|
||||
image: panjit-tempspec:main
|
||||
build:
|
||||
context: .
|
||||
dockerfile: Dockerfile
|
||||
args:
|
||||
# Override this via .env (PY_BASE) if Docker Hub is rate-limiting
|
||||
PY_BASE: ${PY_BASE:-python:3.10-slim}
|
||||
container_name: panjit-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:-}
|
||||
|
||||
|
||||
# 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:12015/
|
||||
ONLYOFFICE_INTERNAL_URL: http://onlyoffice:80
|
||||
ONLYOFFICE_JWT_SECRET: ${ONLYOFFICE_JWT_SECRET:-your_jwt_secret_key_here}
|
||||
|
||||
# ??閮剖?
|
||||
TZ: Asia/Taipei
|
||||
|
||||
# ?嗡?閮剖?
|
||||
UPLOAD_FOLDER: uploads
|
||||
# No external port; only Nginx exposes ports
|
||||
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_started
|
||||
networks:
|
||||
- tempspec-network
|
||||
healthcheck:
|
||||
test: ["CMD", "curl", "-f", "http://localhost:5000/"]
|
||||
interval: 30s
|
||||
timeout: 10s
|
||||
retries: 5
|
||||
|
||||
# Nginx ??隞?? (??啣??芸??)
|
||||
nginx:
|
||||
image: panjit-tempspec:nginx
|
||||
build:
|
||||
context: ./nginx
|
||||
dockerfile: Dockerfile
|
||||
container_name: panjit-tempspec-nginx
|
||||
restart: unless-stopped
|
||||
ports:
|
||||
- "12013:80"
|
||||
volumes:
|
||||
- ./nginx/nginx.conf:/etc/nginx/nginx.conf:ro
|
||||
- ./nginx/conf.d:/etc/nginx/conf.d:ro
|
||||
- ./nginx/ssl:/etc/nginx/ssl:ro
|
||||
depends_on:
|
||||
- app
|
||||
networks:
|
||||
- tempspec-network
|
||||
|
||||
volumes:
|
||||
redis_data:
|
||||
driver: local
|
||||
onlyoffice_data:
|
||||
driver: local
|
||||
onlyoffice_logs:
|
||||
driver: local
|
||||
|
||||
networks:
|
||||
tempspec-network:
|
||||
driver: bridge
|
||||
|
53
gunicorn.conf.py
Normal file
53
gunicorn.conf.py
Normal file
@@ -0,0 +1,53 @@
|
||||
# Gunicorn 生產環境配置
|
||||
import multiprocessing
|
||||
import os
|
||||
|
||||
# 服務器設置
|
||||
bind = "0.0.0.0:5000"
|
||||
workers = min(multiprocessing.cpu_count() * 2 + 1, 8) # 最多8個worker
|
||||
worker_class = "sync"
|
||||
worker_connections = 1000
|
||||
max_requests = 1000
|
||||
max_requests_jitter = 50
|
||||
|
||||
# 超時設置
|
||||
timeout = 300
|
||||
keepalive = 5
|
||||
graceful_timeout = 300
|
||||
|
||||
# 日誌設置
|
||||
accesslog = "-" # stdout
|
||||
errorlog = "-" # stderr
|
||||
loglevel = "info"
|
||||
access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s'
|
||||
|
||||
# 進程設置
|
||||
preload_app = True
|
||||
daemon = False
|
||||
pidfile = "/tmp/gunicorn.pid"
|
||||
|
||||
# 性能調優
|
||||
worker_tmp_dir = "/dev/shm" # 使用內存作為臨時目錄
|
||||
|
||||
# 安全設置
|
||||
limit_request_line = 8190
|
||||
limit_request_fields = 100
|
||||
limit_request_field_size = 8190
|
||||
|
||||
def when_ready(server):
|
||||
server.log.info("Server is ready. Spawning workers")
|
||||
|
||||
def worker_int(worker):
|
||||
worker.log.info("worker received INT or QUIT signal")
|
||||
|
||||
def pre_fork(server, worker):
|
||||
server.log.info("Worker spawned (pid: %s)", worker.pid)
|
||||
|
||||
def post_fork(server, worker):
|
||||
server.log.info("Worker spawned (pid: %s)", worker.pid)
|
||||
|
||||
def post_worker_init(worker):
|
||||
worker.log.info("Worker initialized (pid: %s)", worker.pid)
|
||||
|
||||
def worker_abort(worker):
|
||||
worker.log.info("Worker aborted (pid: %s)", worker.pid)
|
53
init_db.py
Normal file
53
init_db.py
Normal file
@@ -0,0 +1,53 @@
|
||||
from flask import Flask
|
||||
from models import db, User
|
||||
from config import Config
|
||||
|
||||
|
||||
def seed_default_user() -> None:
|
||||
"""Create the default local account if it does not exist.
|
||||
|
||||
Requirement: seed egg/123/念萱 with default role Viewer.
|
||||
"""
|
||||
default_username = "egg"
|
||||
default_password = "123"
|
||||
default_name = "念萱"
|
||||
|
||||
existing = User.query.filter_by(username=default_username).first()
|
||||
if existing:
|
||||
print(f" - Default user '{default_username}' already exists. Skipping.")
|
||||
return
|
||||
|
||||
user = User(username=default_username, name=default_name, role="viewer")
|
||||
user.set_password(default_password)
|
||||
db.session.add(user)
|
||||
db.session.commit()
|
||||
print(f" - Seeded default user {default_username}/{default_password}/{default_name} (role=viewer)")
|
||||
|
||||
|
||||
def init_database(app: Flask) -> None:
|
||||
"""Reset schema and seed initial data (DANGEROUS: drops all tables)."""
|
||||
with app.app_context():
|
||||
print("Initializing database: dropping and recreating tables...")
|
||||
db.drop_all()
|
||||
db.create_all()
|
||||
seed_default_user()
|
||||
print("Database initialized successfully.")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = Flask(__name__)
|
||||
app.config.from_object(Config)
|
||||
db.init_app(app)
|
||||
|
||||
print("=================================================")
|
||||
print(" Database Initialization Utility ")
|
||||
print("=================================================")
|
||||
print("WARNING: This will DROP and RECREATE all tables in the target database.")
|
||||
print("Ensure you have backups before proceeding.")
|
||||
|
||||
confirmation = input("Type 'yes' to continue (yes/no): ")
|
||||
if confirmation.strip().lower() == "yes":
|
||||
init_database(app)
|
||||
else:
|
||||
print("Aborted.")
|
||||
|
69
models.py
Normal file
69
models.py
Normal file
@@ -0,0 +1,69 @@
|
||||
from flask_sqlalchemy import SQLAlchemy
|
||||
from flask_login import UserMixin
|
||||
from datetime import datetime
|
||||
from utils.timezone import taiwan_now
|
||||
from werkzeug.security import generate_password_hash, check_password_hash
|
||||
|
||||
db = SQLAlchemy()
|
||||
|
||||
class User(db.Model, UserMixin):
|
||||
# 修改 table name
|
||||
__tablename__ = 'tst_user'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
username = db.Column(db.String(120), unique=True, nullable=False)
|
||||
password_hash = db.Column(db.String(255), nullable=False)
|
||||
name = db.Column(db.String(100), nullable=False)
|
||||
role = db.Column(db.Enum('viewer', 'editor', 'admin'), nullable=False, default='viewer')
|
||||
last_login = db.Column(db.DateTime)
|
||||
|
||||
def set_password(self, password: str) -> None:
|
||||
self.password_hash = generate_password_hash(password)
|
||||
|
||||
def check_password(self, password: str) -> bool:
|
||||
return check_password_hash(self.password_hash, password)
|
||||
|
||||
class TempSpec(db.Model):
|
||||
# 新增並設定 table name
|
||||
__tablename__ = 'tst_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__ = 'tst_upload'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
# 注意:這裡的 ForeignKey 也要更新為新的 table name
|
||||
temp_spec_id = db.Column(db.Integer, db.ForeignKey('tst_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__ = 'tst_spec_history'
|
||||
id = db.Column(db.Integer, primary_key=True)
|
||||
# 注意:這裡的 ForeignKey 也要更新為新的 table name
|
||||
spec_id = db.Column(db.Integer, db.ForeignKey('tst_temp_spec.id', ondelete='CASCADE'), nullable=False)
|
||||
user_id = db.Column(db.Integer, db.ForeignKey('tst_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=taiwan_now)
|
||||
|
||||
# 建立與 User 和 TempSpec 的關聯,方便查詢
|
||||
user = db.relationship('User')
|
||||
spec = db.relationship('TempSpec', back_populates='history')
|
163
monitor.py
Normal file
163
monitor.py
Normal file
@@ -0,0 +1,163 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
生產環境監控腳本
|
||||
監控系統效能、Redis狀態、資料庫連接等
|
||||
"""
|
||||
import time
|
||||
import requests
|
||||
import redis
|
||||
import json
|
||||
from datetime import datetime
|
||||
import argparse
|
||||
import sys
|
||||
|
||||
def check_app_health():
|
||||
"""檢查應用程式健康狀態"""
|
||||
try:
|
||||
response = requests.get('http://localhost:12010/', timeout=10)
|
||||
return {
|
||||
'status': 'healthy' if response.status_code == 200 else 'unhealthy',
|
||||
'status_code': response.status_code,
|
||||
'response_time': response.elapsed.total_seconds()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e),
|
||||
'response_time': None
|
||||
}
|
||||
|
||||
def check_redis_health():
|
||||
"""檢查 Redis 健康狀態"""
|
||||
try:
|
||||
r = redis.from_url('redis://localhost:6379/0')
|
||||
r.ping()
|
||||
info = r.info()
|
||||
return {
|
||||
'status': 'healthy',
|
||||
'used_memory': info.get('used_memory_human'),
|
||||
'connected_clients': info.get('connected_clients'),
|
||||
'total_commands_processed': info.get('total_commands_processed'),
|
||||
'keyspace_hits': info.get('keyspace_hits'),
|
||||
'keyspace_misses': info.get('keyspace_misses')
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e)
|
||||
}
|
||||
|
||||
def check_onlyoffice_health():
|
||||
"""檢查 OnlyOffice 健康狀態"""
|
||||
try:
|
||||
response = requests.get('http://localhost:12011/healthcheck', timeout=10)
|
||||
return {
|
||||
'status': 'healthy' if response.status_code == 200 else 'unhealthy',
|
||||
'status_code': response.status_code,
|
||||
'response_time': response.elapsed.total_seconds()
|
||||
}
|
||||
except Exception as e:
|
||||
return {
|
||||
'status': 'unhealthy',
|
||||
'error': str(e),
|
||||
'response_time': None
|
||||
}
|
||||
|
||||
def get_docker_stats():
|
||||
"""獲取 Docker 容器統計資訊"""
|
||||
import subprocess
|
||||
try:
|
||||
result = subprocess.run(['docker', 'stats', '--no-stream', '--format',
|
||||
'table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'],
|
||||
capture_output=True, text=True, timeout=10)
|
||||
if result.returncode == 0:
|
||||
return result.stdout
|
||||
else:
|
||||
return f"Error: {result.stderr}"
|
||||
except Exception as e:
|
||||
return f"Error getting docker stats: {str(e)}"
|
||||
|
||||
def main():
|
||||
parser = argparse.ArgumentParser(description='暫時規範系統監控工具')
|
||||
parser.add_argument('--json', action='store_true', help='以JSON格式輸出')
|
||||
parser.add_argument('--watch', '-w', type=int, metavar='SECONDS',
|
||||
help='持續監控,指定刷新間隔秒數')
|
||||
args = parser.parse_args()
|
||||
|
||||
def run_checks():
|
||||
timestamp = datetime.now().isoformat()
|
||||
|
||||
# 執行各項健康檢查
|
||||
app_health = check_app_health()
|
||||
redis_health = check_redis_health()
|
||||
onlyoffice_health = check_onlyoffice_health()
|
||||
|
||||
results = {
|
||||
'timestamp': timestamp,
|
||||
'app': app_health,
|
||||
'redis': redis_health,
|
||||
'onlyoffice': onlyoffice_health
|
||||
}
|
||||
|
||||
if args.json:
|
||||
print(json.dumps(results, indent=2, ensure_ascii=False))
|
||||
else:
|
||||
# 格式化輸出
|
||||
print(f"\n🕒 監控時間: {timestamp}")
|
||||
print("=" * 60)
|
||||
|
||||
# 應用程式狀態
|
||||
status_icon = "✅" if app_health['status'] == 'healthy' else "❌"
|
||||
print(f"{status_icon} 應用程式: {app_health['status']}")
|
||||
if app_health.get('response_time'):
|
||||
print(f" 響應時間: {app_health['response_time']:.3f}s")
|
||||
if app_health.get('error'):
|
||||
print(f" 錯誤: {app_health['error']}")
|
||||
|
||||
# Redis 狀態
|
||||
status_icon = "✅" if redis_health['status'] == 'healthy' else "❌"
|
||||
print(f"{status_icon} Redis: {redis_health['status']}")
|
||||
if redis_health['status'] == 'healthy':
|
||||
print(f" 記憶體使用: {redis_health['used_memory']}")
|
||||
print(f" 連接數: {redis_health['connected_clients']}")
|
||||
if redis_health['keyspace_hits'] and redis_health['keyspace_misses']:
|
||||
hit_rate = redis_health['keyspace_hits'] / (redis_health['keyspace_hits'] + redis_health['keyspace_misses']) * 100
|
||||
print(f" 快取命中率: {hit_rate:.2f}%")
|
||||
elif redis_health.get('error'):
|
||||
print(f" 錯誤: {redis_health['error']}")
|
||||
|
||||
# OnlyOffice 狀態
|
||||
status_icon = "✅" if onlyoffice_health['status'] == 'healthy' else "❌"
|
||||
print(f"{status_icon} OnlyOffice: {onlyoffice_health['status']}")
|
||||
if onlyoffice_health.get('response_time'):
|
||||
print(f" 響應時間: {onlyoffice_health['response_time']:.3f}s")
|
||||
if onlyoffice_health.get('error'):
|
||||
print(f" 錯誤: {onlyoffice_health['error']}")
|
||||
|
||||
# Docker 統計
|
||||
print("\n📊 容器資源使用:")
|
||||
print(get_docker_stats())
|
||||
|
||||
return results
|
||||
|
||||
try:
|
||||
if args.watch:
|
||||
while True:
|
||||
try:
|
||||
if not args.json:
|
||||
print("\033[H\033[J") # 清空終端
|
||||
run_checks()
|
||||
if args.json:
|
||||
print() # JSON輸出間的分隔
|
||||
time.sleep(args.watch)
|
||||
except KeyboardInterrupt:
|
||||
print("\n👋 監控已停止")
|
||||
sys.exit(0)
|
||||
else:
|
||||
run_checks()
|
||||
except Exception as e:
|
||||
print(f"❌ 監控執行錯誤: {str(e)}")
|
||||
sys.exit(1)
|
||||
|
||||
if __name__ == "__main__":
|
||||
main()
|
18
mysql/init/01-init.sql
Normal file
18
mysql/init/01-init.sql
Normal file
@@ -0,0 +1,18 @@
|
||||
-- 初始化暫時規範管理系統資料庫
|
||||
-- 此檔案會在 MySQL 容器啟動時自動執行
|
||||
|
||||
-- 設定字符集和排序規則
|
||||
ALTER DATABASE tempspec_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci;
|
||||
|
||||
-- 確保時區設定
|
||||
SET time_zone = '+08:00';
|
||||
|
||||
-- 創建必要的索引以提升效能
|
||||
-- 注意:資料表結構由 SQLAlchemy 自動創建,這裡只需要創建額外的索引
|
||||
|
||||
-- 記錄初始化完成
|
||||
INSERT INTO mysql.general_log (event_time, user_host, thread_id, server_id, command_type, argument)
|
||||
VALUES (NOW(), 'init_script', 0, 1, 'Query', 'TempSpec Database Initialized');
|
||||
|
||||
-- 輸出初始化資訊
|
||||
SELECT 'TempSpec System Database Initialized Successfully' as STATUS;
|
21
nginx/Dockerfile
Normal file
21
nginx/Dockerfile
Normal file
@@ -0,0 +1,21 @@
|
||||
FROM nginx:alpine
|
||||
|
||||
# 移除預設配置
|
||||
RUN rm /etc/nginx/conf.d/default.conf
|
||||
|
||||
# 複製自定義配置
|
||||
COPY nginx.conf /etc/nginx/nginx.conf
|
||||
COPY conf.d/ /etc/nginx/conf.d/
|
||||
|
||||
# 創建SSL目錄
|
||||
RUN mkdir -p /etc/nginx/ssl
|
||||
|
||||
# 設置正確的權限
|
||||
RUN chown -R nginx:nginx /etc/nginx && \
|
||||
chmod -R 755 /etc/nginx
|
||||
|
||||
# 暴露端口
|
||||
EXPOSE 80 443
|
||||
|
||||
# 啟動nginx
|
||||
CMD ["nginx", "-g", "daemon off;"]
|
83
nginx/conf.d/default.conf
Normal file
83
nginx/conf.d/default.conf
Normal file
@@ -0,0 +1,83 @@
|
||||
# 暫時規範管理系統主站
|
||||
server {
|
||||
listen 80;
|
||||
server_name localhost;
|
||||
|
||||
# 重定向到 HTTPS (可選,需要 SSL 證書)
|
||||
# return 301 https://$server_name$request_uri;
|
||||
|
||||
# 應用程式代理
|
||||
location / {
|
||||
proxy_pass http://panjit-tempspec-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://panjit-tempspec-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://panjit-tempspec-app:5000/static/;
|
||||
expires 1y;
|
||||
add_header Cache-Control "public, immutable";
|
||||
}
|
||||
|
||||
# 健康檢查
|
||||
location /health {
|
||||
proxy_pass http://panjit-tempspec-app:5000/;
|
||||
access_log off;
|
||||
}
|
||||
|
||||
# 錯誤頁面
|
||||
error_page 404 /404.html;
|
||||
error_page 500 502 503 504 /50x.html;
|
||||
}
|
||||
|
||||
# HTTPS 設定 (需要 SSL 證書)
|
||||
# server {
|
||||
# listen 443 ssl http2;
|
||||
# server_name localhost;
|
||||
#
|
||||
# ssl_certificate /etc/nginx/ssl/cert.pem;
|
||||
# ssl_certificate_key /etc/nginx/ssl/key.pem;
|
||||
# ssl_protocols TLSv1.2 TLSv1.3;
|
||||
# ssl_ciphers ECDHE-RSA-AES256-GCM-SHA512:DHE-RSA-AES256-GCM-SHA512:ECDHE-RSA-AES256-GCM-SHA384:DHE-RSA-AES256-GCM-SHA384;
|
||||
# ssl_prefer_server_ciphers off;
|
||||
# ssl_session_cache shared:SSL:10m;
|
||||
# ssl_session_timeout 10m;
|
||||
#
|
||||
# # HSTS
|
||||
# add_header Strict-Transport-Security "max-age=63072000" always;
|
||||
#
|
||||
# # 其餘配置與 HTTP 相同
|
||||
# # ...
|
||||
# }
|
71
nginx/nginx.conf
Normal file
71
nginx/nginx.conf
Normal file
@@ -0,0 +1,71 @@
|
||||
user nginx;
|
||||
worker_processes auto;
|
||||
error_log /var/log/nginx/error.log warn;
|
||||
pid /var/run/nginx.pid;
|
||||
|
||||
events {
|
||||
worker_connections 1024;
|
||||
use epoll;
|
||||
multi_accept on;
|
||||
}
|
||||
|
||||
http {
|
||||
include /etc/nginx/mime.types;
|
||||
default_type application/octet-stream;
|
||||
|
||||
# 日誌格式
|
||||
log_format main '$remote_addr - $remote_user [$time_local] "$request" '
|
||||
'$status $body_bytes_sent "$http_referer" '
|
||||
'"$http_user_agent" "$http_x_forwarded_for"';
|
||||
|
||||
access_log /var/log/nginx/access.log main;
|
||||
|
||||
# 基本設定
|
||||
sendfile on;
|
||||
tcp_nopush on;
|
||||
tcp_nodelay on;
|
||||
keepalive_timeout 65;
|
||||
types_hash_max_size 2048;
|
||||
server_tokens off;
|
||||
|
||||
# 檔案大小限制
|
||||
client_max_body_size 100M;
|
||||
client_body_timeout 120s;
|
||||
client_header_timeout 120s;
|
||||
|
||||
# Gzip 壓縮
|
||||
gzip on;
|
||||
gzip_vary on;
|
||||
gzip_min_length 1024;
|
||||
gzip_proxied any;
|
||||
gzip_comp_level 6;
|
||||
gzip_types
|
||||
application/atom+xml
|
||||
application/geo+json
|
||||
application/javascript
|
||||
application/x-javascript
|
||||
application/json
|
||||
application/ld+json
|
||||
application/manifest+json
|
||||
application/rdf+xml
|
||||
application/rss+xml
|
||||
application/xhtml+xml
|
||||
application/xml
|
||||
font/eot
|
||||
font/otf
|
||||
font/ttf
|
||||
image/svg+xml
|
||||
text/css
|
||||
text/javascript
|
||||
text/plain
|
||||
text/xml;
|
||||
|
||||
# 安全標頭
|
||||
add_header X-Frame-Options DENY;
|
||||
add_header X-Content-Type-Options nosniff;
|
||||
add_header X-XSS-Protection "1; mode=block";
|
||||
add_header Referrer-Policy "strict-origin-when-cross-origin";
|
||||
|
||||
# 包含站點配置
|
||||
include /etc/nginx/conf.d/*.conf;
|
||||
}
|
22
requirements.txt
Normal file
22
requirements.txt
Normal file
@@ -0,0 +1,22 @@
|
||||
# Core web framework
|
||||
Flask==3.0.0
|
||||
Flask-Login==0.6.3
|
||||
Flask-SQLAlchemy==3.0.5
|
||||
Flask-Caching==2.1.0
|
||||
Flask-APScheduler==1.13.1
|
||||
|
||||
# Database
|
||||
PyMySQL==1.1.0
|
||||
|
||||
# Production server
|
||||
gunicorn==21.2.0
|
||||
|
||||
# Utilities
|
||||
python-dotenv==1.0.0
|
||||
requests==2.31.0
|
||||
PyJWT==2.8.0
|
||||
redis==5.0.1
|
||||
|
||||
# Document handling
|
||||
python-docx==1.1.0
|
||||
docxtpl==0.16.7
|
0
routes/__init__.py
Normal file
0
routes/__init__.py
Normal file
120
routes/admin.py
Normal file
120
routes/admin.py
Normal file
@@ -0,0 +1,120 @@
|
||||
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')
|
||||
|
||||
ALLOWED_ROLES = {'viewer', 'editor', 'admin'}
|
||||
|
||||
|
||||
@admin_bp.route('/users')
|
||||
@login_required
|
||||
@admin_required
|
||||
def user_list():
|
||||
users = User.query.order_by(User.username).all()
|
||||
return render_template('user_management.html', users=users)
|
||||
|
||||
|
||||
@admin_bp.route('/users/create', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def create_user():
|
||||
username = request.form.get('username', '').strip()
|
||||
name = request.form.get('name', '').strip()
|
||||
password = request.form.get('password', '')
|
||||
role = request.form.get('role', 'viewer')
|
||||
|
||||
errors = []
|
||||
|
||||
if not username:
|
||||
errors.append('請輸入帳號')
|
||||
if not name:
|
||||
errors.append('請輸入姓名')
|
||||
if not password:
|
||||
errors.append('請輸入密碼')
|
||||
if password and len(password) < 6:
|
||||
errors.append('密碼長度至少需 6 碼')
|
||||
if role not in ALLOWED_ROLES:
|
||||
errors.append('角色設定不正確')
|
||||
if username and User.query.filter_by(username=username).first():
|
||||
errors.append('帳號已存在,請改用其他帳號')
|
||||
|
||||
if errors:
|
||||
for message in errors:
|
||||
flash(message, 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
new_user = User(username=username, name=name, role=role)
|
||||
new_user.set_password(password)
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"已建立帳號 {username}", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
|
||||
@admin_bp.route('/users/update/<int:user_id>', methods=['POST'])
|
||||
@login_required
|
||||
@admin_required
|
||||
def update_user(user_id):
|
||||
user = User.query.get_or_404(user_id)
|
||||
|
||||
name = request.form.get('name', '').strip()
|
||||
role = request.form.get('role', 'viewer')
|
||||
password = request.form.get('password', '').strip()
|
||||
|
||||
if not name:
|
||||
flash('請輸入姓名', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
if role not in ALLOWED_ROLES:
|
||||
flash('角色設定不正確', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
if password and len(password) < 6:
|
||||
flash('密碼長度至少需 6 碼', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
if user.id == current_user.id and user.role == 'admin' and role != 'admin':
|
||||
flash('無法變更自己的管理員權限', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
if user.role == 'admin' and role != 'admin':
|
||||
admin_count = User.query.filter_by(role='admin').count()
|
||||
if admin_count <= 1:
|
||||
flash('系統至少需要一位管理員,無法變更該帳號的管理員權限', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
user.name = name
|
||||
user.role = role
|
||||
if password:
|
||||
user.set_password(password)
|
||||
|
||||
db.session.commit()
|
||||
|
||||
flash(f"已更新帳號 {user.username}", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
|
||||
@admin_bp.route('/users/delete/<int:user_id>', methods=['POST'])
|
||||
@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)
|
||||
|
||||
if user.role == 'admin':
|
||||
admin_count = User.query.filter_by(role='admin').count()
|
||||
if admin_count <= 1:
|
||||
flash('系統至少需要一位管理員,無法刪除該帳號', 'danger')
|
||||
return redirect(url_for('admin.user_list'))
|
||||
|
||||
db.session.delete(user)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"已刪除帳號 {user.username}", 'success')
|
||||
return redirect(url_for('admin.user_list'))
|
9
routes/api.py
Normal file
9
routes/api.py
Normal file
@@ -0,0 +1,9 @@
|
||||
from flask import Blueprint, jsonify
|
||||
|
||||
api_bp = Blueprint('api', __name__, url_prefix='/api')
|
||||
|
||||
|
||||
@api_bp.route('/health', methods=['GET'])
|
||||
def health_check():
|
||||
"""簡易 API 健康檢查端點。"""
|
||||
return jsonify({'status': 'ok'})
|
93
routes/auth.py
Normal file
93
routes/auth.py
Normal file
@@ -0,0 +1,93 @@
|
||||
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 models import User, db
|
||||
from utils.timezone import taiwan_now
|
||||
|
||||
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'))
|
||||
|
||||
context = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form['username'].strip()
|
||||
password = request.form['password']
|
||||
context['username'] = username
|
||||
|
||||
if not username or not password:
|
||||
flash('請輸入帳號與密碼', 'warning')
|
||||
return render_template('login.html', **context)
|
||||
|
||||
user = User.query.filter_by(username=username).first()
|
||||
|
||||
if user and user.check_password(password):
|
||||
user.last_login = taiwan_now()
|
||||
db.session.commit()
|
||||
login_user(user)
|
||||
current_app.logger.info(f"User logged in via local authentication: {username}")
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
current_app.logger.warning(f"Failed local login attempt for: {username}")
|
||||
flash('帳號或密碼錯誤,請重新輸入', 'danger')
|
||||
|
||||
return render_template('login.html', **context)
|
||||
|
||||
|
||||
@auth_bp.route('/register', methods=['GET', 'POST'])
|
||||
def register():
|
||||
if current_user.is_authenticated:
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
context = {}
|
||||
|
||||
if request.method == 'POST':
|
||||
username = request.form['username'].strip()
|
||||
name = request.form['name'].strip()
|
||||
password = request.form['password']
|
||||
confirm_password = request.form['confirm_password']
|
||||
|
||||
context['username'] = username
|
||||
context['name'] = name
|
||||
|
||||
errors = []
|
||||
|
||||
if not username:
|
||||
errors.append('請輸入帳號')
|
||||
if not name:
|
||||
errors.append('請輸入姓名')
|
||||
if not password:
|
||||
errors.append('請輸入密碼')
|
||||
if password and len(password) < 6:
|
||||
errors.append('密碼長度至少需 6 碼')
|
||||
if password != confirm_password:
|
||||
errors.append('確認密碼不相符')
|
||||
if username and User.query.filter_by(username=username).first():
|
||||
errors.append('帳號已存在,請改用其他帳號')
|
||||
|
||||
if errors:
|
||||
for message in errors:
|
||||
flash(message, 'danger')
|
||||
return render_template('register.html', **context)
|
||||
|
||||
new_user = User(username=username, name=name, role='viewer')
|
||||
new_user.set_password(password)
|
||||
new_user.last_login = taiwan_now()
|
||||
db.session.add(new_user)
|
||||
db.session.commit()
|
||||
login_user(new_user)
|
||||
current_app.logger.info(f"New user registered: {username}")
|
||||
flash('帳號建立完成,已自動登入', 'success')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return render_template('register.html', **context)
|
||||
|
||||
|
||||
@auth_bp.route('/logout')
|
||||
@login_required
|
||||
def logout():
|
||||
logout_user()
|
||||
return redirect(url_for('auth.login'))
|
522
routes/temp_spec.py
Normal file
522
routes/temp_spec.py
Normal file
@@ -0,0 +1,522 @@
|
||||
# -*- 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 utils.timezone import taiwan_now, format_taiwan_time
|
||||
from models import TempSpec, db, Upload, SpecHistory
|
||||
from utils import editor_or_admin_required, add_history_log, admin_required, send_email, process_recipients
|
||||
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__)))
|
||||
|
||||
# Enforce authentication for this blueprint (except OnlyOffice callback)
|
||||
@temp_spec_bp.before_request
|
||||
def before_request():
|
||||
"""Ensure every request under this blueprint comes from an authenticated user."""
|
||||
# Allow OnlyOffice callback without authentication
|
||||
if request.endpoint == 'temp_spec.onlyoffice_callback':
|
||||
return None
|
||||
if not current_user.is_authenticated:
|
||||
return redirect(url_for('auth.login'))
|
||||
|
||||
def _generate_next_spec_code():
|
||||
"""Generate the next temporary spec code based on ROC year/month and a sequence."""
|
||||
now = taiwan_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: str) -> str:
|
||||
"""Return a public URL for a generated file stored under /static/generated."""
|
||||
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 = taiwan_now()
|
||||
|
||||
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()
|
||||
|
||||
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'),
|
||||
}
|
||||
|
||||
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}'] = 'Y' if key in selected_stations else 'N'
|
||||
|
||||
selected_tccs_level = form_data.get('tccs_level')
|
||||
level_keys = ['l1', 'l2', 'l3', 'l4']
|
||||
for key in level_keys:
|
||||
context[f't_{key}'] = 'Y' if key == selected_tccs_level else 'N'
|
||||
|
||||
selected_tccs_4m = form_data.get('tccs_4m')
|
||||
m_keys = ['man', 'machine', 'material', 'method', 'env']
|
||||
for key in m_keys:
|
||||
context[f't_{key}'] = 'Y' if key == selected_tccs_4m else 'N'
|
||||
|
||||
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 (template_with_placeholders.docx) was not found.', 'danger')
|
||||
db.session.rollback()
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
doc = DocxTemplate(template_path)
|
||||
doc.render(context)
|
||||
doc.save(new_file_path)
|
||||
|
||||
add_history_log(spec.id, 'create', f"Created draft spec: {spec.spec_code}")
|
||||
db.session.commit()
|
||||
|
||||
return redirect(url_for('temp_spec.edit_spec', spec_id=spec.id))
|
||||
|
||||
return render_template('create_temp_spec_form.html')
|
||||
|
||||
@temp_spec_bp.route('/edit/<int:spec_id>')
|
||||
@editor_or_admin_required
|
||||
def edit_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
doc_filename = f"{spec.spec_code}.docx"
|
||||
|
||||
doc_physical_path = os.path.join(current_app.static_folder, 'generated', doc_filename)
|
||||
if not os.path.exists(doc_physical_path):
|
||||
flash(f"Generated document not found: {doc_filename}", 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
|
||||
doc_url = get_file_uri(doc_filename)
|
||||
callback_url = url_for('temp_spec.onlyoffice_callback', spec_id=spec_id, _external=True)
|
||||
|
||||
if '127.0.0.1' in doc_url or 'localhost' in doc_url:
|
||||
doc_url = doc_url.replace('127.0.0.1:12013', 'panjit-tempspec-nginx:80').replace('localhost:12013', 'panjit-tempspec-nginx:80')
|
||||
doc_url = doc_url.replace('127.0.0.1', 'panjit-tempspec-nginx').replace('localhost', 'panjit-tempspec-nginx')
|
||||
callback_url = callback_url.replace('127.0.0.1:12013', 'panjit-tempspec-nginx:80').replace('localhost:12013', 'panjit-tempspec-nginx:80')
|
||||
callback_url = callback_url.replace('127.0.0.1', 'panjit-tempspec-nginx').replace('localhost', 'panjit-tempspec-nginx')
|
||||
|
||||
|
||||
oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET']
|
||||
|
||||
file_key = f"{spec.id}_{int(os.path.getmtime(doc_physical_path))}"
|
||||
|
||||
payload = {
|
||||
"document": {
|
||||
"fileType": "docx",
|
||||
"key": file_key,
|
||||
"title": doc_filename,
|
||||
"url": doc_url
|
||||
},
|
||||
"documentType": "word",
|
||||
"editorConfig": {
|
||||
"callbackUrl": callback_url,
|
||||
"user": { "id": str(current_user.id), "name": current_user.name or current_user.username },
|
||||
"customization": {
|
||||
"autosave": True,
|
||||
"forcesave": True,
|
||||
"chat": False,
|
||||
"comments": True,
|
||||
"help": False
|
||||
},
|
||||
"mode": "edit"
|
||||
}
|
||||
}
|
||||
|
||||
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']
|
||||
)
|
||||
|
||||
@temp_spec_bp.route('/onlyoffice-callback/<int:spec_id>', methods=['POST'])
|
||||
def onlyoffice_callback(spec_id):
|
||||
data = request.json
|
||||
status = data.get('status')
|
||||
|
||||
current_app.logger.info(f"OnlyOffice callback for spec {spec_id}: status={status}, data={data}")
|
||||
|
||||
|
||||
if status in [2, 6]:
|
||||
try:
|
||||
if 'url' not in data:
|
||||
current_app.logger.error(f"OnlyOffice callback missing URL for spec {spec_id}")
|
||||
return jsonify({"error": 1, "message": "Missing document URL"})
|
||||
|
||||
token = data.get('token')
|
||||
if token:
|
||||
try:
|
||||
oo_secret = current_app.config['ONLYOFFICE_JWT_SECRET']
|
||||
jwt.decode(token, oo_secret, algorithms=['HS256'])
|
||||
except jwt.InvalidTokenError:
|
||||
current_app.logger.error(f"Invalid JWT token in OnlyOffice callback for spec {spec_id}")
|
||||
return jsonify({"error": 1, "message": "Invalid token"})
|
||||
|
||||
download_url = data['url']
|
||||
download_url = download_url.replace('localhost:12015', 'panjit-tempspec-onlyoffice:80')
|
||||
download_url = download_url.replace('127.0.0.1:12015', 'panjit-tempspec-onlyoffice:80')
|
||||
|
||||
current_app.logger.info(f"Downloading updated document from: {data['url']} -> {download_url}")
|
||||
response = requests.get(download_url, timeout=30)
|
||||
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)
|
||||
|
||||
os.makedirs(os.path.dirname(file_path), exist_ok=True)
|
||||
|
||||
with open(file_path, 'wb') as f:
|
||||
f.write(response.content)
|
||||
|
||||
current_app.logger.info(f"Successfully saved updated document for spec {spec_id} to {file_path}")
|
||||
|
||||
spec.updated_at = taiwan_now()
|
||||
db.session.commit()
|
||||
|
||||
except requests.RequestException as e:
|
||||
current_app.logger.error(f"Failed to download document from OnlyOffice for spec {spec_id}: {e}")
|
||||
return jsonify({"error": 1, "message": f"Download failed: {str(e)}"})
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"OnlyOffice callback error for spec {spec_id}: {e}")
|
||||
return jsonify({"error": 1, "message": str(e)})
|
||||
|
||||
elif status == 1:
|
||||
current_app.logger.info(f"Document {spec_id} is being edited")
|
||||
elif status == 4:
|
||||
current_app.logger.info(f"Document {spec_id} closed without changes")
|
||||
elif status == 7:
|
||||
current_app.logger.error(f"OnlyOffice error for document {spec_id}")
|
||||
return jsonify({"error": 1, "message": "OnlyOffice reported an error"})
|
||||
|
||||
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
|
||||
from datetime import date
|
||||
today = date.today()
|
||||
|
||||
return render_template(
|
||||
'spec_list.html',
|
||||
specs=specs,
|
||||
pagination=pagination,
|
||||
query=query,
|
||||
status=status_filter,
|
||||
today=today
|
||||
)
|
||||
|
||||
@temp_spec_bp.route('/activate/<int:spec_id>', methods=['GET', 'POST'])
|
||||
@admin_required
|
||||
def activate_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
if request.method == 'POST':
|
||||
uploaded_file = request.files.get('signed_file')
|
||||
if not uploaded_file or uploaded_file.filename == '':
|
||||
flash('Please upload a file first.', 'danger')
|
||||
return redirect(url_for('temp_spec.activate_spec', spec_id=spec.id))
|
||||
|
||||
filename = secure_filename(f"{spec.spec_code}_signed_{taiwan_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=taiwan_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, 'activate', f"Uploaded signed file '{filename}'")
|
||||
db.session.commit()
|
||||
flash(f"Spec '{spec.spec_code}' is now active.", 'success')
|
||||
|
||||
# --- Start of Dynamic Email Notification ---
|
||||
if recipients_str:
|
||||
recipients = process_recipients(recipients_str)
|
||||
if recipients:
|
||||
subject = f"[TempSpec Notice] Spec '{spec.spec_code}' is active"
|
||||
# Using f-strings and triple quotes for a readable HTML body
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Temp spec <b>{spec.spec_code} - {spec.title}</b> has been approved and is now active.</p>
|
||||
<p>Start date: {spec.start_date.strftime('%Y-%m-%d')}<br>
|
||||
End date: {spec.end_date.strftime('%Y-%m-%d')}</p>
|
||||
<p>Applicant: {spec.applicant}</p>
|
||||
<p>Please sign in to the system for additional details.</p>
|
||||
<p>This notification was sent automatically. Please do not reply.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(f"Sent activation notification for spec {spec.spec_code} to {len(recipients)} recipients.")
|
||||
# --- End of Dynamic Email Notification ---
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return render_template('activate_spec.html', spec=spec)
|
||||
|
||||
@temp_spec_bp.route('/terminate/<int:spec_id>', methods=['GET', 'POST'])
|
||||
@editor_or_admin_required
|
||||
def terminate_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
if request.method == 'POST':
|
||||
reason = request.form.get('reason')
|
||||
if not reason:
|
||||
flash('Please provide the reason for early termination.', 'danger')
|
||||
return redirect(url_for('temp_spec.terminate_spec', spec_id=spec.id))
|
||||
|
||||
spec.status = 'terminated'
|
||||
spec.termination_reason = reason
|
||||
spec.end_date = taiwan_now().date()
|
||||
add_history_log(spec.id, 'terminate', f"Reason: {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"[TempSpec Notice] Spec '{spec.spec_code}' was terminated early"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Temp spec <b>{spec.spec_code} - {spec.title}</b> has been terminated.</p>
|
||||
<p>Termination date: <b>{spec.end_date.strftime('%Y-%m-%d')}</b></p>
|
||||
<p>Applicant: {spec.applicant}</p>
|
||||
<p>Reason: {reason}</p>
|
||||
<p>Please sign in to the system for additional details.</p>
|
||||
<p>This notification was sent automatically. Please do not reply.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(f"Sent termination notification for spec {spec.spec_code} to {len(recipients)} recipients.")
|
||||
# --- End of Dynamic Email Notification ---
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Spec '{spec.spec_code}' has been terminated early.", 'warning')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return render_template('terminate_spec.html', spec=spec, saved_emails=spec.notification_emails)
|
||||
|
||||
@temp_spec_bp.route('/download_initial_word/<int:spec_id>')
|
||||
@login_required
|
||||
def download_initial_word(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
if current_user.role not in ['editor', 'admin']:
|
||||
flash('You do not have permission to download the Word document.', '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('The original Word document cannot be found. It might have been moved or deleted.', 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
return send_file(word_path, as_attachment=True)
|
||||
|
||||
@temp_spec_bp.route('/download_signed/<int:spec_id>')
|
||||
@login_required
|
||||
def download_signed_pdf(spec_id):
|
||||
latest_upload = Upload.query.filter_by(temp_spec_id=spec_id).order_by(Upload.upload_time.desc()).first()
|
||||
|
||||
if not latest_upload:
|
||||
flash('No signed files have been uploaded yet.', 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER'])
|
||||
return send_file(os.path.join(upload_folder, latest_upload.filename), as_attachment=True)
|
||||
|
||||
@temp_spec_bp.route('/extend/<int:spec_id>', methods=['GET', 'POST'])
|
||||
@editor_or_admin_required
|
||||
def extend_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
|
||||
if spec.extension_count >= 2:
|
||||
flash('This temporary specification has reached the extension limit (2 times) and the total 90-day duration. Extension is no longer allowed.', 'danger')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
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('Please upload the supporting PDF before extending.', 'danger')
|
||||
return redirect(url_for('temp_spec.extend_spec', spec_id=spec.id))
|
||||
|
||||
if not new_end_date_str:
|
||||
flash('Please choose a new end date.', '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}_{taiwan_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=taiwan_now()
|
||||
)
|
||||
db.session.add(new_upload)
|
||||
|
||||
details = f"Extended end date to {spec.end_date.strftime('%Y-%m-%d')}"
|
||||
details += f", uploaded file '{new_upload.filename}'"
|
||||
add_history_log(spec.id, 'extend', 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"[TempSpec Notice] Spec '{spec.spec_code}' was extended"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>Temp spec <b>{spec.spec_code} - {spec.title}</b> has been extended.</p>
|
||||
<p>New end date: <b>{spec.end_date.strftime('%Y-%m-%d')}</b></p>
|
||||
<p>Applicant: {spec.applicant}</p>
|
||||
<p>Please sign in to the system for additional details.</p>
|
||||
<p>This notification was sent automatically. Please do not reply.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(f"Sent extension notification for spec {spec.spec_code} to {len(recipients)} recipients.")
|
||||
# --- End of Dynamic Email Notification ---
|
||||
|
||||
db.session.commit()
|
||||
flash(f"Spec '{spec.spec_code}' was extended successfully.", 'success')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
||||
|
||||
default_new_end_date = spec.end_date + timedelta(days=30)
|
||||
return render_template('extend_spec.html', spec=spec, default_new_end_date=default_new_end_date, saved_emails=spec.notification_emails)
|
||||
|
||||
@temp_spec_bp.route('/history/<int:spec_id>')
|
||||
@login_required
|
||||
def spec_history(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
history = SpecHistory.query.filter_by(spec_id=spec_id).order_by(SpecHistory.timestamp.desc()).all()
|
||||
return render_template('spec_history.html', spec=spec, history=history)
|
||||
|
||||
@temp_spec_bp.route('/delete/<int:spec_id>', methods=['POST'])
|
||||
@admin_required
|
||||
def delete_spec(spec_id):
|
||||
spec = TempSpec.query.get_or_404(spec_id)
|
||||
spec_code = spec.spec_code
|
||||
|
||||
files_to_delete = []
|
||||
generated_folder = os.path.join(current_app.static_folder, 'generated')
|
||||
files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx"))
|
||||
|
||||
upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER'])
|
||||
for upload_record in spec.uploads:
|
||||
files_to_delete.append(os.path.join(upload_folder, upload_record.filename))
|
||||
|
||||
for f_path in files_to_delete:
|
||||
try:
|
||||
if os.path.exists(f_path):
|
||||
os.remove(f_path)
|
||||
except Exception as e:
|
||||
current_app.logger.error(f"Failed to delete file: {f_path}, reason: {e}")
|
||||
|
||||
db.session.delete(spec)
|
||||
db.session.commit()
|
||||
|
||||
flash(f"Spec '{spec_code}' and related files were deleted successfully.", 'success')
|
||||
return redirect(url_for('temp_spec.spec_list'))
|
29
routes/upload.py
Normal file
29
routes/upload.py
Normal file
@@ -0,0 +1,29 @@
|
||||
from flask import Blueprint, request, jsonify, current_app
|
||||
from werkzeug.utils import secure_filename
|
||||
import os
|
||||
import time
|
||||
|
||||
upload_bp = Blueprint('upload', __name__)
|
||||
|
||||
@upload_bp.route('/image', methods=['POST'])
|
||||
def upload_image():
|
||||
file = request.files.get('file')
|
||||
if not file:
|
||||
return jsonify({'error': 'No file part'}), 400
|
||||
|
||||
# 建立一個獨特的檔名
|
||||
extension = os.path.splitext(file.filename)[1]
|
||||
filename = f"{int(time.time())}_{secure_filename(file.filename)}"
|
||||
|
||||
# 確保上傳資料夾存在
|
||||
# 為了讓圖片能被網頁存取,我們將它存在 static 資料夾下
|
||||
image_folder = os.path.join(current_app.static_folder, 'uploads', 'images')
|
||||
os.makedirs(image_folder, exist_ok=True)
|
||||
|
||||
file_path = os.path.join(image_folder, filename)
|
||||
file.save(file_path)
|
||||
|
||||
# 回傳 TinyMCE 需要的 JSON 格式
|
||||
# 路徑必須是相對於網域根目錄的 URL
|
||||
location = f"/static/uploads/images/{filename}"
|
||||
return jsonify({'location': location})
|
102
start-production.sh
Normal file
102
start-production.sh
Normal file
@@ -0,0 +1,102 @@
|
||||
#!/bin/bash
|
||||
|
||||
# 暫時規範管理系統 V4 - 生產環境啟動腳本
|
||||
# 使用方式: ./start-production.sh
|
||||
|
||||
set -e
|
||||
|
||||
echo "🚀 暫時規範管理系統 V4 - 生產環境部署"
|
||||
echo "=================================================="
|
||||
|
||||
# 檢查必要檔案
|
||||
echo "📋 檢查必要檔案..."
|
||||
if [ ! -f ".env" ]; then
|
||||
echo "❌ 錯誤: .env 檔案不存在"
|
||||
echo "請複製 .env.production 為 .env 並配置相應的值"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
if [ ! -f "docker-compose.yml" ]; then
|
||||
echo "❌ 錯誤: docker-compose.yml 檔案不存在"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 檢查 Docker 是否運行
|
||||
echo "🐳 檢查 Docker 服務..."
|
||||
if ! docker info > /dev/null 2>&1; then
|
||||
echo "❌ 錯誤: Docker 服務未運行"
|
||||
echo "請啟動 Docker 服務後再試"
|
||||
exit 1
|
||||
fi
|
||||
|
||||
# 停止舊的容器(如果存在)
|
||||
echo "🛑 停止現有容器..."
|
||||
docker-compose down || true
|
||||
|
||||
# 建構新的映像
|
||||
echo "🔨 建構應用程式映像..."
|
||||
docker-compose build --no-cache
|
||||
|
||||
# 啟動服務(生產環境配置)
|
||||
echo "🌟 啟動生產環境服務..."
|
||||
if [ -f "docker-compose.prod.yml" ]; then
|
||||
echo "使用生產環境配置檔案..."
|
||||
docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d
|
||||
else
|
||||
echo "使用標準配置啟動..."
|
||||
docker-compose --profile production up -d
|
||||
fi
|
||||
|
||||
# 等待服務啟動
|
||||
echo "⏳ 等待服務啟動..."
|
||||
sleep 10
|
||||
|
||||
# 檢查服務狀態
|
||||
echo "📊 檢查服務狀態..."
|
||||
docker-compose ps
|
||||
|
||||
# 顯示服務 URL
|
||||
echo ""
|
||||
echo "✅ 部署完成!"
|
||||
echo "=================================================="
|
||||
echo "📍 服務存取點:"
|
||||
echo " 主應用程式: http://localhost:12010"
|
||||
echo " OnlyOffice: http://localhost:12011"
|
||||
echo ""
|
||||
echo "📊 管理命令:"
|
||||
echo " 查看日誌: docker-compose logs -f"
|
||||
echo " 停止服務: docker-compose down"
|
||||
echo " 重啟服務: docker-compose restart"
|
||||
echo ""
|
||||
echo "🔧 監控命令:"
|
||||
echo " 查看容器狀態: docker-compose ps"
|
||||
echo " 查看資源使用: docker stats"
|
||||
echo ""
|
||||
|
||||
# 檢查健康狀態
|
||||
echo "🏥 健康檢查..."
|
||||
sleep 5
|
||||
|
||||
# 檢查 Redis
|
||||
if docker-compose exec -T redis redis-cli ping > /dev/null 2>&1; then
|
||||
echo "✅ Redis: 健康"
|
||||
else
|
||||
echo "❌ Redis: 異常"
|
||||
fi
|
||||
|
||||
# 檢查應用程式
|
||||
if curl -f http://localhost:12010/ > /dev/null 2>&1; then
|
||||
echo "✅ 應用程式: 健康"
|
||||
else
|
||||
echo "❌ 應用程式: 異常"
|
||||
fi
|
||||
|
||||
# 檢查 OnlyOffice
|
||||
if curl -f http://localhost:12011/healthcheck > /dev/null 2>&1; then
|
||||
echo "✅ OnlyOffice: 健康"
|
||||
else
|
||||
echo "❌ OnlyOffice: 異常"
|
||||
fi
|
||||
|
||||
echo ""
|
||||
echo "🎉 生產環境啟動完成!"
|
203
static/css/style.css
Normal file
203
static/css/style.css
Normal file
@@ -0,0 +1,203 @@
|
||||
/* --- 全域與背景設定 --- */
|
||||
body {
|
||||
background-color: #0d1117;
|
||||
background-image: linear-gradient(180deg, #161b22 0%, #0d1117 100%);
|
||||
background-attachment: fixed;
|
||||
color: #c9d1d9;
|
||||
font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif;
|
||||
}
|
||||
|
||||
/* --- 強制設定通用元素的文字顏色 --- */
|
||||
p, label, th, td, .form-label, .form-check-label, .card-body {
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* --- 導覽列 --- */
|
||||
.navbar {
|
||||
background-color: rgba(13, 17, 23, 0.8);
|
||||
backdrop-filter: blur(10px);
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
/* --- 標題 --- */
|
||||
h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 {
|
||||
color: #f0f6fc;
|
||||
}
|
||||
|
||||
/* --- 卡片與容器 --- */
|
||||
.card {
|
||||
background-color: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
border-radius: 0.5rem;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2);
|
||||
}
|
||||
.card-header, .card-footer {
|
||||
background-color: rgba(22, 27, 34, 0.7);
|
||||
border-bottom: 1px solid #30363d;
|
||||
color: #f0f6fc;
|
||||
}
|
||||
|
||||
/* --- 按鈕 --- */
|
||||
.btn-primary {
|
||||
background-color: #5865f2; border-color: #5865f2; color: #ffffff;
|
||||
transition: all 0.2s ease-in-out;
|
||||
}
|
||||
.btn-primary:hover, .btn-primary:focus {
|
||||
background-color: #4752c4; border-color: #4752c4;
|
||||
box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.5);
|
||||
}
|
||||
.btn-success { background-color: #2ea043; border-color: #2ea043; color: #fff; }
|
||||
.btn-success:hover { background-color: #268839; border-color: #268839; color: #fff;}
|
||||
.btn-danger { background-color: #da3633; border-color: #da3633; color: #fff;}
|
||||
.btn-danger:hover { background-color: #b92d2b; border-color: #b92d2b; color: #fff;}
|
||||
.btn-warning { background-color: #f0ad4e; border-color: #f0ad4e; color: #0d1117; }
|
||||
.btn-warning:hover { background-color: #e39b37; border-color: #e39b37; color: #0d1117;}
|
||||
.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #0d1117; }
|
||||
.btn-info:hover { background-color: #0baccc; border-color: #0baccc; color: #0d1117;}
|
||||
|
||||
/* --- 表單輸入框 --- */
|
||||
.form-control, .form-select {
|
||||
background-color: #0d1117; color: #c9d1d9;
|
||||
border: 1px solid #30363d; border-radius: 0.375rem;
|
||||
}
|
||||
.form-control:focus, .form-select:focus {
|
||||
background-color: #0d1117; color: #c9d1d9;
|
||||
border-color: #5865f2;
|
||||
box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25);
|
||||
}
|
||||
.form-control::placeholder { color: #8b949e; }
|
||||
.form-control[readonly] { background-color: #161b22; opacity: 0.7; }
|
||||
.input-group-text {
|
||||
background-color: #161b22;
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
/* --- 表格 --- */
|
||||
.table {
|
||||
--bs-table-color: #c9d1d9;
|
||||
--bs-table-bg: #161b22;
|
||||
--bs-table-border-color: #30363d;
|
||||
--bs-table-striped-color: #c9d1d9;
|
||||
--bs-table-striped-bg: #21262d;
|
||||
--bs-table-hover-color: #f0f6fc;
|
||||
--bs-table-hover-bg: #30363d;
|
||||
border-color: var(--bs-table-border-color);
|
||||
}
|
||||
.table > thead {
|
||||
color: #f0f6fc;
|
||||
}
|
||||
|
||||
/* --- 分頁 --- */
|
||||
.pagination {
|
||||
--bs-pagination-color: #58a6ff;
|
||||
--bs-pagination-bg: #0d1117; /* 最深的背景色,使其與容器分離 */
|
||||
--bs-pagination-border-color: #30363d;
|
||||
--bs-pagination-hover-color: #80b6ff;
|
||||
--bs-pagination-hover-bg: #21262d; /* 懸停時變亮 */
|
||||
--bs-pagination-hover-border-color: #4d555e;
|
||||
--bs-pagination-focus-color: #80b6ff;
|
||||
--bs-pagination-focus-bg: #21262d;
|
||||
--bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25);
|
||||
--bs-pagination-active-color: #fff;
|
||||
--bs-pagination-active-bg: #5865f2;
|
||||
--bs-pagination-active-border-color: #5865f2;
|
||||
--bs-pagination-disabled-color: #8b949e;
|
||||
--bs-pagination-disabled-bg: #161b22; /* 禁用的背景色,使其看起來凹陷 */
|
||||
--bs-pagination-disabled-border-color: #30363d;
|
||||
}
|
||||
|
||||
/* --- 提示訊息 (Alerts) --- */
|
||||
.alert { border-width: 1px; border-style: solid; }
|
||||
.alert-danger { background-color: rgba(218, 54, 51, 0.15); border-color: #da3633; color: #ff8986; }
|
||||
.alert-success { background-color: rgba(46, 160, 67, 0.15); border-color: #2ea043; color: #7ce38f; }
|
||||
.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #6be2fa; }
|
||||
.alert-warning { background-color: rgba(240, 173, 78, 0.15); border-color: #f0ad4e; color: #f0ad4e; }
|
||||
|
||||
/* --- 狀態標籤 (Badges) --- */
|
||||
.badge { --bs-badge-font-size: 0.8em; --bs-badge-padding-y: 0.4em; --bs-badge-padding-x: 0.7em; }
|
||||
.bg-success { background-color: #2ea043 !important; }
|
||||
.bg-info { background-color: #0dcaf0 !important; color: #0d1117 !important; }
|
||||
.bg-warning { background-color: #f0ad4e !important; color: #0d1117 !important; }
|
||||
.bg-secondary { background-color: #8b949e !important; }
|
||||
|
||||
/* --- 連結 --- */
|
||||
a { color: #58a6ff; }
|
||||
a:hover { color: #80b6ff; }
|
||||
|
||||
/* 頁面切換的淡入效果 */
|
||||
@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } }
|
||||
main.container { animation: fadeIn 0.5s ease-in-out; }
|
||||
|
||||
/* --- 通知 (Toast) --- */
|
||||
.toast {
|
||||
background-color: #21262d; /* 使用比卡片稍亮的深色背景 */
|
||||
border: 1px solid #30363d;
|
||||
color: #c9d1d9;
|
||||
}
|
||||
|
||||
.toast-header {
|
||||
background-color: #161b22; /* 使用與卡片相同的深色背景 */
|
||||
color: #f0f6fc; /* 標題使用較亮的白色文字 */
|
||||
border-bottom: 1px solid #30363d;
|
||||
}
|
||||
|
||||
.toast-body {
|
||||
color: #c9d1d9; /* 內文使用標準的灰色文字 */
|
||||
}
|
||||
|
||||
/* 讓關閉按鈕在深色背景下可見 */
|
||||
.btn-close {
|
||||
filter: invert(1) grayscale(100%) brightness(200%);
|
||||
}
|
||||
|
||||
/* --- 列表群組 (List Group for History Page) --- */
|
||||
.list-group-flush .list-group-item {
|
||||
background-color: transparent; /* 在 card 中使用透明背景 */
|
||||
border-color: #30363d;
|
||||
}
|
||||
|
||||
.list-group-item {
|
||||
background-color: #161b22;
|
||||
border-color: #30363d;
|
||||
}
|
||||
|
||||
/* 確保列表內的文字顏色正確 */
|
||||
.list-group-item,
|
||||
.list-group-item p,
|
||||
.list-group-item small {
|
||||
color: #c9d1d9; /* 標準灰色文字 */
|
||||
}
|
||||
|
||||
/* 讓使用者名稱等重要文字更亮 */
|
||||
.list-group-item h5 strong {
|
||||
color: #f0f6fc;
|
||||
}
|
||||
|
||||
/* --- 剩餘天數標籤 (Days Remaining Badge) --- */
|
||||
.days-badge {
|
||||
padding: 0.3em 0.6em;
|
||||
border-radius: 0.375rem;
|
||||
font-weight: 500;
|
||||
font-size: 0.85em;
|
||||
color: #0d1117; /* 預設使用深色文字 */
|
||||
}
|
||||
|
||||
.days-safe {
|
||||
background-color: #2ea043; /* 綠色 */
|
||||
color: #ffffff; /* 搭配淺色文字 */
|
||||
}
|
||||
|
||||
.days-warning {
|
||||
background-color: #f0ad4e; /* 黃色 */
|
||||
}
|
||||
|
||||
.days-critical {
|
||||
background-color: #da3633; /* 紅色 */
|
||||
color: #ffffff; /* 搭配淺色文字 */
|
||||
}
|
||||
|
||||
.days-expired {
|
||||
background-color: #8b949e; /* 灰色 */
|
||||
color: #ffffff;
|
||||
}
|
338
swagger.json
Normal file
338
swagger.json
Normal file
@@ -0,0 +1,338 @@
|
||||
{
|
||||
"basePath": "/api",
|
||||
"consumes": [
|
||||
"application/json"
|
||||
],
|
||||
"definitions": {
|
||||
"Error": {
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"example": false,
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
},
|
||||
"User": {
|
||||
"properties": {
|
||||
"email": {
|
||||
"example": "sharnading@panjit.com.tw",
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"example": 1,
|
||||
"type": "integer"
|
||||
},
|
||||
"loginid": {
|
||||
"example": "92460",
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"example": "\u4e01\u4e8e\u8ed2",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"host": "0.0.0.0:12023",
|
||||
"info": {
|
||||
"contact": {
|
||||
"email": "it@panjit.com.tw",
|
||||
"name": "Panjit IT Department"
|
||||
},
|
||||
"description": "Panjit \u5bc6\u78bc\u7ba1\u7406\u7cfb\u7d71 API",
|
||||
"title": "Panjit Password Management API",
|
||||
"version": "1.0.0"
|
||||
},
|
||||
"paths": {
|
||||
"/change-password": {
|
||||
"post": {
|
||||
"description": "\u4fee\u6539\u7528\u6236\u7684\u767b\u5165\u5bc6\u78bc",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"properties": {
|
||||
"account": {
|
||||
"description": "\u7528\u6236\u5e33\u865f\uff08loginid \u6216 email\uff09",
|
||||
"example": "92460",
|
||||
"type": "string"
|
||||
},
|
||||
"new_password": {
|
||||
"description": "\u65b0\u5bc6\u78bc\uff086-50\u5b57\u5143\uff0c\u9700\u5305\u542b\u5b57\u6bcd\u548c\u6578\u5b57\uff09",
|
||||
"example": "MyNewPassword123",
|
||||
"type": "string"
|
||||
},
|
||||
"old_password": {
|
||||
"description": "\u820a\u5bc6\u78bc",
|
||||
"example": "Panjit92460",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"account",
|
||||
"old_password",
|
||||
"new_password"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "\u5bc6\u78bc\u4fee\u6539\u6210\u529f",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"400": {
|
||||
"description": "\u8acb\u6c42\u932f\u8aa4",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "\u820a\u5bc6\u78bc\u4e0d\u6b63\u78ba",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "\u4fee\u6539\u7528\u6236\u5bc6\u78bc",
|
||||
"tags": [
|
||||
"Password Management"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/health": {
|
||||
"get": {
|
||||
"description": "\u6aa2\u67e5 API \u670d\u52d9\u548c\u8cc7\u6599\u5eab\u9023\u7dda\u72c0\u614b",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "\u7cfb\u7d71\u6b63\u5e38",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"database": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"timestamp": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "\u7cfb\u7d71\u5065\u5eb7\u6aa2\u67e5",
|
||||
"tags": [
|
||||
"System"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/login": {
|
||||
"post": {
|
||||
"description": "\u4f7f\u7528\u5e33\u865f\u548c\u5bc6\u78bc\u9032\u884c\u767b\u5165\u9a57\u8b49",
|
||||
"parameters": [
|
||||
{
|
||||
"in": "body",
|
||||
"name": "body",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"properties": {
|
||||
"account": {
|
||||
"description": "\u7528\u6236\u5e33\u865f\uff08loginid \u6216 email\uff09",
|
||||
"example": "92460",
|
||||
"type": "string"
|
||||
},
|
||||
"password": {
|
||||
"description": "\u7528\u6236\u5bc6\u78bc",
|
||||
"example": "Panjit92460",
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"required": [
|
||||
"account",
|
||||
"password"
|
||||
],
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "\u767b\u5165\u6210\u529f",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"loginid": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"401": {
|
||||
"description": "\u767b\u5165\u5931\u6557",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "\u7528\u6236\u767b\u5165\u9a57\u8b49",
|
||||
"tags": [
|
||||
"Authentication"
|
||||
]
|
||||
}
|
||||
},
|
||||
"/user/{account}": {
|
||||
"get": {
|
||||
"description": "\u6839\u64da\u5e33\u865f\u67e5\u8a62\u7528\u6236\u57fa\u672c\u8cc7\u8a0a",
|
||||
"parameters": [
|
||||
{
|
||||
"description": "\u7528\u6236\u5e33\u865f\uff08loginid \u6216 email\uff09",
|
||||
"example": "92460",
|
||||
"in": "path",
|
||||
"name": "account",
|
||||
"required": true,
|
||||
"type": "string"
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "\u67e5\u8a62\u6210\u529f",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
},
|
||||
"user": {
|
||||
"properties": {
|
||||
"email": {
|
||||
"type": "string"
|
||||
},
|
||||
"id": {
|
||||
"type": "integer"
|
||||
},
|
||||
"loginid": {
|
||||
"type": "string"
|
||||
},
|
||||
"username": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
},
|
||||
"404": {
|
||||
"description": "\u7528\u6236\u4e0d\u5b58\u5728",
|
||||
"schema": {
|
||||
"properties": {
|
||||
"error_code": {
|
||||
"type": "string"
|
||||
},
|
||||
"message": {
|
||||
"type": "string"
|
||||
},
|
||||
"success": {
|
||||
"type": "boolean"
|
||||
}
|
||||
},
|
||||
"type": "object"
|
||||
}
|
||||
}
|
||||
},
|
||||
"summary": "\u67e5\u8a62\u7528\u6236\u8cc7\u8a0a",
|
||||
"tags": [
|
||||
"User Management"
|
||||
]
|
||||
}
|
||||
}
|
||||
},
|
||||
"produces": [
|
||||
"application/json"
|
||||
],
|
||||
"schemes": [
|
||||
"http",
|
||||
"https"
|
||||
],
|
||||
"swagger": "2.0"
|
||||
}
|
||||
|
62
tasks.py
Normal file
62
tasks.py
Normal file
@@ -0,0 +1,62 @@
|
||||
from datetime import date, timedelta
|
||||
from flask import current_app
|
||||
from models import TempSpec
|
||||
from utils import send_email, process_recipients
|
||||
|
||||
|
||||
def check_expiring_specs(app):
|
||||
"""Scheduled task: notify when specs will expire in 3 or 7 days."""
|
||||
with app.app_context():
|
||||
current_app.logger.info("Running scheduled task: Checking for expiring specs...")
|
||||
today = date.today()
|
||||
targets = {3, 7}
|
||||
|
||||
expiring_soon = TempSpec.query.filter(
|
||||
TempSpec.status == "active"
|
||||
).all()
|
||||
|
||||
if not expiring_soon:
|
||||
current_app.logger.info("No active specs found for expiry check.")
|
||||
return
|
||||
|
||||
configured_defaults = app.config.get("DEFAULT_NOTIFICATION_EMAILS", "")
|
||||
default_recipients = process_recipients(configured_defaults) if configured_defaults else []
|
||||
|
||||
for spec in expiring_soon:
|
||||
if not spec.end_date:
|
||||
continue
|
||||
remaining_days = (spec.end_date - today).days
|
||||
if remaining_days not in targets:
|
||||
continue
|
||||
|
||||
recipients_source = spec.notification_emails or configured_defaults
|
||||
recipients = process_recipients(recipients_source) if recipients_source else default_recipients
|
||||
|
||||
if not recipients:
|
||||
current_app.logger.warning(
|
||||
"Skip expiry reminder for %s - no recipients.", spec.spec_code
|
||||
)
|
||||
continue
|
||||
|
||||
subject = f"[TempSpec Reminder] '{spec.spec_code}' expires in {remaining_days} day(s)"
|
||||
body = f"""
|
||||
<html>
|
||||
<body>
|
||||
<p>Hello,</p>
|
||||
<p>This is an automated reminder.</p>
|
||||
<p>Temp spec <b>{spec.spec_code} - {spec.title}</b> will expire on {spec.end_date.strftime('%Y-%m-%d')}.</p>
|
||||
<p><b>Days remaining: {remaining_days}</b></p>
|
||||
<p>Applicant: {spec.applicant}</p>
|
||||
<p>Please sign in to the system if an extension is required.</p>
|
||||
<p>This message was sent automatically. Please do not reply.</p>
|
||||
</body>
|
||||
</html>
|
||||
"""
|
||||
|
||||
send_email(recipients, subject, body)
|
||||
current_app.logger.info(
|
||||
"Sent expiry reminder for spec %s to %d recipients.",
|
||||
spec.spec_code,
|
||||
len(recipients),
|
||||
)
|
||||
|
BIN
template_with_placeholders.docx
Normal file
BIN
template_with_placeholders.docx
Normal file
Binary file not shown.
12
templates/403.html
Normal file
12
templates/403.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}權限不足{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<h1 class="display-1">403</h1>
|
||||
<h2 class="mb-4">權限不足 (Forbidden)</h2>
|
||||
<p class="lead">抱歉,您沒有權限存取此頁面。</p>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-primary mt-3">返回總表</a>
|
||||
</div>
|
||||
{% endblock %}
|
12
templates/404.html
Normal file
12
templates/404.html
Normal file
@@ -0,0 +1,12 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}找不到頁面{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="container text-center py-5">
|
||||
<h1 class="display-1">404</h1>
|
||||
<h2 class="mb-4">找不到頁面 (Not Found)</h2>
|
||||
<p class="lead">抱歉,您要找的頁面不存在。</p>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-primary mt-3">返回總表</a>
|
||||
</div>
|
||||
{% endblock %}
|
41
templates/activate_spec.html
Normal file
41
templates/activate_spec.html
Normal file
@@ -0,0 +1,41 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}啟用暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">上傳簽核檔案以啟用規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<div class="alert alert-info">
|
||||
請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="signed_file" class="form-label"><strong>已簽核的 PDF 檔案</strong></label>
|
||||
<input class="form-control" type="file" id="signed_file" name="signed_file" accept=".pdf" required>
|
||||
</div>
|
||||
|
||||
<!-- 郵件通知對象選擇 -->
|
||||
<div class="mb-3">
|
||||
<label for="recipients" class="form-label"><strong>郵件通知對象</strong></label>
|
||||
<textarea class="form-control" id="recipients" name="recipients" rows="3" placeholder="mail1@example.com; mail2@example.com">{{ saved_emails or '' }}</textarea>
|
||||
<div class="form-text">請輸入完整郵件地址,多筆請以分號 (;) 分隔。</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-success">上傳並啟用</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 郵件通知輸入改為手動維護,以分號分隔多筆郵件。
|
||||
</script>
|
||||
{% endblock %}
|
98
templates/base.html
Normal file
98
templates/base.html
Normal file
@@ -0,0 +1,98 @@
|
||||
<!doctype html>
|
||||
<html lang="zh-Hant">
|
||||
<head>
|
||||
<meta charset="utf-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||
<title>{% block title %}暫時規範系統{% endblock %}</title>
|
||||
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/css/bootstrap.min.css" rel="stylesheet">
|
||||
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.3/font/bootstrap-icons.min.css">
|
||||
<link rel="stylesheet" href="{{ url_for('static', filename='css/style.css') }}">
|
||||
<!-- Toast UI Editor Core CSS -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor/latest/toastui-editor.min.css" />
|
||||
<!-- Plugins CSS -->
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/editor-plugin-table-merged-cell/latest/toastui-editor-plugin-table-merged-cell.min.css" />
|
||||
<link rel="stylesheet" href="https://uicdn.toast.com/tui-image-editor/latest/tui-image-editor.min.css">
|
||||
<!-- Tom Select CSS -->
|
||||
<link href="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/css/tom-select.bootstrap5.css" rel="stylesheet">
|
||||
</head>
|
||||
<body>
|
||||
<nav class="navbar navbar-expand-lg navbar-dark">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ url_for('temp_spec.spec_list') }}">暫時規範系統</a>
|
||||
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarNav">
|
||||
<ul class="navbar-nav ms-auto">
|
||||
{% if current_user.is_authenticated %}
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('temp_spec.create_temp_spec') }}">暫時規範建立</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('temp_spec.spec_list') }}">總表檢視</a>
|
||||
</li>
|
||||
{% if current_user.role == 'admin' %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('admin.user_list') }}">權限管理</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.logout') }}">登出</a>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="nav-item">
|
||||
<a class="nav-link" href="{{ url_for('auth.login') }}">登入</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<main class="container mt-4">
|
||||
{% block content %}{% endblock %}
|
||||
</main>
|
||||
|
||||
<!-- Toast 容器 -->
|
||||
<div class="toast-container position-fixed bottom-0 end-0 p-3">
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="toast" role="alert" aria-live="assertive" aria-atomic="true">
|
||||
<div class="toast-header">
|
||||
<i class="bi bi-bell-fill me-2"></i>
|
||||
<strong class="me-auto">通知</strong>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="toast" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="toast-body">
|
||||
{{ message }}
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
</div>
|
||||
|
||||
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.3/dist/js/bootstrap.bundle.min.js"></script>
|
||||
<!-- Toast UI Editor Dependencies & Core -->
|
||||
<script src="https://uicdn.toast.com/tui-color-picker/latest/tui-color-picker.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor/latest/toastui-editor-all.min.js"></script>
|
||||
<!-- Plugins JS -->
|
||||
<script src="https://uicdn.toast.com/editor-plugin-color-syntax/latest/toastui-editor-plugin-color-syntax.min.js"></script>
|
||||
<script src="https://uicdn.toast.com/editor-plugin-table-merged-cell/latest/toastui-editor-plugin-table-merged-cell.min.js"></script>
|
||||
<!-- Tom Select JS -->
|
||||
<script src="https://cdn.jsdelivr.net/npm/tom-select@2.3.1/dist/js/tom-select.complete.min.js"></script>
|
||||
|
||||
<script>
|
||||
// 啟用所有 Toast
|
||||
const toastElList = document.querySelectorAll('.toast');
|
||||
const toastList = [...toastElList].map(toastEl => new bootstrap.Toast(toastEl).show());
|
||||
</script>
|
||||
|
||||
{% block scripts %}{% endblock %}
|
||||
</body>
|
||||
</html>
|
83
templates/create_temp_spec_form.html
Normal file
83
templates/create_temp_spec_form.html
Normal file
@@ -0,0 +1,83 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}建立新的暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-10">
|
||||
<h2 class="mb-4">建立新的暫時規範</h2>
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form id="spec-form" method="post">
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="theme" class="form-label">主題/目的</label>
|
||||
<input type="text" class="form-control" id="theme" name="theme" required>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="applicant" class="form-label">申請者</label>
|
||||
<input type="text" class="form-control" id="applicant" name="applicant" value="{{ current_user.name }}" readonly>
|
||||
</div>
|
||||
<div class="col-md-4 mb-3">
|
||||
<label for="applicant_phone" class="form-label">電話(分機)</label>
|
||||
<input type="text" class="form-control" id="applicant_phone" name="applicant_phone">
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label class="form-label">站別 (可多選)</label>
|
||||
<div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="probing"><label>點測</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="dicing"><label>切割</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="diebond"><label>晶粒黏著</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="wirebond"><label>銲線黏著</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="solder"><label>錫膏焊接</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="molding"><label>成型</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="degate"><label>去膠</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="deflash"><label>吹砂</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="plating"><label>電鍍</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="trimform"><label>切彎腳</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="marking"><label>印字</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="tmtt"><label>測試</label></div>
|
||||
<div class="form-check form-check-inline"><input class="form-check-input" type="checkbox" name="station" value="other"><label>其他</label></div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-6 mb-3">
|
||||
<label class="form-label">TCCS Level</label>
|
||||
<div class="input-group">
|
||||
<select class="form-select" name="tccs_level">
|
||||
<option selected value="">請選擇 Level...</option>
|
||||
<option value="l1">Level 1</option>
|
||||
<option value="l2">Level 2</option>
|
||||
<option value="l3">Level 3</option>
|
||||
<option value="l4">Level 4</option>
|
||||
</select>
|
||||
<div class="input-group-text">
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="man"><label>人</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="machine"><label>機</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="material"><label>料</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="method"><label>法</label></div>
|
||||
<div class="form-check form-check-inline mb-0"><input class="form-check-input" type="radio" name="tccs_4m" value="env"><label>環</label></div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="row">
|
||||
<div class="col-md-4 mb-3"><label for="package" class="form-label">Package</label><input type="text" class="form-control" id="package" name="package"></div>
|
||||
<div class="col-md-4 mb-3"><label for="lot_number" class="form-label">工單批號</label><input type="text" class="form-control" id="lot_number" name="lot_number"></div>
|
||||
<div class="col-md-4 mb-3"><label for="equipment_type" class="form-label">設備型(編)號</label><input type="text" class="form-control" id="equipment_type" name="equipment_type"></div>
|
||||
</div>
|
||||
|
||||
<div class="d-flex justify-content-end">
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary me-2">取消</a>
|
||||
<button type="submit" class="btn btn-primary">建立並開始編輯</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
59
templates/extend_spec.html
Normal file
59
templates/extend_spec.html
Normal file
@@ -0,0 +1,59 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}展延暫時規範{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">展延暫時規範</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
規範編號: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post" enctype="multipart/form-data">
|
||||
<p><strong>主題:</strong> {{ spec.title }}</p>
|
||||
<p><strong>原結束日期:</strong> {{ spec.end_date|taiwan_date }}</p>
|
||||
<p><strong>展延次數:</strong>
|
||||
<span class="badge bg-info">{{ spec.extension_count }} / 2</span>
|
||||
<small class="text-muted ms-2">
|
||||
剩餘可展延次數: <strong>{{ 2 - spec.extension_count }}</strong> 次
|
||||
</small>
|
||||
</p>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_end_date" class="form-label"><strong>新的結束日期</strong></label>
|
||||
<input type="date" class="form-control" id="new_end_date" name="new_end_date"
|
||||
value="{{ default_new_end_date|taiwan_date }}" required>
|
||||
<div class="form-text">請輸入完整郵件地址,多筆請以分號 (;) 分隔。</div>
|
||||
</div>
|
||||
|
||||
<div class="mb-3">
|
||||
<label for="new_file" class="form-label"><strong>重新上傳佐證檔案 (必填)</strong></label>
|
||||
<input class="form-control" type="file" id="new_file" name="new_file" accept=".pdf" required>
|
||||
<div class="form-text">請上傳展延申請的相關佐證文件 (PDF 格式)。</div>
|
||||
</div>
|
||||
|
||||
<!-- 郵件通知對象選擇 -->
|
||||
<div class="mb-3">
|
||||
<label for="recipients" class="form-label"><strong>郵件通知對象</strong></label>
|
||||
{% if saved_emails %}
|
||||
<div class="alert alert-info mb-2">
|
||||
<small>以下為先前儲存的通知清單,可直接調整或重新輸入。</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<textarea class="form-control" id="recipients" name="recipients" rows="3" placeholder="mail1@example.com; mail2@example.com">{{ saved_emails or '' }}</textarea>
|
||||
<div class="form-text">請輸入完整郵件地址,多筆請以分號 (;) 分隔。</div>
|
||||
</div>
|
||||
|
||||
<button type="submit" class="btn btn-primary">確認展延</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">取消</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// 郵件通知輸入改為分號分隔字串,無需額外初始化。
|
||||
</script>
|
||||
{% endblock %}
|
43
templates/login.html
Normal file
43
templates/login.html
Normal file
@@ -0,0 +1,43 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}登入 - 暫規系統{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-4">
|
||||
<h2 class="text-center mb-4">登入</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category or 'danger' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">帳號</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="請輸入帳號" value="{{ username or '' }}" required>
|
||||
<div class="form-text text-light fw-bold">帳號可使用公司信箱或指定字串。</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button type="submit" class="btn btn-primary">登入</button>
|
||||
<a href="{{ url_for('auth.register') }}" class="btn btn-link">建立新帳號</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
39
templates/onlyoffice_editor.html
Normal file
39
templates/onlyoffice_editor.html
Normal file
@@ -0,0 +1,39 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-3">
|
||||
<div>
|
||||
<h2 class="mb-0">正在編輯: {{ spec.spec_code }}</h2>
|
||||
<p class="lead text-muted">主題: {{ spec.title }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary"><i class="bi bi-arrow-left-circle me-2"></i>返回總表</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body p-0" style="height: 85vh;">
|
||||
<div id="placeholder"></div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script type="text/javascript" src="{{ onlyoffice_url }}web-apps/apps/api/documents/api.js"></script>
|
||||
|
||||
<script>
|
||||
document.addEventListener('DOMContentLoaded', function() {
|
||||
// 從後端接收的設定
|
||||
const config = {{ config|tojson|safe }};
|
||||
|
||||
// 建立 DocEditor 物件
|
||||
const docEditor = new DocsAPI.DocEditor("placeholder", config);
|
||||
|
||||
// 您可以在這裡加入更多事件處理,例如:
|
||||
// config.events = {
|
||||
// 'onAppReady': function() { console.log('Editor is ready'); },
|
||||
// 'onDocumentStateChange': function(event) { console.log('Document state changed'); },
|
||||
// };
|
||||
});
|
||||
</script>
|
||||
{% endblock %}
|
53
templates/register.html
Normal file
53
templates/register.html
Normal file
@@ -0,0 +1,53 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}註冊帳號 - 暫規系統{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="row justify-content-center">
|
||||
<div class="col-md-6 col-lg-5">
|
||||
<h2 class="text-center mb-4">建立新帳號</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category or 'danger' }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<div class="mb-3">
|
||||
<label for="username" class="form-label">帳號</label>
|
||||
<input type="text" class="form-control" id="username" name="username"
|
||||
placeholder="請輸入帳號" value="{{ username or '' }}" required>
|
||||
<div class="form-text">建議以公司信箱為帳號,避免重複。</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="name" class="form-label">姓名</label>
|
||||
<input type="text" class="form-control" id="name" name="name"
|
||||
placeholder="請輸入姓名" value="{{ name or '' }}" required>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="password" class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="password" name="password" required>
|
||||
<div class="form-text">密碼需至少 6 碼。</div>
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="confirm_password" class="form-label">確認密碼</label>
|
||||
<input type="password" class="form-control" id="confirm_password" name="confirm_password" required>
|
||||
</div>
|
||||
<div class="d-flex justify-content-between align-items-center">
|
||||
<button type="submit" class="btn btn-success">註冊</button>
|
||||
<a href="{{ url_for('auth.login') }}" class="btn btn-link">回登入頁</a>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
34
templates/spec_history.html
Normal file
34
templates/spec_history.html
Normal file
@@ -0,0 +1,34 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<div>
|
||||
<h2 class="mb-0">操作歷史紀錄</h2>
|
||||
<p class="lead text-muted">規範編號: {{ spec.spec_code }}</p>
|
||||
</div>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary"><i class="bi bi-arrow-left-circle me-2"></i>返回總表</a>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<ul class="list-group list-group-flush">
|
||||
{% for entry in history %}
|
||||
<li class="list-group-item">
|
||||
<div class="d-flex w-100 justify-content-between">
|
||||
<h5 class="mb-1">
|
||||
<span class="badge bg-primary rounded-pill me-2">{{ entry.action }}</span>
|
||||
由 <strong>{{ entry.user.name if entry.user and entry.user.name else (entry.user.username if entry.user else '[已刪除的使用者]') }}</strong> 執行
|
||||
</h5>
|
||||
<small>{{ entry.timestamp|taiwan_time }}</small>
|
||||
</div>
|
||||
<p class="mb-1 mt-2">{{ entry.details }}</p>
|
||||
</li>
|
||||
{% else %}
|
||||
<li class="list-group-item">沒有任何歷史紀錄。</li>
|
||||
{% endfor %}
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
169
templates/spec_list.html
Normal file
169
templates/spec_list.html
Normal file
@@ -0,0 +1,169 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}臨時規格清單{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<div class="d-flex justify-content-between align-items-center mb-4">
|
||||
<h2 class="mb-0">臨時規格清單</h2>
|
||||
{% if current_user.role in ['editor','admin'] %}
|
||||
<a href="{{ url_for('temp_spec.create_temp_spec') }}" class="btn btn-primary">
|
||||
<i class="bi bi-plus-circle-fill me-2"></i>暫時規範建立
|
||||
</a>
|
||||
{% endif %}
|
||||
</div>
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-body">
|
||||
<form method="get" action="{{ url_for('temp_spec.spec_list') }}" class="row g-3 align-items-center">
|
||||
<div class="col-md-6">
|
||||
<div class="input-group">
|
||||
<span class="input-group-text"><i class="bi bi-search"></i></span>
|
||||
<input type="text" name="query" class="form-control" placeholder="依規格編號或標題搜尋..." value="{{ query or '' }}">
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<select name="status" class="form-select">
|
||||
<option value="">所有狀態</option>
|
||||
<option value="pending_approval" {% if status == 'pending_approval' %}selected{% endif %}>待核准</option>
|
||||
<option value="active" {% if status == 'active' %}selected{% endif %}>生效中</option>
|
||||
<option value="terminated" {% if status == 'terminated' %}selected{% endif %}>已終止</option>
|
||||
<option value="expired" {% if status == 'expired' %}selected{% endif %}>已過期</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<button type="submit" class="btn btn-primary w-100">篩選</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-body">
|
||||
<div class="table-responsive">
|
||||
<table class="table table-hover table-striped align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>規格編號</th>
|
||||
<th>標題</th>
|
||||
<th>申請人</th>
|
||||
<th>建立日期</th>
|
||||
<th>結束日期</th>
|
||||
<th class="text-center">剩餘天數</th>
|
||||
<th class="text-center">狀態</th>
|
||||
<th class="text-center">操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for spec in specs %}
|
||||
<tr>
|
||||
<td>{{ spec.spec_code }}</td>
|
||||
<td>{{ spec.title }}</td>
|
||||
<td>{{ spec.applicant }}</td>
|
||||
<td>{{ spec.created_at|taiwan_date }}</td>
|
||||
<td>{{ spec.end_date|taiwan_date }}</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if spec.status in ['active', 'expired'] %}
|
||||
{% set remaining_days = (spec.end_date - today).days %}
|
||||
{% if remaining_days < 0 %}
|
||||
{% set color_class = 'days-expired' %}
|
||||
{% elif remaining_days <= 3 %}
|
||||
{% set color_class = 'days-critical' %}
|
||||
{% elif remaining_days <= 7 %}
|
||||
{% set color_class = 'days-warning' %}
|
||||
{% else %}
|
||||
{% set color_class = 'days-safe' %}
|
||||
{% endif %}
|
||||
<span class="days-badge {{ color_class }}">
|
||||
{{ remaining_days if remaining_days >= 0 else '已過期' }}
|
||||
</span>
|
||||
{% else %}
|
||||
<span>-</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if spec.status == 'active' %}
|
||||
<span class="badge fs-6 bg-success bg-opacity-75"><i class="bi bi-check-circle-fill me-1"></i>生效中</span>
|
||||
{% elif spec.status == 'pending_approval' %}
|
||||
<span class="badge fs-6 bg-info bg-opacity-75 text-dark"><i class="bi bi-hourglass-split me-1"></i>待核准</span>
|
||||
{% elif spec.status == 'terminated' %}
|
||||
<span class="badge fs-6 bg-warning bg-opacity-75 text-dark"><i class="bi bi-slash-circle-fill me-1"></i>已終止</span>
|
||||
{% else %}
|
||||
<span class="badge fs-6 bg-secondary bg-opacity-75"><i class="bi bi-calendar-x-fill me-1"></i>已過期</span>
|
||||
{% endif %}
|
||||
|
||||
{% if spec.extension_count > 0 %}
|
||||
<br>
|
||||
<div class="mt-1">
|
||||
<span class="badge bg-light text-dark border">
|
||||
<i class="bi bi-arrow-repeat me-1"></i>已延期 {{ spec.extension_count }} 次
|
||||
</span>
|
||||
{% if spec.extension_count >= 2 %}
|
||||
<br><span class="badge bg-danger mt-1">已達延期上限</span>
|
||||
{% endif %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</td>
|
||||
|
||||
<td class="text-center">
|
||||
{% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.edit_spec', spec_id=spec.id) }}" class="btn btn-sm btn-warning" title="編輯"><i class="bi bi-pencil-fill"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' and spec.status == 'pending_approval' %}
|
||||
<a href="{{ url_for('temp_spec.activate_spec', spec_id=spec.id) }}" class="btn btn-sm btn-primary" title="核准"><i class="bi bi-check2-circle"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %}
|
||||
{% if spec.extension_count < 2 %}
|
||||
<a href="{{ url_for('temp_spec.extend_spec', spec_id=spec.id) }}" class="btn btn-sm btn-secondary" title="延期"><i class="bi bi-calendar-plus"></i></a>
|
||||
{% else %}
|
||||
<button class="btn btn-sm btn-secondary" disabled title="已達延期上限(2次)"><i class="bi bi-calendar-x"></i></button>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('temp_spec.terminate_spec', spec_id=spec.id) }}" class="btn btn-sm btn-danger" title="終止"><i class="bi bi-x-circle"></i></a>
|
||||
{% endif %}
|
||||
|
||||
{% if current_user.role == 'admin' %}
|
||||
<form action="{{ url_for('temp_spec.delete_spec', spec_id=spec.id) }}" method="post" class="d-inline" onsubmit="return confirm('確定要刪除此規格和相關檔案嗎?此操作無法復原。');">
|
||||
<button type="submit" class="btn btn-sm btn-danger" title="刪除"><i class="bi bi-trash-fill"></i></button>
|
||||
</form>
|
||||
{% endif %}
|
||||
|
||||
{% if spec.status == 'pending_approval' %}
|
||||
{% if current_user.role in ['editor', 'admin'] %}
|
||||
<a href="{{ url_for('temp_spec.download_initial_word', spec_id=spec.id) }}" class="btn btn-sm btn-primary" title="下載Word文件"><i class="bi bi-file-earmark-word-fill"></i></a>
|
||||
{% endif %}
|
||||
{% elif spec.uploads %}
|
||||
<a href="{{ url_for('temp_spec.download_signed_pdf', spec_id=spec.id) }}" class="btn btn-sm btn-success" title="下載已簽核PDF"><i class="bi bi-file-earmark-check-fill"></i></a>
|
||||
{% endif %}
|
||||
<a href="{{ url_for('temp_spec.spec_history', spec_id=spec.id) }}" class="btn btn-sm btn-outline-secondary" title="歷史記錄"><i class="bi bi-clock-history"></i></a>
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-footer">
|
||||
<nav aria-label="Page navigation">
|
||||
<ul class="pagination justify-content-center mb-0">
|
||||
<li class="page-item {% if not pagination.has_prev %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=pagination.prev_num, query=query, status=status) }}">上一頁</a>
|
||||
</li>
|
||||
{% for page_num in pagination.iter_pages() %}
|
||||
{% if page_num %}
|
||||
<li class="page-item {% if page_num == pagination.page %}active{% endif %}"><a class="page-link" href="{{ url_for('temp_spec.spec_list', page=page_num, query=query, status=status) }}">{{ page_num }}</a></li>
|
||||
{% else %}
|
||||
<li class="page-item disabled"><span class="page-link">...</span></li>
|
||||
{% endif %}
|
||||
{% endfor %}
|
||||
<li class="page-item {% if not pagination.has_next %}disabled{% endif %}">
|
||||
<a class="page-link" href="{{ url_for('temp_spec.spec_list', page=pagination.next_num, query=query, status=status) }}">下一頁</a>
|
||||
</li>
|
||||
</ul>
|
||||
</nav>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
46
templates/terminate_spec.html
Normal file
46
templates/terminate_spec.html
Normal file
@@ -0,0 +1,46 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}?蝯??急?閬?{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">?蝯??急?閬?</h2>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
閬?蝺刻?: <strong>{{ spec.spec_code }}</strong>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form method="post">
|
||||
<p><strong>銝駁?:</strong> {{ spec.title }}</p>
|
||||
<div class="alert alert-warning">
|
||||
?瑁?甇斗?雿????喟?甇a遢?急?閬?嚗???霈?歇蝯迫??蝯??交???啁隞予??
|
||||
</div>
|
||||
<div class="mb-3">
|
||||
<label for="reason" class="form-label"><strong>?蝯???</strong></label>
|
||||
<textarea class="form-control" id="reason" name="reason" rows="4" required></textarea>
|
||||
</div>
|
||||
|
||||
<!-- ?萎辣?撠情?豢? -->
|
||||
<div class="mb-3">
|
||||
<label for="recipients" class="form-label"><strong>?萎辣?撠情</strong></label>
|
||||
{% if saved_emails %}
|
||||
<div class="alert alert-info mb-2">
|
||||
<small>以下為先前儲存的通知清單,可直接調整或重新輸入。</small>
|
||||
</div>
|
||||
{% endif %}
|
||||
<textarea class="form-control" id="recipients" name="recipients" rows="3" placeholder="mail1@example.com; mail2@example.com">{{ saved_emails or '' }}</textarea>
|
||||
<div class="form-text">請輸入完整郵件地址,多筆請以分號 (;) 分隔。</div>
|
||||
|
||||
<button type="submit" class="btn btn-danger">蝣箄?蝯迫</button>
|
||||
<a href="{{ url_for('temp_spec.spec_list') }}" class="btn btn-secondary">??</a>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
||||
|
||||
{% block scripts %}
|
||||
<script>
|
||||
// ?萎辣??寧??頛詨?????隞嗅?銝脯?
|
||||
</script>
|
||||
{% endblock %}
|
||||
|
172
templates/user_management.html
Normal file
172
templates/user_management.html
Normal file
@@ -0,0 +1,172 @@
|
||||
{% extends "base.html" %}
|
||||
|
||||
{% block title %}帳號管理{% endblock %}
|
||||
|
||||
{% block content %}
|
||||
<h2 class="mb-4">帳號管理</h2>
|
||||
|
||||
{% with messages = get_flashed_messages(with_categories=true) %}
|
||||
{% if messages %}
|
||||
{% for category, message in messages %}
|
||||
<div class="alert alert-{{ category }} alert-dismissible fade show" role="alert">
|
||||
{{ message }}
|
||||
<button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>
|
||||
</div>
|
||||
{% endfor %}
|
||||
{% endif %}
|
||||
{% endwith %}
|
||||
|
||||
<div class="card mb-4">
|
||||
<div class="card-header bg-primary text-white">
|
||||
<i class="bi bi-person-plus-fill"></i> 新增帳號
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<form action="{{ url_for('admin.create_user') }}" method="post" class="row g-3">
|
||||
<div class="col-md-4">
|
||||
<label for="new-username" class="form-label">帳號</label>
|
||||
<input type="text" class="form-control" id="new-username" name="username" placeholder="例如:user@example.com" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="new-name" class="form-label">姓名</label>
|
||||
<input type="text" class="form-control" id="new-name" name="name" required>
|
||||
</div>
|
||||
<div class="col-md-3">
|
||||
<label for="new-password" class="form-label">密碼</label>
|
||||
<input type="password" class="form-control" id="new-password" name="password" minlength="6" required>
|
||||
<div class="form-text">至少 6 碼。</div>
|
||||
</div>
|
||||
<div class="col-md-2">
|
||||
<label for="new-role" class="form-label">角色</label>
|
||||
<select class="form-select" id="new-role" name="role">
|
||||
<option value="viewer" selected>檢視 (Viewer)</option>
|
||||
<option value="editor">編輯 (Editor)</option>
|
||||
<option value="admin">管理 (Admin)</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="col-12 text-end">
|
||||
<button type="submit" class="btn btn-success">
|
||||
<i class="bi bi-check-lg"></i> 建立帳號
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card">
|
||||
<div class="card-header bg-secondary text-white">
|
||||
<i class="bi bi-people-fill"></i> 現有帳號
|
||||
</div>
|
||||
<div class="card-body">
|
||||
{% if users %}
|
||||
<div class="table-responsive">
|
||||
<table class="table table-striped table-hover align-middle">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>ID</th>
|
||||
<th>帳號</th>
|
||||
<th>姓名</th>
|
||||
<th>角色</th>
|
||||
<th>上次登入</th>
|
||||
<th>重設密碼</th>
|
||||
<th>操作</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{% for user in users %}
|
||||
<tr {% if user.id == current_user.id %}class="table-warning"{% endif %}>
|
||||
<td>{{ user.id }}</td>
|
||||
<td>
|
||||
{{ user.username }}
|
||||
{% if user.id == current_user.id %}
|
||||
<span class="badge bg-info ms-1">目前使用者</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="form-control form-control-sm" name="name" value="{{ user.name }}" form="update-user-{{ user.id }}" required>
|
||||
</td>
|
||||
<td>
|
||||
<select name="role" class="form-select form-select-sm" form="update-user-{{ user.id }}">
|
||||
<option value="viewer" {% if user.role == 'viewer' %}selected{% endif %}>檢視</option>
|
||||
<option value="editor" {% if user.role == 'editor' %}selected{% endif %}>編輯</option>
|
||||
<option value="admin" {% if user.role == 'admin' %}selected{% endif %}>管理</option>
|
||||
</select>
|
||||
</td>
|
||||
<td>
|
||||
{% if user.last_login %}
|
||||
{{ user.last_login|taiwan_time('%Y-%m-%d %H:%M') }}
|
||||
{% else %}
|
||||
<span class="text-muted">從未登入</span>
|
||||
{% endif %}
|
||||
</td>
|
||||
<td>
|
||||
<input type="password" class="form-control form-control-sm" name="password" placeholder="留空不變" form="update-user-{{ user.id }}" minlength="6">
|
||||
</td>
|
||||
<td class="text-nowrap">
|
||||
<form id="update-user-{{ user.id }}" action="{{ url_for('admin.update_user', user_id=user.id) }}" method="post" class="d-inline"></form>
|
||||
<button type="submit" class="btn btn-outline-primary btn-sm me-1" form="update-user-{{ user.id }}">
|
||||
<i class="bi bi-save"></i> 儲存
|
||||
</button>
|
||||
{% if user.id != current_user.id %}
|
||||
<form action="{{ url_for('admin.delete_user', user_id=user.id) }}" method="post" class="d-inline" onsubmit="return confirm('確定要刪除 {{ user.username }} 嗎?此動作無法復原。');">
|
||||
<button type="submit" class="btn btn-outline-danger btn-sm">
|
||||
<i class="bi bi-trash"></i>
|
||||
</button>
|
||||
</form>
|
||||
{% else %}
|
||||
<button type="button" class="btn btn-outline-secondary btn-sm" disabled title="無法刪除自己的帳號">
|
||||
<i class="bi bi-shield-lock"></i>
|
||||
</button>
|
||||
{% endif %}
|
||||
</td>
|
||||
</tr>
|
||||
{% endfor %}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{% else %}
|
||||
<div class="alert alert-info">
|
||||
<i class="bi bi-info-circle"></i> 目前尚無帳號紀錄。
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card mt-4">
|
||||
<div class="card-header">
|
||||
<i class="bi bi-info-circle"></i> 角色說明
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="row">
|
||||
<div class="col-md-4">
|
||||
<h6><span class="badge bg-secondary"><i class="bi bi-eye-fill"></i> 檢視 (Viewer)</span></h6>
|
||||
<ul class="small mb-0">
|
||||
<li>登入系統</li>
|
||||
<li>檢視暫規清單</li>
|
||||
<li>下載已核准的 PDF 檔案</li>
|
||||
<li>查看歷史紀錄</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><span class="badge bg-warning text-dark"><i class="bi bi-pencil-fill"></i> 編輯 (Editor)</span></h6>
|
||||
<ul class="small mb-0">
|
||||
<li>包含 Viewer 權限</li>
|
||||
<li>建立暫規申請</li>
|
||||
<li>編輯暫規內容</li>
|
||||
<li>展延與終止暫規</li>
|
||||
<li>下載 Word 編輯檔</li>
|
||||
</ul>
|
||||
</div>
|
||||
<div class="col-md-4">
|
||||
<h6><span class="badge bg-danger"><i class="bi bi-shield-fill"></i> 管理 (Admin)</span></h6>
|
||||
<ul class="small mb-0">
|
||||
<li>包含 Editor 權限</li>
|
||||
<li>核准待審暫規</li>
|
||||
<li>管理帳號與角色</li>
|
||||
<li>刪除暫規</li>
|
||||
<li>系統設定維護</li>
|
||||
</ul>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endblock %}
|
6
tmp_decompiled/temp_spec.cpython-312.py
Normal file
6
tmp_decompiled/temp_spec.cpython-312.py
Normal file
@@ -0,0 +1,6 @@
|
||||
# decompyle3 version 3.9.2
|
||||
# Python bytecode version base 3.12.0 (3531)
|
||||
# Decompiled from: Python 3.13.7 (tags/v3.13.7:bcee1c3, Aug 14 2025, 14:15:11) [MSC v.1944 64 bit (AMD64)]
|
||||
# Embedded file name: C:\Users\EGG\WORK\data\user_scrip\TOOL\TEMP_spec_system_V3\routes\temp_spec.py
|
||||
# Compiled at: 2025-08-28 11:20:30
|
||||
# Size of source mod 2**32: 20690 bytes
|
37
update_admin.py
Normal file
37
update_admin.py
Normal file
@@ -0,0 +1,37 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
更新使用者權限為管理員的腳本
|
||||
"""
|
||||
|
||||
from models import db, User
|
||||
from app import app
|
||||
|
||||
def update_user_to_admin(username):
|
||||
"""將指定使用者設為管理員權限"""
|
||||
with app.app_context():
|
||||
user = User.query.filter_by(username=username).first()
|
||||
if user:
|
||||
print(f'找到使用者: {user.username}')
|
||||
print(f'當前權限: {user.role}')
|
||||
|
||||
if user.role != 'admin':
|
||||
user.role = 'admin'
|
||||
db.session.commit()
|
||||
print(f'權限已更新為: {user.role}')
|
||||
else:
|
||||
print('使用者已經是管理員權限')
|
||||
return True
|
||||
else:
|
||||
print(f'使用者 {username} 不存在')
|
||||
return False
|
||||
|
||||
if __name__ == "__main__":
|
||||
username = 'ymirliu@panjit.com.tw'
|
||||
print(f"正在更新使用者 {username} 的權限...")
|
||||
|
||||
success = update_user_to_admin(username)
|
||||
|
||||
if success:
|
||||
print("\n管理員權限設定完成!")
|
||||
else:
|
||||
print("\n請先使用該帳號登入一次以建立使用者記錄")
|
144
utils/__init__.py
Normal file
144
utils/__init__.py
Normal file
@@ -0,0 +1,144 @@
|
||||
"""Utility helpers used across the application."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
import smtplib
|
||||
from email.header import Header
|
||||
from email.mime.text import MIMEText
|
||||
from functools import wraps
|
||||
from typing import Callable, Iterable, List
|
||||
|
||||
from flask import abort, current_app
|
||||
from flask_login import current_user
|
||||
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Access control decorators
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def _require_roles(roles: Iterable[str]) -> Callable:
|
||||
"""Abort with 403 when the current user does not own one of the roles."""
|
||||
|
||||
def decorator(view_func: Callable) -> Callable:
|
||||
@wraps(view_func)
|
||||
def wrapper(*args, **kwargs):
|
||||
if not current_user.is_authenticated or current_user.role not in roles:
|
||||
abort(403)
|
||||
return view_func(*args, **kwargs)
|
||||
|
||||
return wrapper
|
||||
|
||||
return decorator
|
||||
|
||||
|
||||
def admin_required(view_func: Callable) -> Callable:
|
||||
"""Limit a view to administrator accounts only."""
|
||||
|
||||
return _require_roles({"admin"})(view_func)
|
||||
|
||||
|
||||
def editor_or_admin_required(view_func: Callable) -> Callable:
|
||||
"""Limit a view to editor or administrator accounts."""
|
||||
|
||||
return _require_roles({"editor", "admin"})(view_func)
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# History helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def add_history_log(spec_id: int, action: str, details: str = "") -> None:
|
||||
"""Persist a history record for an action executed on a spec."""
|
||||
|
||||
from models import SpecHistory, db
|
||||
entry = SpecHistory(
|
||||
spec_id=spec_id,
|
||||
user_id=current_user.id if current_user.is_authenticated else None,
|
||||
action=action,
|
||||
details=details,
|
||||
)
|
||||
db.session.add(entry)
|
||||
db.session.commit()
|
||||
|
||||
|
||||
# ---------------------------------------------------------------------------
|
||||
# Notification helpers
|
||||
# ---------------------------------------------------------------------------
|
||||
|
||||
def process_recipients(recipients_str: str | None) -> List[str]:
|
||||
"""Convert a semicolon separated string into a list of unique addresses."""
|
||||
|
||||
if not recipients_str:
|
||||
return []
|
||||
|
||||
normalized = recipients_str.replace("\r", ";").replace("\n", ";")
|
||||
candidates = [item.strip() for item in normalized.split(";") if item.strip()]
|
||||
|
||||
seen: set[str] = set()
|
||||
result: List[str] = []
|
||||
for email in candidates:
|
||||
if email not in seen:
|
||||
seen.add(email)
|
||||
result.append(email)
|
||||
return result
|
||||
|
||||
|
||||
def send_email(to_addrs: Iterable[str], subject: str, body: str) -> bool:
|
||||
"""Send an HTML email using the SMTP configuration in Flask config."""
|
||||
|
||||
recipients = list(to_addrs)
|
||||
if not recipients:
|
||||
logger.info("send_email skipped: no recipients provided")
|
||||
return False
|
||||
|
||||
cfg = current_app.config
|
||||
smtp_server = cfg.get("SMTP_SERVER")
|
||||
smtp_port = int(cfg.get("SMTP_PORT", 25))
|
||||
use_tls = bool(cfg.get("SMTP_USE_TLS", False))
|
||||
use_ssl = bool(cfg.get("SMTP_USE_SSL", False))
|
||||
sender = cfg.get("SMTP_SENDER_EMAIL")
|
||||
password = cfg.get("SMTP_SENDER_PASSWORD", "")
|
||||
auth_required = bool(cfg.get("SMTP_AUTH_REQUIRED", False))
|
||||
|
||||
logger.debug(
|
||||
"Preparing email: subject=%s recipients=%s server=%s:%s use_tls=%s use_ssl=%s auth_required=%s",
|
||||
subject,
|
||||
recipients,
|
||||
smtp_server,
|
||||
smtp_port,
|
||||
use_tls,
|
||||
use_ssl,
|
||||
auth_required,
|
||||
)
|
||||
|
||||
message = MIMEText(body, "html", "utf-8")
|
||||
message["Subject"] = Header(subject, "utf-8")
|
||||
message["From"] = sender
|
||||
message["To"] = ", ".join(recipients)
|
||||
|
||||
try:
|
||||
if use_ssl and smtp_port == 465:
|
||||
server = smtplib.SMTP_SSL(smtp_server, smtp_port)
|
||||
else:
|
||||
server = smtplib.SMTP(smtp_server, smtp_port)
|
||||
|
||||
if use_tls and smtp_port == 587:
|
||||
server.starttls()
|
||||
|
||||
if auth_required and password:
|
||||
server.login(sender, password)
|
||||
|
||||
server.sendmail(sender, recipients, message.as_string())
|
||||
server.quit()
|
||||
logger.info("Email sent to %s (subject=%s)", recipients, subject)
|
||||
return True
|
||||
|
||||
except smtplib.SMTPException as exc:
|
||||
logger.error("SMTP error while sending email: %s", exc)
|
||||
except Exception as exc: # pragma: no cover
|
||||
logger.exception("Unexpected error while sending email: %s", exc)
|
||||
|
||||
return False
|
60
utils/timezone.py
Normal file
60
utils/timezone.py
Normal file
@@ -0,0 +1,60 @@
|
||||
"""Timezone utilities used by the temp spec application."""
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from datetime import datetime, date, time, timedelta, timezone
|
||||
|
||||
TAIWAN_TZ = timezone(timedelta(hours=8))
|
||||
|
||||
|
||||
def now_taiwan() -> datetime:
|
||||
"""Return current datetime in Taiwan time (aware)."""
|
||||
return datetime.now(TAIWAN_TZ)
|
||||
|
||||
|
||||
def now_utc() -> datetime:
|
||||
"""Return current datetime in UTC (aware)."""
|
||||
return datetime.now(timezone.utc)
|
||||
|
||||
|
||||
def taiwan_now() -> datetime:
|
||||
"""Return current datetime in Taiwan time (naive)."""
|
||||
return now_taiwan().replace(tzinfo=None)
|
||||
|
||||
|
||||
def to_taiwan_time(dt: datetime | date | None) -> datetime | None:
|
||||
"""Convert a datetime/date (naive or aware) to Taiwan time (aware)."""
|
||||
if dt is None:
|
||||
return None
|
||||
# Promote date to datetime
|
||||
if isinstance(dt, date) and not isinstance(dt, datetime):
|
||||
dt = datetime.combine(dt, time.min)
|
||||
# Treat naive timestamps as UTC
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=timezone.utc)
|
||||
return dt.astimezone(TAIWAN_TZ)
|
||||
|
||||
|
||||
def to_utc_time(dt: datetime | date | None) -> datetime | None:
|
||||
"""Convert a datetime/date (naive or aware) to UTC (aware)."""
|
||||
if dt is None:
|
||||
return None
|
||||
if isinstance(dt, date) and not isinstance(dt, datetime):
|
||||
dt = datetime.combine(dt, time.min).replace(tzinfo=TAIWAN_TZ)
|
||||
if dt.tzinfo is None:
|
||||
dt = dt.replace(tzinfo=TAIWAN_TZ)
|
||||
return dt.astimezone(timezone.utc)
|
||||
|
||||
|
||||
def format_taiwan_time(dt: datetime | date | None, format_str: str = "%Y-%m-%d %H:%M:%S") -> str:
|
||||
"""Format a datetime/date using Taiwan time."""
|
||||
if dt is None:
|
||||
return ""
|
||||
tpe_dt = to_taiwan_time(dt)
|
||||
return tpe_dt.strftime(format_str)
|
||||
|
||||
|
||||
def parse_taiwan_time(time_str: str, format_str: str = "%Y-%m-%d %H:%M:%S") -> datetime:
|
||||
"""Parse a string as Taiwan time (aware)."""
|
||||
naive_dt = datetime.strptime(time_str, format_str)
|
||||
return naive_dt.replace(tzinfo=TAIWAN_TZ)
|
27
wsgi.py
Normal file
27
wsgi.py
Normal file
@@ -0,0 +1,27 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
WSGI 生產環境入口點
|
||||
用於Gunicorn部署
|
||||
"""
|
||||
import os
|
||||
import logging
|
||||
import sys
|
||||
from app import app
|
||||
|
||||
# 配置生產環境日誌
|
||||
if __name__ != "__main__":
|
||||
# 只在Gunicorn環境下配置日誌
|
||||
gunicorn_logger = logging.getLogger('gunicorn.error')
|
||||
app.logger.handlers = gunicorn_logger.handlers
|
||||
app.logger.setLevel(gunicorn_logger.level)
|
||||
|
||||
# 確保在生產環境
|
||||
os.environ['FLASK_ENV'] = 'production'
|
||||
|
||||
if __name__ == "__main__":
|
||||
# 開發環境直接運行
|
||||
print("🚀 開發環境啟動")
|
||||
app.run(host='0.0.0.0', port=5000, debug=False)
|
||||
else:
|
||||
# 生產環境通過Gunicorn
|
||||
print("🌟 生產環境啟動 (Gunicorn)")
|
Reference in New Issue
Block a user