commit 333a640a3b6035ca36f3d78887c3b40a13da1bc1 Author: beabigegg Date: Thu Sep 25 08:44:44 2025 +0800 REMOVE LDAP diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..98299af --- /dev/null +++ b/.dockerignore @@ -0,0 +1,67 @@ +# Version control +.git +.gitignore + +# Python cache +__pycache__ +*.pyc +*.pyo +*.pyd +.Python +pip-log.txt + +# Virtual environment +venv/ +env/ +.venv/ +.env/ + +# IDE +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs/ +*.log + +# Runtime data +uploads/* +!uploads/.gitkeep +static/generated/* +!static/generated/.gitkeep + +# Docker +Dockerfile +docker-compose*.yml +.dockerignore + +# Documentation +*.md +docs/ + +# Test files +tests/ +test_* +*_test.py + +# Build artifacts +dist/ +build/ +*.egg-info/ + +# Database +*.db +*.sqlite +*.sqlite3 \ No newline at end of file diff --git a/.env b/.env new file mode 100644 index 0000000..d8d8b3e --- /dev/null +++ b/.env @@ -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 diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..52b06fc --- /dev/null +++ b/.env.example @@ -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 \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..4521bcb --- /dev/null +++ b/.gitignore @@ -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 diff --git a/DEPLOYMENT.md b/DEPLOYMENT.md new file mode 100644 index 0000000..cd66588 --- /dev/null +++ b/DEPLOYMENT.md @@ -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 + 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 | diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..a09bcd4 --- /dev/null +++ b/Dockerfile @@ -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"] + diff --git a/Dockerfile.redis b/Dockerfile.redis new file mode 100644 index 0000000..8db1579 --- /dev/null +++ b/Dockerfile.redis @@ -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"] \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000..d563d26 --- /dev/null +++ b/README.md @@ -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 + 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. + diff --git a/USER_MANUAL.md b/USER_MANUAL.md new file mode 100644 index 0000000..6274e04 --- /dev/null +++ b/USER_MANUAL.md @@ -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. diff --git a/app.py b/app.py new file mode 100644 index 0000000..6e58709 --- /dev/null +++ b/app.py @@ -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) diff --git a/cache_utils.py b/cache_utils.py new file mode 100644 index 0000000..58d7cdf --- /dev/null +++ b/cache_utils.py @@ -0,0 +1,97 @@ +""" +快取輔助函數 +用於提升應用程式效能 +""" +from functools import wraps +from flask import request, current_app +from app import cache +import hashlib +import json + +def cache_key(*args, **kwargs): + """生成快取鍵值""" + key_data = { + 'args': args, + 'kwargs': kwargs, + 'user_id': getattr(request, 'user_id', 'anonymous'), + 'path': request.path if hasattr(request, 'path') else '' + } + key_string = json.dumps(key_data, sort_keys=True, default=str) + return hashlib.md5(key_string.encode('utf-8')).hexdigest() + +def cached_route(timeout=300): + """路由快取裝飾器""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_app.config.get('CACHE_TYPE') or current_app.debug: + return f(*args, **kwargs) + + key = f"route:{f.__name__}:{cache_key(*args, **kwargs)}" + + # 嘗試從快取獲取 + cached_result = cache.get(key) + if cached_result is not None: + current_app.logger.debug(f"快取命中: {key}") + return cached_result + + # 執行函數並快取結果 + result = f(*args, **kwargs) + cache.set(key, result, timeout=timeout) + current_app.logger.debug(f"快取設定: {key}") + + return result + return decorated_function + return decorator + +def cached_query(timeout=300): + """資料庫查詢快取裝飾器""" + def decorator(f): + @wraps(f) + def decorated_function(*args, **kwargs): + if not current_app.config.get('CACHE_TYPE') or current_app.debug: + return f(*args, **kwargs) + + key = f"query:{f.__name__}:{cache_key(*args, **kwargs)}" + + # 嘗試從快取獲取 + cached_result = cache.get(key) + if cached_result is not None: + current_app.logger.debug(f"查詢快取命中: {key}") + return cached_result + + # 執行查詢並快取結果 + result = f(*args, **kwargs) + cache.set(key, result, timeout=timeout) + current_app.logger.debug(f"查詢快取設定: {key}") + + return result + return decorated_function + return decorator + +def invalidate_cache(pattern): + """清除快取""" + try: + if hasattr(cache, 'delete_many'): + # Redis backend + keys = cache.cache._read_clients.keys(f"flask_cache_{pattern}*") + if keys: + cache.delete_many(*keys) + current_app.logger.info(f"清除快取: {len(keys)} 個項目") + else: + # Simple cache backend + cache.clear() + current_app.logger.info("清除所有快取") + except Exception as e: + current_app.logger.error(f"清除快取失敗: {e}") + +# 快取統計 +def cache_stats(): + """獲取快取統計資訊""" + try: + if hasattr(cache.cache, 'info'): + return cache.cache.info() + else: + return {"status": "simple cache", "info": "無統計資訊"} + except Exception as e: + return {"error": str(e)} \ No newline at end of file diff --git a/cdn_utils.py b/cdn_utils.py new file mode 100644 index 0000000..a401aa1 --- /dev/null +++ b/cdn_utils.py @@ -0,0 +1,66 @@ +""" +CDN 工具函數 +用於靜態資源加速 +""" +from flask import current_app, url_for as flask_url_for +import os + +def cdn_url_for(endpoint, **values): + """ + CDN化的 url_for 函數 + 自動將靜態資源指向CDN域名 + """ + if endpoint == 'static': + cdn_domain = current_app.config.get('CDN_DOMAIN', '').strip() + if cdn_domain: + # 確保CDN域名格式正確 + if not cdn_domain.startswith(('http://', 'https://')): + cdn_domain = f"https://{cdn_domain}" + + filename = values.get('filename', '') + if filename: + # 移除開頭的斜線 + filename = filename.lstrip('/') + return f"{cdn_domain.rstrip('/')}/static/{filename}" + + # 非靜態資源或未配置CDN時使用原始url_for + return flask_url_for(endpoint, **values) + +def get_static_url(filename): + """ + 獲取靜態資源URL + 自動判斷使用CDN還是本地路徑 + """ + return cdn_url_for('static', filename=filename) + +def is_cdn_enabled(): + """檢查是否啟用CDN""" + return bool(current_app.config.get('CDN_DOMAIN', '').strip()) + +class CDNHelper: + """CDN輔助類""" + + def __init__(self, app=None): + if app: + self.init_app(app) + + def init_app(self, app): + """初始化應用""" + app.jinja_env.globals['cdn_url_for'] = cdn_url_for + app.jinja_env.globals['get_static_url'] = get_static_url + app.jinja_env.globals['is_cdn_enabled'] = is_cdn_enabled + + # 添加模板過濾器 + app.jinja_env.filters['cdn'] = self._cdn_filter + + def _cdn_filter(self, filename): + """Jinja2過濾器:將靜態檔案路徑轉換為CDN URL""" + if filename.startswith('/static/'): + filename = filename[8:] # 移除 '/static/' 前綴 + elif filename.startswith('static/'): + filename = filename[7:] # 移除 'static/' 前綴 + + return get_static_url(filename) + +# 全局CDN輔助實例 +cdn_helper = CDNHelper() \ No newline at end of file diff --git a/config.py b/config.py new file mode 100644 index 0000000..7c1f76d --- /dev/null +++ b/config.py @@ -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'] diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..d04744c --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,61 @@ +# 生產環境專用配置 +# 使用方式: docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d + +version: '3.8' + +services: + # 在生產環境中擴展 app 服務 + app: + deploy: + replicas: 2 # 多個實例提升可用性 + update_config: + parallelism: 1 + delay: 10s + restart_policy: + condition: on-failure + max_attempts: 3 + restart_policy: + condition: on-failure + delay: 5s + max_attempts: 3 + resources: + limits: + cpus: '2.0' + memory: 2G + reservations: + cpus: '1.0' + memory: 1G + + # 啟用 Nginx 反向代理 + nginx: + profiles: [] # 移除 production profile,使其在生產環境中自動啟動 + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + + # Redis 生產優化 + redis: + deploy: + resources: + limits: + cpus: '0.5' + memory: 512M + reservations: + cpus: '0.25' + memory: 256M + + # OnlyOffice 資源配置 + onlyoffice: + deploy: + resources: + limits: + cpus: '2.0' + memory: 4G + reservations: + cpus: '1.0' + memory: 2G \ No newline at end of file diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..bc0a493 --- /dev/null +++ b/docker-compose.yml @@ -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 + diff --git a/gunicorn.conf.py b/gunicorn.conf.py new file mode 100644 index 0000000..95d81ae --- /dev/null +++ b/gunicorn.conf.py @@ -0,0 +1,53 @@ +# Gunicorn 生產環境配置 +import multiprocessing +import os + +# 服務器設置 +bind = "0.0.0.0:5000" +workers = min(multiprocessing.cpu_count() * 2 + 1, 8) # 最多8個worker +worker_class = "sync" +worker_connections = 1000 +max_requests = 1000 +max_requests_jitter = 50 + +# 超時設置 +timeout = 300 +keepalive = 5 +graceful_timeout = 300 + +# 日誌設置 +accesslog = "-" # stdout +errorlog = "-" # stderr +loglevel = "info" +access_log_format = '%(h)s %(l)s %(u)s %(t)s "%(r)s" %(s)s %(b)s "%(f)s" "%(a)s" %(D)s' + +# 進程設置 +preload_app = True +daemon = False +pidfile = "/tmp/gunicorn.pid" + +# 性能調優 +worker_tmp_dir = "/dev/shm" # 使用內存作為臨時目錄 + +# 安全設置 +limit_request_line = 8190 +limit_request_fields = 100 +limit_request_field_size = 8190 + +def when_ready(server): + server.log.info("Server is ready. Spawning workers") + +def worker_int(worker): + worker.log.info("worker received INT or QUIT signal") + +def pre_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_fork(server, worker): + server.log.info("Worker spawned (pid: %s)", worker.pid) + +def post_worker_init(worker): + worker.log.info("Worker initialized (pid: %s)", worker.pid) + +def worker_abort(worker): + worker.log.info("Worker aborted (pid: %s)", worker.pid) \ No newline at end of file diff --git a/init_db.py b/init_db.py new file mode 100644 index 0000000..2644003 --- /dev/null +++ b/init_db.py @@ -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.") + diff --git a/models.py b/models.py new file mode 100644 index 0000000..9c0243a --- /dev/null +++ b/models.py @@ -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') \ No newline at end of file diff --git a/monitor.py b/monitor.py new file mode 100644 index 0000000..0a503ee --- /dev/null +++ b/monitor.py @@ -0,0 +1,163 @@ +#!/usr/bin/env python3 +""" +生產環境監控腳本 +監控系統效能、Redis狀態、資料庫連接等 +""" +import time +import requests +import redis +import json +from datetime import datetime +import argparse +import sys + +def check_app_health(): + """檢查應用程式健康狀態""" + try: + response = requests.get('http://localhost:12010/', timeout=10) + return { + 'status': 'healthy' if response.status_code == 200 else 'unhealthy', + 'status_code': response.status_code, + 'response_time': response.elapsed.total_seconds() + } + except Exception as e: + return { + 'status': 'unhealthy', + 'error': str(e), + 'response_time': None + } + +def check_redis_health(): + """檢查 Redis 健康狀態""" + try: + r = redis.from_url('redis://localhost:6379/0') + r.ping() + info = r.info() + return { + 'status': 'healthy', + 'used_memory': info.get('used_memory_human'), + 'connected_clients': info.get('connected_clients'), + 'total_commands_processed': info.get('total_commands_processed'), + 'keyspace_hits': info.get('keyspace_hits'), + 'keyspace_misses': info.get('keyspace_misses') + } + except Exception as e: + return { + 'status': 'unhealthy', + 'error': str(e) + } + +def check_onlyoffice_health(): + """檢查 OnlyOffice 健康狀態""" + try: + response = requests.get('http://localhost:12011/healthcheck', timeout=10) + return { + 'status': 'healthy' if response.status_code == 200 else 'unhealthy', + 'status_code': response.status_code, + 'response_time': response.elapsed.total_seconds() + } + except Exception as e: + return { + 'status': 'unhealthy', + 'error': str(e), + 'response_time': None + } + +def get_docker_stats(): + """獲取 Docker 容器統計資訊""" + import subprocess + try: + result = subprocess.run(['docker', 'stats', '--no-stream', '--format', + 'table {{.Container}}\t{{.CPUPerc}}\t{{.MemUsage}}\t{{.MemPerc}}'], + capture_output=True, text=True, timeout=10) + if result.returncode == 0: + return result.stdout + else: + return f"Error: {result.stderr}" + except Exception as e: + return f"Error getting docker stats: {str(e)}" + +def main(): + parser = argparse.ArgumentParser(description='暫時規範系統監控工具') + parser.add_argument('--json', action='store_true', help='以JSON格式輸出') + parser.add_argument('--watch', '-w', type=int, metavar='SECONDS', + help='持續監控,指定刷新間隔秒數') + args = parser.parse_args() + + def run_checks(): + timestamp = datetime.now().isoformat() + + # 執行各項健康檢查 + app_health = check_app_health() + redis_health = check_redis_health() + onlyoffice_health = check_onlyoffice_health() + + results = { + 'timestamp': timestamp, + 'app': app_health, + 'redis': redis_health, + 'onlyoffice': onlyoffice_health + } + + if args.json: + print(json.dumps(results, indent=2, ensure_ascii=False)) + else: + # 格式化輸出 + print(f"\n🕒 監控時間: {timestamp}") + print("=" * 60) + + # 應用程式狀態 + status_icon = "✅" if app_health['status'] == 'healthy' else "❌" + print(f"{status_icon} 應用程式: {app_health['status']}") + if app_health.get('response_time'): + print(f" 響應時間: {app_health['response_time']:.3f}s") + if app_health.get('error'): + print(f" 錯誤: {app_health['error']}") + + # Redis 狀態 + status_icon = "✅" if redis_health['status'] == 'healthy' else "❌" + print(f"{status_icon} Redis: {redis_health['status']}") + if redis_health['status'] == 'healthy': + print(f" 記憶體使用: {redis_health['used_memory']}") + print(f" 連接數: {redis_health['connected_clients']}") + if redis_health['keyspace_hits'] and redis_health['keyspace_misses']: + hit_rate = redis_health['keyspace_hits'] / (redis_health['keyspace_hits'] + redis_health['keyspace_misses']) * 100 + print(f" 快取命中率: {hit_rate:.2f}%") + elif redis_health.get('error'): + print(f" 錯誤: {redis_health['error']}") + + # OnlyOffice 狀態 + status_icon = "✅" if onlyoffice_health['status'] == 'healthy' else "❌" + print(f"{status_icon} OnlyOffice: {onlyoffice_health['status']}") + if onlyoffice_health.get('response_time'): + print(f" 響應時間: {onlyoffice_health['response_time']:.3f}s") + if onlyoffice_health.get('error'): + print(f" 錯誤: {onlyoffice_health['error']}") + + # Docker 統計 + print("\n📊 容器資源使用:") + print(get_docker_stats()) + + return results + + try: + if args.watch: + while True: + try: + if not args.json: + print("\033[H\033[J") # 清空終端 + run_checks() + if args.json: + print() # JSON輸出間的分隔 + time.sleep(args.watch) + except KeyboardInterrupt: + print("\n👋 監控已停止") + sys.exit(0) + else: + run_checks() + except Exception as e: + print(f"❌ 監控執行錯誤: {str(e)}") + sys.exit(1) + +if __name__ == "__main__": + main() \ No newline at end of file diff --git a/mysql/init/01-init.sql b/mysql/init/01-init.sql new file mode 100644 index 0000000..0a621ee --- /dev/null +++ b/mysql/init/01-init.sql @@ -0,0 +1,18 @@ +-- 初始化暫時規範管理系統資料庫 +-- 此檔案會在 MySQL 容器啟動時自動執行 + +-- 設定字符集和排序規則 +ALTER DATABASE tempspec_db CHARACTER SET = utf8mb4 COLLATE = utf8mb4_unicode_ci; + +-- 確保時區設定 +SET time_zone = '+08:00'; + +-- 創建必要的索引以提升效能 +-- 注意:資料表結構由 SQLAlchemy 自動創建,這裡只需要創建額外的索引 + +-- 記錄初始化完成 +INSERT INTO mysql.general_log (event_time, user_host, thread_id, server_id, command_type, argument) +VALUES (NOW(), 'init_script', 0, 1, 'Query', 'TempSpec Database Initialized'); + +-- 輸出初始化資訊 +SELECT 'TempSpec System Database Initialized Successfully' as STATUS; \ No newline at end of file diff --git a/nginx/Dockerfile b/nginx/Dockerfile new file mode 100644 index 0000000..8465ef9 --- /dev/null +++ b/nginx/Dockerfile @@ -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;"] \ No newline at end of file diff --git a/nginx/conf.d/default.conf b/nginx/conf.d/default.conf new file mode 100644 index 0000000..ce1f05e --- /dev/null +++ b/nginx/conf.d/default.conf @@ -0,0 +1,83 @@ +# 暫時規範管理系統主站 +server { + listen 80; + server_name localhost; + + # 重定向到 HTTPS (可選,需要 SSL 證書) + # return 301 https://$server_name$request_uri; + + # 應用程式代理 + location / { + proxy_pass http://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 相同 +# # ... +# } \ No newline at end of file diff --git a/nginx/nginx.conf b/nginx/nginx.conf new file mode 100644 index 0000000..683b3dd --- /dev/null +++ b/nginx/nginx.conf @@ -0,0 +1,71 @@ +user nginx; +worker_processes auto; +error_log /var/log/nginx/error.log warn; +pid /var/run/nginx.pid; + +events { + worker_connections 1024; + use epoll; + multi_accept on; +} + +http { + include /etc/nginx/mime.types; + default_type application/octet-stream; + + # 日誌格式 + log_format main '$remote_addr - $remote_user [$time_local] "$request" ' + '$status $body_bytes_sent "$http_referer" ' + '"$http_user_agent" "$http_x_forwarded_for"'; + + access_log /var/log/nginx/access.log main; + + # 基本設定 + sendfile on; + tcp_nopush on; + tcp_nodelay on; + keepalive_timeout 65; + types_hash_max_size 2048; + server_tokens off; + + # 檔案大小限制 + client_max_body_size 100M; + client_body_timeout 120s; + client_header_timeout 120s; + + # Gzip 壓縮 + gzip on; + gzip_vary on; + gzip_min_length 1024; + gzip_proxied any; + gzip_comp_level 6; + gzip_types + application/atom+xml + application/geo+json + application/javascript + application/x-javascript + application/json + application/ld+json + application/manifest+json + application/rdf+xml + application/rss+xml + application/xhtml+xml + application/xml + font/eot + font/otf + font/ttf + image/svg+xml + text/css + text/javascript + text/plain + text/xml; + + # 安全標頭 + add_header X-Frame-Options DENY; + add_header X-Content-Type-Options nosniff; + add_header X-XSS-Protection "1; mode=block"; + add_header Referrer-Policy "strict-origin-when-cross-origin"; + + # 包含站點配置 + include /etc/nginx/conf.d/*.conf; +} \ No newline at end of file diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..36f99a6 --- /dev/null +++ b/requirements.txt @@ -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 diff --git a/routes/__init__.py b/routes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/routes/admin.py b/routes/admin.py new file mode 100644 index 0000000..b5b84b3 --- /dev/null +++ b/routes/admin.py @@ -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/', 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/', 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')) diff --git a/routes/api.py b/routes/api.py new file mode 100644 index 0000000..24418bb --- /dev/null +++ b/routes/api.py @@ -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'}) diff --git a/routes/auth.py b/routes/auth.py new file mode 100644 index 0000000..252e2f3 --- /dev/null +++ b/routes/auth.py @@ -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')) diff --git a/routes/temp_spec.py b/routes/temp_spec.py new file mode 100644 index 0000000..b9a1136 --- /dev/null +++ b/routes/temp_spec.py @@ -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/') +@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/', 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/', 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""" + + +

