From 333a640a3b6035ca36f3d78887c3b40a13da1bc1 Mon Sep 17 00:00:00 2001 From: beabigegg Date: Thu, 25 Sep 2025 08:44:44 +0800 Subject: [PATCH] REMOVE LDAP --- .dockerignore | 67 +++ .env | 29 ++ .env.example | 33 ++ .gitignore | 56 +++ DEPLOYMENT.md | 93 +++++ Dockerfile | 49 +++ Dockerfile.redis | 17 + README.md | 89 ++++ USER_MANUAL.md | 80 ++++ app.py | 129 ++++++ cache_utils.py | 97 +++++ cdn_utils.py | 66 +++ config.py | 43 ++ docker-compose.prod.yml | 61 +++ docker-compose.yml | 156 +++++++ gunicorn.conf.py | 53 +++ init_db.py | 53 +++ models.py | 69 ++++ monitor.py | 163 ++++++++ mysql/init/01-init.sql | 18 + nginx/Dockerfile | 21 + nginx/conf.d/default.conf | 83 ++++ nginx/nginx.conf | 71 ++++ requirements.txt | 22 + routes/__init__.py | 0 routes/admin.py | 120 ++++++ routes/api.py | 9 + routes/auth.py | 93 +++++ routes/temp_spec.py | 522 ++++++++++++++++++++++++ routes/upload.py | 29 ++ start-production.sh | 102 +++++ static/css/style.css | 203 +++++++++ swagger.json | 338 +++++++++++++++ tasks.py | 62 +++ template_with_placeholders.docx | Bin 0 -> 27596 bytes templates/403.html | 12 + templates/404.html | 12 + templates/activate_spec.html | 41 ++ templates/base.html | 98 +++++ templates/create_temp_spec_form.html | 83 ++++ templates/extend_spec.html | 59 +++ templates/login.html | 43 ++ templates/onlyoffice_editor.html | 39 ++ templates/register.html | 53 +++ templates/spec_history.html | 34 ++ templates/spec_list.html | 169 ++++++++ templates/terminate_spec.html | 46 +++ templates/user_management.html | 172 ++++++++ tmp_decompiled/temp_spec.cpython-312.py | 6 + update_admin.py | 37 ++ utils/__init__.py | 144 +++++++ utils/timezone.py | 60 +++ wsgi.py | 27 ++ 53 files changed, 4231 insertions(+) create mode 100644 .dockerignore create mode 100644 .env create mode 100644 .env.example create mode 100644 .gitignore create mode 100644 DEPLOYMENT.md create mode 100644 Dockerfile create mode 100644 Dockerfile.redis create mode 100644 README.md create mode 100644 USER_MANUAL.md create mode 100644 app.py create mode 100644 cache_utils.py create mode 100644 cdn_utils.py create mode 100644 config.py create mode 100644 docker-compose.prod.yml create mode 100644 docker-compose.yml create mode 100644 gunicorn.conf.py create mode 100644 init_db.py create mode 100644 models.py create mode 100644 monitor.py create mode 100644 mysql/init/01-init.sql create mode 100644 nginx/Dockerfile create mode 100644 nginx/conf.d/default.conf create mode 100644 nginx/nginx.conf create mode 100644 requirements.txt create mode 100644 routes/__init__.py create mode 100644 routes/admin.py create mode 100644 routes/api.py create mode 100644 routes/auth.py create mode 100644 routes/temp_spec.py create mode 100644 routes/upload.py create mode 100644 start-production.sh create mode 100644 static/css/style.css create mode 100644 swagger.json create mode 100644 tasks.py create mode 100644 template_with_placeholders.docx create mode 100644 templates/403.html create mode 100644 templates/404.html create mode 100644 templates/activate_spec.html create mode 100644 templates/base.html create mode 100644 templates/create_temp_spec_form.html create mode 100644 templates/extend_spec.html create mode 100644 templates/login.html create mode 100644 templates/onlyoffice_editor.html create mode 100644 templates/register.html create mode 100644 templates/spec_history.html create mode 100644 templates/spec_list.html create mode 100644 templates/terminate_spec.html create mode 100644 templates/user_management.html create mode 100644 tmp_decompiled/temp_spec.cpython-312.py create mode 100644 update_admin.py create mode 100644 utils/__init__.py create mode 100644 utils/timezone.py create mode 100644 wsgi.py 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 0000000000000000000000000000000000000000..7bef1e32f82a6951d1b2ac00f50a595fdbd99a0f GIT binary patch literal 27596 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

X9}X?&?Ga8uL0I+oFx22Tj}OBWcY2N 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 +

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