Hello,

+

Temp spec {spec.spec_code} - {spec.title} has been approved and is now active.

+

Start date: {spec.start_date.strftime('%Y-%m-%d')}
+ End date: {spec.end_date.strftime('%Y-%m-%d')}

+

Applicant: {spec.applicant}

+

Please sign in to the system for additional details.

+

This notification was sent automatically. Please do not reply.

+ + + """ + send_email(recipients, subject, body) + current_app.logger.info(f"Sent activation notification for spec {spec.spec_code} to {len(recipients)} recipients.") + # --- End of Dynamic Email Notification --- + return redirect(url_for('temp_spec.spec_list')) + + return render_template('activate_spec.html', spec=spec) + +@temp_spec_bp.route('/terminate/', methods=['GET', 'POST']) +@editor_or_admin_required +def terminate_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + if request.method == 'POST': + reason = request.form.get('reason') + if not reason: + flash('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""" + + +

Hello,

+

Temp spec {spec.spec_code} - {spec.title} has been terminated.

+

Termination date: {spec.end_date.strftime('%Y-%m-%d')}

+

Applicant: {spec.applicant}

+

Reason: {reason}

+

Please sign in to the system for additional details.

+

This notification was sent automatically. Please do not reply.

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

Hello,

+

Temp spec {spec.spec_code} - {spec.title} has been extended.

+

New end date: {spec.end_date.strftime('%Y-%m-%d')}

+

Applicant: {spec.applicant}

+

Please sign in to the system for additional details.

+

This notification was sent automatically. Please do not reply.

+ + + """ + 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/') +@login_required +def spec_history(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + history = SpecHistory.query.filter_by(spec_id=spec_id).order_by(SpecHistory.timestamp.desc()).all() + return render_template('spec_history.html', spec=spec, history=history) + +@temp_spec_bp.route('/delete/', methods=['POST']) +@admin_required +def delete_spec(spec_id): + spec = TempSpec.query.get_or_404(spec_id) + spec_code = spec.spec_code + + files_to_delete = [] + generated_folder = os.path.join(current_app.static_folder, 'generated') + files_to_delete.append(os.path.join(generated_folder, f"{spec.spec_code}.docx")) + + upload_folder = os.path.join(BASE_DIR, current_app.config['UPLOAD_FOLDER']) + for upload_record in spec.uploads: + files_to_delete.append(os.path.join(upload_folder, upload_record.filename)) + + for f_path in files_to_delete: + try: + if os.path.exists(f_path): + os.remove(f_path) + except Exception as e: + current_app.logger.error(f"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')) diff --git a/routes/upload.py b/routes/upload.py new file mode 100644 index 0000000..989dc92 --- /dev/null +++ b/routes/upload.py @@ -0,0 +1,29 @@ +from flask import Blueprint, request, jsonify, current_app +from werkzeug.utils import secure_filename +import os +import time + +upload_bp = Blueprint('upload', __name__) + +@upload_bp.route('/image', methods=['POST']) +def upload_image(): + file = request.files.get('file') + if not file: + return jsonify({'error': 'No file part'}), 400 + + # 建立一個獨特的檔名 + extension = os.path.splitext(file.filename)[1] + filename = f"{int(time.time())}_{secure_filename(file.filename)}" + + # 確保上傳資料夾存在 + # 為了讓圖片能被網頁存取,我們將它存在 static 資料夾下 + image_folder = os.path.join(current_app.static_folder, 'uploads', 'images') + os.makedirs(image_folder, exist_ok=True) + + file_path = os.path.join(image_folder, filename) + file.save(file_path) + + # 回傳 TinyMCE 需要的 JSON 格式 + # 路徑必須是相對於網域根目錄的 URL + location = f"/static/uploads/images/{filename}" + return jsonify({'location': location}) diff --git a/start-production.sh b/start-production.sh new file mode 100644 index 0000000..d21e145 --- /dev/null +++ b/start-production.sh @@ -0,0 +1,102 @@ +#!/bin/bash + +# 暫時規範管理系統 V4 - 生產環境啟動腳本 +# 使用方式: ./start-production.sh + +set -e + +echo "🚀 暫時規範管理系統 V4 - 生產環境部署" +echo "==================================================" + +# 檢查必要檔案 +echo "📋 檢查必要檔案..." +if [ ! -f ".env" ]; then + echo "❌ 錯誤: .env 檔案不存在" + echo "請複製 .env.production 為 .env 並配置相應的值" + exit 1 +fi + +if [ ! -f "docker-compose.yml" ]; then + echo "❌ 錯誤: docker-compose.yml 檔案不存在" + exit 1 +fi + +# 檢查 Docker 是否運行 +echo "🐳 檢查 Docker 服務..." +if ! docker info > /dev/null 2>&1; then + echo "❌ 錯誤: Docker 服務未運行" + echo "請啟動 Docker 服務後再試" + exit 1 +fi + +# 停止舊的容器(如果存在) +echo "🛑 停止現有容器..." +docker-compose down || true + +# 建構新的映像 +echo "🔨 建構應用程式映像..." +docker-compose build --no-cache + +# 啟動服務(生產環境配置) +echo "🌟 啟動生產環境服務..." +if [ -f "docker-compose.prod.yml" ]; then + echo "使用生產環境配置檔案..." + docker-compose -f docker-compose.yml -f docker-compose.prod.yml up -d +else + echo "使用標準配置啟動..." + docker-compose --profile production up -d +fi + +# 等待服務啟動 +echo "⏳ 等待服務啟動..." +sleep 10 + +# 檢查服務狀態 +echo "📊 檢查服務狀態..." +docker-compose ps + +# 顯示服務 URL +echo "" +echo "✅ 部署完成!" +echo "==================================================" +echo "📍 服務存取點:" +echo " 主應用程式: http://localhost:12010" +echo " OnlyOffice: http://localhost:12011" +echo "" +echo "📊 管理命令:" +echo " 查看日誌: docker-compose logs -f" +echo " 停止服務: docker-compose down" +echo " 重啟服務: docker-compose restart" +echo "" +echo "🔧 監控命令:" +echo " 查看容器狀態: docker-compose ps" +echo " 查看資源使用: docker stats" +echo "" + +# 檢查健康狀態 +echo "🏥 健康檢查..." +sleep 5 + +# 檢查 Redis +if docker-compose exec -T redis redis-cli ping > /dev/null 2>&1; then + echo "✅ Redis: 健康" +else + echo "❌ Redis: 異常" +fi + +# 檢查應用程式 +if curl -f http://localhost:12010/ > /dev/null 2>&1; then + echo "✅ 應用程式: 健康" +else + echo "❌ 應用程式: 異常" +fi + +# 檢查 OnlyOffice +if curl -f http://localhost:12011/healthcheck > /dev/null 2>&1; then + echo "✅ OnlyOffice: 健康" +else + echo "❌ OnlyOffice: 異常" +fi + +echo "" +echo "🎉 生產環境啟動完成!" \ No newline at end of file diff --git a/static/css/style.css b/static/css/style.css new file mode 100644 index 0000000..b1b3371 --- /dev/null +++ b/static/css/style.css @@ -0,0 +1,203 @@ +/* --- 全域與背景設定 --- */ +body { + background-color: #0d1117; + background-image: linear-gradient(180deg, #161b22 0%, #0d1117 100%); + background-attachment: fixed; + color: #c9d1d9; + font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "Helvetica Neue", Arial, sans-serif; +} + +/* --- 強制設定通用元素的文字顏色 --- */ +p, label, th, td, .form-label, .form-check-label, .card-body { + color: #c9d1d9; +} + +/* --- 導覽列 --- */ +.navbar { + background-color: rgba(13, 17, 23, 0.8); + backdrop-filter: blur(10px); + border-bottom: 1px solid #30363d; +} + +/* --- 標題 --- */ +h1, h2, h3, h4, h5, h6, .h1, .h2, .h3, .h4, .h5, .h6 { + color: #f0f6fc; +} + +/* --- 卡片與容器 --- */ +.card { + background-color: #161b22; + border: 1px solid #30363d; + border-radius: 0.5rem; + box-shadow: 0 8px 24px rgba(0, 0, 0, 0.2); +} +.card-header, .card-footer { + background-color: rgba(22, 27, 34, 0.7); + border-bottom: 1px solid #30363d; + color: #f0f6fc; +} + +/* --- 按鈕 --- */ +.btn-primary { + background-color: #5865f2; border-color: #5865f2; color: #ffffff; + transition: all 0.2s ease-in-out; +} +.btn-primary:hover, .btn-primary:focus { + background-color: #4752c4; border-color: #4752c4; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.5); +} +.btn-success { background-color: #2ea043; border-color: #2ea043; color: #fff; } +.btn-success:hover { background-color: #268839; border-color: #268839; color: #fff;} +.btn-danger { background-color: #da3633; border-color: #da3633; color: #fff;} +.btn-danger:hover { background-color: #b92d2b; border-color: #b92d2b; color: #fff;} +.btn-warning { background-color: #f0ad4e; border-color: #f0ad4e; color: #0d1117; } +.btn-warning:hover { background-color: #e39b37; border-color: #e39b37; color: #0d1117;} +.btn-info { background-color: #0dcaf0; border-color: #0dcaf0; color: #0d1117; } +.btn-info:hover { background-color: #0baccc; border-color: #0baccc; color: #0d1117;} + +/* --- 表單輸入框 --- */ +.form-control, .form-select { + background-color: #0d1117; color: #c9d1d9; + border: 1px solid #30363d; border-radius: 0.375rem; +} +.form-control:focus, .form-select:focus { + background-color: #0d1117; color: #c9d1d9; + border-color: #5865f2; + box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); +} +.form-control::placeholder { color: #8b949e; } +.form-control[readonly] { background-color: #161b22; opacity: 0.7; } +.input-group-text { + background-color: #161b22; + border: 1px solid #30363d; + color: #c9d1d9; +} + +/* --- 表格 --- */ +.table { + --bs-table-color: #c9d1d9; + --bs-table-bg: #161b22; + --bs-table-border-color: #30363d; + --bs-table-striped-color: #c9d1d9; + --bs-table-striped-bg: #21262d; + --bs-table-hover-color: #f0f6fc; + --bs-table-hover-bg: #30363d; + border-color: var(--bs-table-border-color); +} +.table > thead { + color: #f0f6fc; +} + +/* --- 分頁 --- */ +.pagination { + --bs-pagination-color: #58a6ff; + --bs-pagination-bg: #0d1117; /* 最深的背景色,使其與容器分離 */ + --bs-pagination-border-color: #30363d; + --bs-pagination-hover-color: #80b6ff; + --bs-pagination-hover-bg: #21262d; /* 懸停時變亮 */ + --bs-pagination-hover-border-color: #4d555e; + --bs-pagination-focus-color: #80b6ff; + --bs-pagination-focus-bg: #21262d; + --bs-pagination-focus-box-shadow: 0 0 0 0.25rem rgba(88, 101, 242, 0.25); + --bs-pagination-active-color: #fff; + --bs-pagination-active-bg: #5865f2; + --bs-pagination-active-border-color: #5865f2; + --bs-pagination-disabled-color: #8b949e; + --bs-pagination-disabled-bg: #161b22; /* 禁用的背景色,使其看起來凹陷 */ + --bs-pagination-disabled-border-color: #30363d; +} + +/* --- 提示訊息 (Alerts) --- */ +.alert { border-width: 1px; border-style: solid; } +.alert-danger { background-color: rgba(218, 54, 51, 0.15); border-color: #da3633; color: #ff8986; } +.alert-success { background-color: rgba(46, 160, 67, 0.15); border-color: #2ea043; color: #7ce38f; } +.alert-info { background-color: rgba(13, 202, 240, 0.15); border-color: #0dcaf0; color: #6be2fa; } +.alert-warning { background-color: rgba(240, 173, 78, 0.15); border-color: #f0ad4e; color: #f0ad4e; } + +/* --- 狀態標籤 (Badges) --- */ +.badge { --bs-badge-font-size: 0.8em; --bs-badge-padding-y: 0.4em; --bs-badge-padding-x: 0.7em; } +.bg-success { background-color: #2ea043 !important; } +.bg-info { background-color: #0dcaf0 !important; color: #0d1117 !important; } +.bg-warning { background-color: #f0ad4e !important; color: #0d1117 !important; } +.bg-secondary { background-color: #8b949e !important; } + +/* --- 連結 --- */ +a { color: #58a6ff; } +a:hover { color: #80b6ff; } + +/* 頁面切換的淡入效果 */ +@keyframes fadeIn { from { opacity: 0; } to { opacity: 1; } } +main.container { animation: fadeIn 0.5s ease-in-out; } + +/* --- 通知 (Toast) --- */ +.toast { + background-color: #21262d; /* 使用比卡片稍亮的深色背景 */ + border: 1px solid #30363d; + color: #c9d1d9; +} + +.toast-header { + background-color: #161b22; /* 使用與卡片相同的深色背景 */ + color: #f0f6fc; /* 標題使用較亮的白色文字 */ + border-bottom: 1px solid #30363d; +} + +.toast-body { + color: #c9d1d9; /* 內文使用標準的灰色文字 */ +} + +/* 讓關閉按鈕在深色背景下可見 */ +.btn-close { + filter: invert(1) grayscale(100%) brightness(200%); +} + +/* --- 列表群組 (List Group for History Page) --- */ +.list-group-flush .list-group-item { + background-color: transparent; /* 在 card 中使用透明背景 */ + border-color: #30363d; +} + +.list-group-item { + background-color: #161b22; + border-color: #30363d; +} + +/* 確保列表內的文字顏色正確 */ +.list-group-item, +.list-group-item p, +.list-group-item small { + color: #c9d1d9; /* 標準灰色文字 */ +} + +/* 讓使用者名稱等重要文字更亮 */ +.list-group-item h5 strong { + color: #f0f6fc; +} + +/* --- 剩餘天數標籤 (Days Remaining Badge) --- */ +.days-badge { + padding: 0.3em 0.6em; + border-radius: 0.375rem; + font-weight: 500; + font-size: 0.85em; + color: #0d1117; /* 預設使用深色文字 */ +} + +.days-safe { + background-color: #2ea043; /* 綠色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-warning { + background-color: #f0ad4e; /* 黃色 */ +} + +.days-critical { + background-color: #da3633; /* 紅色 */ + color: #ffffff; /* 搭配淺色文字 */ +} + +.days-expired { + background-color: #8b949e; /* 灰色 */ + color: #ffffff; +} \ No newline at end of file diff --git a/swagger.json b/swagger.json new file mode 100644 index 0000000..2166140 --- /dev/null +++ b/swagger.json @@ -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" +} + diff --git a/tasks.py b/tasks.py new file mode 100644 index 0000000..77e84d6 --- /dev/null +++ b/tasks.py @@ -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""" + + +

Hello,

+

This is an automated reminder.

+

Temp spec {spec.spec_code} - {spec.title} will expire on {spec.end_date.strftime('%Y-%m-%d')}.

+

Days remaining: {remaining_days}

+

Applicant: {spec.applicant}

+

Please sign in to the system if an extension is required.

+

This message was sent automatically. Please do not reply.

+ + + """ + + send_email(recipients, subject, body) + current_app.logger.info( + "Sent expiry reminder for spec %s to %d recipients.", + spec.spec_code, + len(recipients), + ) + diff --git a/template_with_placeholders.docx b/template_with_placeholders.docx new file mode 100644 index 0000000..7bef1e3 Binary files /dev/null and b/template_with_placeholders.docx differ diff --git a/templates/403.html b/templates/403.html new file mode 100644 index 0000000..87e6be7 --- /dev/null +++ b/templates/403.html @@ -0,0 +1,12 @@ +{% extends "base.html" %} + +{% block title %}權限不足{% endblock %} + +{% block content %} +
+

403

+

權限不足 (Forbidden)

+

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

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

404

+

找不到頁面 (Not Found)

+

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

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

上傳簽核檔案以啟用規範

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

主題: {{ spec.title }}

+
+ 請上傳已經過完整簽核的 PDF 檔案。上傳後,此規範的狀態將變為「生效」。 +
+
+ + +
+ + +
+ + +
請輸入完整郵件地址,多筆請以分號 (;) 分隔。
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} diff --git a/templates/base.html b/templates/base.html new file mode 100644 index 0000000..d4ab161 --- /dev/null +++ b/templates/base.html @@ -0,0 +1,98 @@ + + + + + + {% block title %}暫時規範系統{% endblock %} + + + + + + + + + + + + + + + + +
+ {% block content %}{% endblock %} +
+ + +
+ {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} +
+ + + + + + + + + + + + + + {% block scripts %}{% endblock %} + + diff --git a/templates/create_temp_spec_form.html b/templates/create_temp_spec_form.html new file mode 100644 index 0000000..fdc27fa --- /dev/null +++ b/templates/create_temp_spec_form.html @@ -0,0 +1,83 @@ +{% extends "base.html" %} + +{% block title %}建立新的暫時規範{% endblock %} + +{% block content %} +
+
+

建立新的暫時規範

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

展延暫時規範

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

主題: {{ spec.title }}

+

原結束日期: {{ spec.end_date|taiwan_date }}

+

展延次數: + {{ spec.extension_count }} / 2 + + 剩餘可展延次數: {{ 2 - spec.extension_count }} 次 + +

+ +
+ + +
請輸入完整郵件地址,多筆請以分號 (;) 分隔。
+
+ +
+ + +
請上傳展延申請的相關佐證文件 (PDF 格式)。
+
+ + +
+ + {% if saved_emails %} +
+ 以下為先前儲存的通知清單,可直接調整或重新輸入。 +
+ {% endif %} + +
請輸入完整郵件地址,多筆請以分號 (;) 分隔。
+
+ + + 取消 +
+
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} \ No newline at end of file diff --git a/templates/login.html b/templates/login.html new file mode 100644 index 0000000..baaed33 --- /dev/null +++ b/templates/login.html @@ -0,0 +1,43 @@ +{% extends "base.html" %} + +{% block title %}登入 - 暫規系統{% endblock %} + +{% block content %} +
+
+

登入

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
帳號可使用公司信箱或指定字串。
+
+
+ + +
+
+ + 建立新帳號 +
+
+
+
+
+
+{% endblock %} diff --git a/templates/onlyoffice_editor.html b/templates/onlyoffice_editor.html new file mode 100644 index 0000000..510c9ba --- /dev/null +++ b/templates/onlyoffice_editor.html @@ -0,0 +1,39 @@ +{% extends "base.html" %} + +{% block title %}編輯規範 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

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

+

主題: {{ spec.title }}

+
+ 返回總表 +
+ +
+
+
+
+
+{% endblock %} + +{% block scripts %} + + + +{% endblock %} \ No newline at end of file diff --git a/templates/register.html b/templates/register.html new file mode 100644 index 0000000..25ce5fb --- /dev/null +++ b/templates/register.html @@ -0,0 +1,53 @@ +{% extends "base.html" %} + +{% block title %}註冊帳號 - 暫規系統{% endblock %} + +{% block content %} +
+
+

建立新帳號

+ + {% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} + {% endwith %} + +
+
+
+
+ + +
建議以公司信箱為帳號,避免重複。
+
+
+ + +
+
+ + +
密碼需至少 6 碼。
+
+
+ + +
+
+ + 回登入頁 +
+
+
+
+
+
+{% endblock %} diff --git a/templates/spec_history.html b/templates/spec_history.html new file mode 100644 index 0000000..2ee69f3 --- /dev/null +++ b/templates/spec_history.html @@ -0,0 +1,34 @@ +{% extends "base.html" %} + +{% block title %}操作歷史 - {{ spec.spec_code }}{% endblock %} + +{% block content %} +
+
+

操作歷史紀錄

+

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

+
+ 返回總表 +
+ +
+
+
    + {% for entry in history %} +
  • +
    +
    + {{ entry.action }} + 由 {{ entry.user.name if entry.user and entry.user.name else (entry.user.username if entry.user else '[已刪除的使用者]') }} 執行 +
    + {{ entry.timestamp|taiwan_time }} +
    +

    {{ entry.details }}

    +
  • + {% else %} +
  • 沒有任何歷史紀錄。
  • + {% endfor %} +
+
+
+{% endblock %} diff --git a/templates/spec_list.html b/templates/spec_list.html new file mode 100644 index 0000000..ba79be7 --- /dev/null +++ b/templates/spec_list.html @@ -0,0 +1,169 @@ +{% extends "base.html" %} + +{% block title %}臨時規格清單{% endblock %} + +{% block content %} +
+

臨時規格清單

+ {% if current_user.role in ['editor','admin'] %} + + 暫時規範建立 + + {% endif %} +
+ +
+
+
+
+
+ + +
+
+
+ +
+
+ +
+
+
+
+ +
+
+
+ + + + + + + + + + + + + + + {% for spec in specs %} + + + + + + + + + + + + + + {% endfor %} + +
規格編號標題申請人建立日期結束日期剩餘天數狀態操作
{{ spec.spec_code }}{{ spec.title }}{{ spec.applicant }}{{ spec.created_at|taiwan_date }}{{ spec.end_date|taiwan_date }} + {% if spec.status in ['active', 'expired'] %} + {% set remaining_days = (spec.end_date - today).days %} + {% if remaining_days < 0 %} + {% set color_class = 'days-expired' %} + {% elif remaining_days <= 3 %} + {% set color_class = 'days-critical' %} + {% elif remaining_days <= 7 %} + {% set color_class = 'days-warning' %} + {% else %} + {% set color_class = 'days-safe' %} + {% endif %} + + {{ remaining_days if remaining_days >= 0 else '已過期' }} + + {% else %} + - + {% endif %} + + {% if spec.status == 'active' %} + 生效中 + {% elif spec.status == 'pending_approval' %} + 待核准 + {% elif spec.status == 'terminated' %} + 已終止 + {% else %} + 已過期 + {% endif %} + + {% if spec.extension_count > 0 %} +
+
+ + 已延期 {{ spec.extension_count }} 次 + + {% if spec.extension_count >= 2 %} +
已達延期上限 + {% endif %} +
+ {% endif %} +
+ {% if spec.status == 'pending_approval' and current_user.role in ['editor', 'admin'] %} + + {% endif %} + + {% if current_user.role == 'admin' and spec.status == 'pending_approval' %} + + {% endif %} + + {% if current_user.role in ['editor', 'admin'] and spec.status == 'active' %} + {% if spec.extension_count < 2 %} + + {% else %} + + {% endif %} + + {% endif %} + + {% if current_user.role == 'admin' %} +
+ +
+ {% endif %} + + {% if spec.status == 'pending_approval' %} + {% if current_user.role in ['editor', 'admin'] %} + + {% endif %} + {% elif spec.uploads %} + + {% endif %} + +
+
+
+ + +
+{% endblock %} diff --git a/templates/terminate_spec.html b/templates/terminate_spec.html new file mode 100644 index 0000000..f5cd265 --- /dev/null +++ b/templates/terminate_spec.html @@ -0,0 +1,46 @@ +{% extends "base.html" %} + +{% block title %}?蝯??急?閬?{% endblock %} + +{% block content %} +

?蝯??急?閬?

+ +
+
+ 閬?蝺刻?: {{ spec.spec_code }} +
+
+
+

銝駁?: {{ spec.title }}

+
+ ?瑁?甇斗?雿????喟?甇a€遢?急?閬?嚗???霈?歇蝯迫??蝯??交???啁隞予?? +
+
+ + +
+ + +
+ + {% if saved_emails %} +
+ 以下為先前儲存的通知清單,可直接調整或重新輸入。 +
+ {% endif %} + +
請輸入完整郵件地址,多筆請以分號 (;) 分隔。
+ + + ?? + +
+
+{% endblock %} + +{% block scripts %} + +{% endblock %} + diff --git a/templates/user_management.html b/templates/user_management.html new file mode 100644 index 0000000..45aeacd --- /dev/null +++ b/templates/user_management.html @@ -0,0 +1,172 @@ +{% extends "base.html" %} + +{% block title %}帳號管理{% endblock %} + +{% block content %} +

帳號管理

+ +{% with messages = get_flashed_messages(with_categories=true) %} + {% if messages %} + {% for category, message in messages %} + + {% endfor %} + {% endif %} +{% endwith %} + +
+
+ 新增帳號 +
+
+
+
+ + +
+
+ + +
+
+ + +
至少 6 碼。
+
+
+ + +
+
+ +
+
+
+
+ +
+
+ 現有帳號 +
+
+ {% if users %} +
+ + + + + + + + + + + + + + {% for user in users %} + + + + + + + + + + {% endfor %} + +
ID帳號姓名角色上次登入重設密碼操作
{{ user.id }} + {{ user.username }} + {% if user.id == current_user.id %} + 目前使用者 + {% endif %} + + + + + + {% if user.last_login %} + {{ user.last_login|taiwan_time('%Y-%m-%d %H:%M') }} + {% else %} + 從未登入 + {% endif %} + + + +
+ + {% if user.id != current_user.id %} +
+ +
+ {% else %} + + {% endif %} +
+
+ {% else %} +
+ 目前尚無帳號紀錄。 +
+ {% endif %} +
+
+ +
+
+ 角色說明 +
+
+
+
+
檢視 (Viewer)
+
    +
  • 登入系統
  • +
  • 檢視暫規清單
  • +
  • 下載已核准的 PDF 檔案
  • +
  • 查看歷史紀錄
  • +
+
+
+
編輯 (Editor)
+
    +
  • 包含 Viewer 權限
  • +
  • 建立暫規申請
  • +
  • 編輯暫規內容
  • +
  • 展延與終止暫規
  • +
  • 下載 Word 編輯檔
  • +
+
+
+
管理 (Admin)
+
    +
  • 包含 Editor 權限
  • +
  • 核准待審暫規
  • +
  • 管理帳號與角色
  • +
  • 刪除暫規
  • +
  • 系統設定維護
  • +
+
+
+
+
+{% endblock %} diff --git a/tmp_decompiled/temp_spec.cpython-312.py b/tmp_decompiled/temp_spec.cpython-312.py new file mode 100644 index 0000000..a320472 --- /dev/null +++ b/tmp_decompiled/temp_spec.cpython-312.py @@ -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 diff --git a/update_admin.py b/update_admin.py new file mode 100644 index 0000000..420daff --- /dev/null +++ b/update_admin.py @@ -0,0 +1,37 @@ +#!/usr/bin/env python3 +""" +更新使用者權限為管理員的腳本 +""" + +from models import db, User +from app import app + +def update_user_to_admin(username): + """將指定使用者設為管理員權限""" + with app.app_context(): + user = User.query.filter_by(username=username).first() + if user: + print(f'找到使用者: {user.username}') + print(f'當前權限: {user.role}') + + if user.role != 'admin': + user.role = 'admin' + db.session.commit() + print(f'權限已更新為: {user.role}') + else: + print('使用者已經是管理員權限') + return True + else: + print(f'使用者 {username} 不存在') + return False + +if __name__ == "__main__": + username = 'ymirliu@panjit.com.tw' + print(f"正在更新使用者 {username} 的權限...") + + success = update_user_to_admin(username) + + if success: + print("\n管理員權限設定完成!") + else: + print("\n請先使用該帳號登入一次以建立使用者記錄") \ No newline at end of file diff --git a/utils/__init__.py b/utils/__init__.py new file mode 100644 index 0000000..e59a988 --- /dev/null +++ b/utils/__init__.py @@ -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 diff --git a/utils/timezone.py b/utils/timezone.py new file mode 100644 index 0000000..5f095ec --- /dev/null +++ b/utils/timezone.py @@ -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) diff --git a/wsgi.py b/wsgi.py new file mode 100644 index 0000000..d411e05 --- /dev/null +++ b/wsgi.py @@ -0,0 +1,27 @@ +#!/usr/bin/env python3 +""" +WSGI 生產環境入口點 +用於Gunicorn部署 +""" +import os +import logging +import sys +from app import app + +# 配置生產環境日誌 +if __name__ != "__main__": + # 只在Gunicorn環境下配置日誌 + gunicorn_logger = logging.getLogger('gunicorn.error') + app.logger.handlers = gunicorn_logger.handlers + app.logger.setLevel(gunicorn_logger.level) + +# 確保在生產環境 +os.environ['FLASK_ENV'] = 'production' + +if __name__ == "__main__": + # 開發環境直接運行 + print("🚀 開發環境啟動") + app.run(host='0.0.0.0', port=5000, debug=False) +else: + # 生產環境通過Gunicorn + print("🌟 生產環境啟動 (Gunicorn)") \ No newline at end of file